mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-06 23:06:20 +01:00
Merge branch 'main' into dev/dmitriv/update-mode-none
This commit is contained in:
@@ -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` selects among in `getPolicyData()` via `selectManagedSettings(nativeMdm, server, file)` (single authoritative source by precedence native MDM > server > file; no merging between layers) |
|
||||
| **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` |
|
||||
@@ -41,7 +41,7 @@ Policies allow enterprise administrators to lock configuration settings via OS-l
|
||||
| `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=<extensionPolicyFixture.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.
|
||||
|
||||
@@ -52,8 +52,8 @@ 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`).
|
||||
**Precedence: native MDM managed settings win over the server-delivered channel, which in
|
||||
turn wins 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
|
||||
@@ -201,7 +201,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. |
|
||||
| `selectManagedSettings(nativeMdm, server, file)` | Picks the single authoritative channel by precedence (native MDM → server → file); never merges. **The extension point when adding a new channel** — extend the `ManagedSettingsSource` union 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
|
||||
|
||||
Generated
+48
-36
@@ -13,7 +13,7 @@
|
||||
"@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.66-2",
|
||||
"@google/genai": "^1.22.0",
|
||||
"@humanwhocodes/gitignore-to-minimatch": "1.0.2",
|
||||
"@microsoft/tiktokenizer": "^1.0.10",
|
||||
@@ -2952,9 +2952,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.66-2.tgz",
|
||||
"integrity": "sha512-nAhhtfjpryklyombieuu18NK2g+BmEk4/8qvXVj8k+w/63tiVpLxFh865Vf6NQiVh/S7hbjMghTbrptsspYg2w==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.1.2"
|
||||
@@ -2963,20 +2963,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.66-2",
|
||||
"@github/copilot-darwin-x64": "1.0.66-2",
|
||||
"@github/copilot-linux-arm64": "1.0.66-2",
|
||||
"@github/copilot-linux-x64": "1.0.66-2",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.66-2",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.66-2",
|
||||
"@github/copilot-win32-arm64": "1.0.66-2",
|
||||
"@github/copilot-win32-x64": "1.0.66-2"
|
||||
}
|
||||
},
|
||||
"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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-gjLRtAQOdFQUOTm7nYi+zufkGxMlQlTzUyncQ3W4u1+WdGQbx5fWqMg/yd+j1yMN9PEETyF/ZHZqAaFWkEpQww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2990,9 +2990,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-wWWBsVwJtRTXqCK8lVpzwbJd3Tm1F23avf942K+PmsGYiZZYNcS5pt4umQRRj0sHKgO/muuA4eg/tMfGNi5fgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3006,12 +3006,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-j0hjx77JNFR3ZS8z3flY2j5SfGZMfKigYVFpDlTJM8FhfkMCUJ5IUhsZwSTimhHlxrsXuI31S6g0WsZLmBUe6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3022,12 +3025,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-vWaNbh4WdwkiI40Thcfbwi8tZFKo06r+Dm9Zfb8uY4wAz3X5PaGeSq+8XrNoV3uaRWltI0ncSIrq5tSOyDtRPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3038,12 +3044,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-LbWy5NlWasBeV/i+Xol+8dW7kbAQr6MF46apbseRNHYkhwyF/417WtLfirP8O2hPuqyU72q/HAQziFXkz14pIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3054,12 +3063,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-djOu52fGIU7eUhQdUS0K5xB2eFdi8LTTbxvphHWlrN1AD1BdZ+VX9Pk2avt6yCfW+Hh0loh2pNsCbTfNyxvULA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3070,9 +3082,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-YQu01atiwoz8XfrHKqvI1xNjnc2IIIxgJDkQ6PxwrWPZ4IO320izwlXbW2ZaOz9yDgjWNis6EJ4Ryz8K+mM6kg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3086,9 +3098,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-4/kTs+lKc67f7KEAQ+Gt3sEBFDSEGoUxJujddV/+fS8EAg9uF2g6e3NzS1I4+htyRM4Oq/Z6xfWjGUgQsi9rfw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -4062,19 +4062,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,
|
||||
@@ -4364,6 +4351,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"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7194,7 +7199,7 @@
|
||||
"@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.66-2",
|
||||
"@google/genai": "^1.22.0",
|
||||
"@humanwhocodes/gitignore-to-minimatch": "1.0.2",
|
||||
"@microsoft/tiktokenizer": "^1.0.10",
|
||||
|
||||
@@ -279,6 +279,8 @@
|
||||
"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.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",
|
||||
@@ -344,7 +346,6 @@
|
||||
"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.",
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
@@ -176,6 +176,7 @@ suite('LanguageModelAccess model info', () => {
|
||||
testingServiceCollection.define(IAutomodeService, {
|
||||
_serviceBrand: undefined,
|
||||
resolveAutoModeEndpoint: async () => endpoint,
|
||||
consumeLastRoutingDecision: () => undefined,
|
||||
invalidateRouterCache: () => { },
|
||||
} as unknown as IAutomodeService);
|
||||
testingServiceCollection.define(IEndpointProvider, {
|
||||
|
||||
@@ -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';
|
||||
@@ -464,6 +464,12 @@ 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 {
|
||||
|
||||
@@ -205,5 +205,9 @@ class NullAutomodeService implements IAutomodeService {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
consumeLastRoutingDecision(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
invalidateRouterCache(): void { }
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@ interface IFindTextInFilesToolParams {
|
||||
includeIgnoredFiles?: boolean;
|
||||
}
|
||||
|
||||
const MaxResultsCap = 200;
|
||||
|
||||
interface FileMatch {
|
||||
path: string;
|
||||
matches: vscode.TextSearchMatch2[];
|
||||
@@ -96,11 +94,13 @@ export class FindTextInFilesTool implements ICopilotTool<IFindTextInFilesToolPar
|
||||
|
||||
const outputFormat = this.getOutputFormat();
|
||||
const useGrepStyle = outputFormat === 'grep';
|
||||
void this.sendSearchToolTelemetry(options, globResult, outputFormat);
|
||||
const defaultMaxResults = this.getDefaultMaxResults();
|
||||
const maxResultsCap = this.getMaxResultsCap();
|
||||
void this.sendSearchToolTelemetry(options, globResult, outputFormat, defaultMaxResults, maxResultsCap);
|
||||
|
||||
checkCancellation(token);
|
||||
const askedForTooManyResults = options.input.maxResults && options.input.maxResults > 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 ?? defaultMaxResults, maxResultsCap);
|
||||
const isRegExp = options.input.isRegexp ?? true;
|
||||
const queryIsValidRegex = this.isValidRegex(options.input.query);
|
||||
const includeIgnoredFiles = options.input.includeIgnoredFiles ?? false;
|
||||
@@ -154,14 +154,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<IFindTextInFilesToolParams>, maxResults: number, globResult: InputGlobResult | undefined, askedForTooManyResults: boolean | number | undefined, isRegExp: boolean, noMatchInstructions: string | undefined, token: CancellationToken): Promise<vscode.ExtendedLanguageModelToolResult> {
|
||||
private async renderTagStyle(results: vscode.TextSearchResult2[], options: vscode.LanguageModelToolInvocationOptions<IFindTextInFilesToolParams>, maxResults: number, maxResultsCap: number, globResult: InputGlobResult | undefined, askedForTooManyResults: boolean | number | undefined, isRegExp: boolean, noMatchInstructions: string | undefined, token: CancellationToken): Promise<vscode.ExtendedLanguageModelToolResult> {
|
||||
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 +306,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<IFindTextInFilesToolParams>, globResult: InputGlobResult | undefined, outputFormat: string): Promise<void> {
|
||||
private async sendSearchToolTelemetry(options: vscode.LanguageModelToolInvocationOptions<IFindTextInFilesToolParams>, globResult: InputGlobResult | undefined, outputFormat: string, defaultMaxResults: number, maxResultsCap: number): Promise<void> {
|
||||
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 +320,9 @@ 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" },
|
||||
"defaultMaxResults": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The default maximum number of results" },
|
||||
"maxResultsCap": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The maximum number of results that can be returned" }
|
||||
}
|
||||
*/
|
||||
this.telemetryService.sendMSFTTelemetryEvent('findTextInFilesToolInvoked', {
|
||||
@@ -331,6 +333,9 @@ 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
|
||||
},{
|
||||
defaultMaxResults,
|
||||
maxResultsCap
|
||||
});
|
||||
}
|
||||
|
||||
@@ -451,12 +456,23 @@ Then if you want to include those files you can call the tool again by setting "
|
||||
const expFlag = this.configurationService.getExperimentBasedConfig(ConfigKey.GrepSearchOutputFormat, this.experimentationService);
|
||||
return expFlag === 'grep' ? 'grep' : 'tag';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
ToolRegistry.registerTool(FindTextInFilesTool);
|
||||
export interface FindTextInFilesResultProps extends BasePromptElementProps {
|
||||
textResults: vscode.TextSearchResult2[];
|
||||
maxResults: number;
|
||||
maxResultsCap: number;
|
||||
askedForTooManyResults?: boolean;
|
||||
noMatchInstructions?: string;
|
||||
}
|
||||
@@ -479,7 +495,7 @@ export class FindTextInFilesResult extends PromptElement<FindTextInFilesResultPr
|
||||
const resultCountToDisplay = Math.min(numResults, this.props.maxResults);
|
||||
const numResultsText = numResults === 1 ? '1 match' : `${resultCountToDisplay} matches`;
|
||||
const maxResultsText = numResults > 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 <>
|
||||
{<TextChunk priority={20}>{numResultsText}{maxResultsText}{maxResultsTooLargeText}</TextChunk>}
|
||||
{textMatches.flatMap(result => {
|
||||
|
||||
@@ -30,7 +30,7 @@ suite('FindTextInFilesResult', () => {
|
||||
const clz = class extends PromptElement {
|
||||
render() {
|
||||
return <UserMessage>
|
||||
<FindTextInFilesResult textResults={results} maxResults={20} />
|
||||
<FindTextInFilesResult textResults={results} maxResults={20} maxResultsCap={200} />
|
||||
</UserMessage>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -904,15 +904,6 @@ export namespace ConfigKey {
|
||||
export const InlineEditsJointCompletionsProviderTriggerChangeStrategy = defineTeamInternalSetting<JointCompletionsProviderTriggerChangeStrategy>('chat.advanced.inlineEdits.jointCompletionsProvider.triggerChangeStrategy', ConfigType.ExperimentBased, JointCompletionsProviderTriggerChangeStrategy.NoTriggerOnCompletionsRequestInFlight);
|
||||
export const InstantApplyModelName = defineTeamInternalSetting<string>('chat.advanced.instantApply.modelName', ConfigType.ExperimentBased, CHAT_MODEL.GPT4OPROXY);
|
||||
export const VerifyTextDocumentChanges = defineTeamInternalSetting<boolean>('chat.advanced.inlineEdits.verifyTextDocumentChanges', ConfigType.ExperimentBased, false);
|
||||
export const UseAutoModeRouting = defineTeamInternalSetting<boolean>('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<string>('chat.advanced.autoModeRoutingMethod', ConfigType.ExperimentBased, '', undefined, undefined, { experimentName: 'copilotchat.autoModeRoutingMethod' });
|
||||
|
||||
/** Inline Completions */
|
||||
export const InlineCompletionsDefaultDiagnosticsOptions = defineTeamInternalSetting<string | undefined>('chat.advanced.inlineCompletions.defaultDiagnosticsOptionsString', ConfigType.ExperimentBased, undefined);
|
||||
@@ -973,8 +964,6 @@ export namespace ConfigKey {
|
||||
export const UseAnthropicMessagesApi = defineSetting<boolean | undefined>('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<boolean>('chat.responsesApiContextManagement.enabled', ConfigType.ExperimentBased, false);
|
||||
/** Enable client-side prompt_cache_key (conversationId:modelFamily) sent to Responses API */
|
||||
@@ -1106,6 +1095,8 @@ export namespace ConfigKey {
|
||||
|
||||
/** grep_search configs */
|
||||
export const GrepSearchOutputFormat = defineSetting<'grep' | 'tag'>('chat.tools.grepSearch.outputFormat', ConfigType.ExperimentBased, 'tag');
|
||||
export const GrepSearchDefaultMaxResults = defineSetting<number>('chat.tools.grepSearch.defaultMaxResults', ConfigType.ExperimentBased, 20);
|
||||
export const GrepSearchMaxResultsCap = defineSetting<number>('chat.tools.grepSearch.maxResultsCap', ConfigType.ExperimentBased, 200);
|
||||
}
|
||||
|
||||
export function getAllConfigKeys(): string[] {
|
||||
|
||||
@@ -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>('IAutomodeService');
|
||||
|
||||
export interface IAutomodeService {
|
||||
@@ -111,6 +117,13 @@ export interface IAutomodeService {
|
||||
|
||||
resolveAutoModeEndpoint(chatRequest: ChatRequest | undefined, knownEndpoints: IChatEndpoint[]): Promise<IChatEndpoint>;
|
||||
|
||||
/**
|
||||
* 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<string, AutoModelCacheEntry> = new Map();
|
||||
private _reserveTokens: DisposableMap<ChatLocation, AutoModeTokenBank> = 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<ImageTelemetryMeasurements>,
|
||||
): 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
|
||||
@@ -387,7 +419,17 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
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}`);
|
||||
}
|
||||
return { selectedModel, lastRoutedPrompt: prompt, candidateModel: result.candidate_models[0] };
|
||||
return {
|
||||
selectedModel,
|
||||
lastRoutedPrompt: prompt,
|
||||
candidateModel: result.candidate_models[0],
|
||||
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 +482,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -155,14 +155,11 @@ 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 summary: string | undefined = undefined;
|
||||
const persistentCoTEnabled = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiPersistentCoTEnabled, expService)
|
||||
&& (isGpt54(endpoint) || isGpt55(endpoint) || isHiddenModelM(endpoint));
|
||||
if (effort || summary || persistentCoTEnabled) {
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface RouterDecisionResponse {
|
||||
hydra_scores?: Record<string, number>;
|
||||
chosen_model?: string;
|
||||
chosen_shortfall?: number;
|
||||
reasoning_bucket?: string;
|
||||
}
|
||||
|
||||
export interface RoutingContextSignals {
|
||||
|
||||
@@ -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<typeof vi.fn>; sendMSFTTelemetryEvent: ReturnType<typeof vi.fn> };
|
||||
@@ -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<ChatRequest> = {
|
||||
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();
|
||||
|
||||
|
||||
@@ -378,7 +378,7 @@ describe('createResponsesRequestBody', () => {
|
||||
|
||||
const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions([], false), endpoint.model, endpoint));
|
||||
|
||||
expect(body.reasoning).toEqual({ effort: 'medium', summary: 'detailed', context: 'all_turns' });
|
||||
expect(body.reasoning).toEqual({ effort: 'medium', context: 'all_turns' });
|
||||
|
||||
accessor.dispose();
|
||||
services.dispose();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+53
-41
@@ -11,8 +11,8 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.82.0",
|
||||
"@github/copilot": "^1.0.65",
|
||||
"@github/copilot-sdk": "^1.0.4",
|
||||
"@github/copilot": "^1.0.66-2",
|
||||
"@github/copilot-sdk": "^1.0.5-preview.0",
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@microsoft/dev-tunnels-connections": "^1.3.41",
|
||||
@@ -1107,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.66-2.tgz",
|
||||
"integrity": "sha512-nAhhtfjpryklyombieuu18NK2g+BmEk4/8qvXVj8k+w/63tiVpLxFh865Vf6NQiVh/S7hbjMghTbrptsspYg2w==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.1.2"
|
||||
@@ -1118,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.66-2",
|
||||
"@github/copilot-darwin-x64": "1.0.66-2",
|
||||
"@github/copilot-linux-arm64": "1.0.66-2",
|
||||
"@github/copilot-linux-x64": "1.0.66-2",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.66-2",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.66-2",
|
||||
"@github/copilot-win32-arm64": "1.0.66-2",
|
||||
"@github/copilot-win32-x64": "1.0.66-2"
|
||||
}
|
||||
},
|
||||
"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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-gjLRtAQOdFQUOTm7nYi+zufkGxMlQlTzUyncQ3W4u1+WdGQbx5fWqMg/yd+j1yMN9PEETyF/ZHZqAaFWkEpQww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1145,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-wWWBsVwJtRTXqCK8lVpzwbJd3Tm1F23avf942K+PmsGYiZZYNcS5pt4umQRRj0sHKgO/muuA4eg/tMfGNi5fgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1161,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-j0hjx77JNFR3ZS8z3flY2j5SfGZMfKigYVFpDlTJM8FhfkMCUJ5IUhsZwSTimhHlxrsXuI31S6g0WsZLmBUe6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1177,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-vWaNbh4WdwkiI40Thcfbwi8tZFKo06r+Dm9Zfb8uY4wAz3X5PaGeSq+8XrNoV3uaRWltI0ncSIrq5tSOyDtRPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1193,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-LbWy5NlWasBeV/i+Xol+8dW7kbAQr6MF46apbseRNHYkhwyF/417WtLfirP8O2hPuqyU72q/HAQziFXkz14pIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1209,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-djOu52fGIU7eUhQdUS0K5xB2eFdi8LTTbxvphHWlrN1AD1BdZ+VX9Pk2avt6yCfW+Hh0loh2pNsCbTfNyxvULA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1225,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.5-preview.0",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.5-preview.0.tgz",
|
||||
"integrity": "sha512-XFXTemA4yrHEHJY/t7FPXLYEchKjGeLcNl5l1sWT0YT58qilfNjEQSfXS+t5IrCJH6l43IHufcs8YQ8KZ7gKfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.65",
|
||||
"@github/copilot": "^1.0.66-1",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -1248,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-YQu01atiwoz8XfrHKqvI1xNjnc2IIIxgJDkQ6PxwrWPZ4IO320izwlXbW2ZaOz9yDgjWNis6EJ4Ryz8K+mM6kg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1264,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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-4/kTs+lKc67f7KEAQ+Gt3sEBFDSEGoUxJujddV/+fS8EAg9uF2g6e3NzS1I4+htyRM4Oq/Z6xfWjGUgQsi9rfw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
+2
-2
@@ -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.66-2",
|
||||
"@github/copilot-sdk": "^1.0.5-preview.0",
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@microsoft/dev-tunnels-connections": "^1.3.41",
|
||||
|
||||
Generated
+53
-41
@@ -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.66-2",
|
||||
"@github/copilot-sdk": "^1.0.5-preview.0",
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@microsoft/mxc-sdk": "0.6.1",
|
||||
@@ -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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.66-2.tgz",
|
||||
"integrity": "sha512-nAhhtfjpryklyombieuu18NK2g+BmEk4/8qvXVj8k+w/63tiVpLxFh865Vf6NQiVh/S7hbjMghTbrptsspYg2w==",
|
||||
"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.66-2",
|
||||
"@github/copilot-darwin-x64": "1.0.66-2",
|
||||
"@github/copilot-linux-arm64": "1.0.66-2",
|
||||
"@github/copilot-linux-x64": "1.0.66-2",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.66-2",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.66-2",
|
||||
"@github/copilot-win32-arm64": "1.0.66-2",
|
||||
"@github/copilot-win32-x64": "1.0.66-2"
|
||||
}
|
||||
},
|
||||
"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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-gjLRtAQOdFQUOTm7nYi+zufkGxMlQlTzUyncQ3W4u1+WdGQbx5fWqMg/yd+j1yMN9PEETyF/ZHZqAaFWkEpQww==",
|
||||
"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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-wWWBsVwJtRTXqCK8lVpzwbJd3Tm1F23avf942K+PmsGYiZZYNcS5pt4umQRRj0sHKgO/muuA4eg/tMfGNi5fgA==",
|
||||
"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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-j0hjx77JNFR3ZS8z3flY2j5SfGZMfKigYVFpDlTJM8FhfkMCUJ5IUhsZwSTimhHlxrsXuI31S6g0WsZLmBUe6A==",
|
||||
"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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-vWaNbh4WdwkiI40Thcfbwi8tZFKo06r+Dm9Zfb8uY4wAz3X5PaGeSq+8XrNoV3uaRWltI0ncSIrq5tSOyDtRPg==",
|
||||
"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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-LbWy5NlWasBeV/i+Xol+8dW7kbAQr6MF46apbseRNHYkhwyF/417WtLfirP8O2hPuqyU72q/HAQziFXkz14pIQ==",
|
||||
"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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-djOu52fGIU7eUhQdUS0K5xB2eFdi8LTTbxvphHWlrN1AD1BdZ+VX9Pk2avt6yCfW+Hh0loh2pNsCbTfNyxvULA==",
|
||||
"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.5-preview.0",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.5-preview.0.tgz",
|
||||
"integrity": "sha512-XFXTemA4yrHEHJY/t7FPXLYEchKjGeLcNl5l1sWT0YT58qilfNjEQSfXS+t5IrCJH6l43IHufcs8YQ8KZ7gKfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.65",
|
||||
"@github/copilot": "^1.0.66-1",
|
||||
"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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-YQu01atiwoz8XfrHKqvI1xNjnc2IIIxgJDkQ6PxwrWPZ4IO320izwlXbW2ZaOz9yDgjWNis6EJ4Ryz8K+mM6kg==",
|
||||
"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.66-2",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.66-2.tgz",
|
||||
"integrity": "sha512-4/kTs+lKc67f7KEAQ+Gt3sEBFDSEGoUxJujddV/+fS8EAg9uF2g6e3NzS1I4+htyRM4Oq/Z6xfWjGUgQsi9rfw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
+2
-2
@@ -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.66-2",
|
||||
"@github/copilot-sdk": "^1.0.5-preview.0",
|
||||
"@microsoft/1ds-core-js": "^3.2.13",
|
||||
"@microsoft/1ds-post-js": "^3.2.13",
|
||||
"@microsoft/mxc-sdk": "0.6.1",
|
||||
|
||||
@@ -151,6 +151,10 @@ export abstract class CachedListVirtualDelegate<T extends object> 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;
|
||||
|
||||
|
||||
@@ -1620,12 +1620,7 @@ export class ListView<T> implements IListView<T> {
|
||||
|
||||
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<T>, index: number): number {
|
||||
@@ -1635,6 +1630,7 @@ export class ListView<T> implements IListView<T> {
|
||||
const size = item.size;
|
||||
item.size = newSize;
|
||||
item.lastDynamicHeightWidth = this.renderWidth;
|
||||
this.publishDynamicHeight(item);
|
||||
return newSize - size;
|
||||
}
|
||||
}
|
||||
@@ -1660,6 +1656,7 @@ export class ListView<T> implements IListView<T> {
|
||||
}
|
||||
}
|
||||
item.lastDynamicHeightWidth = this.renderWidth;
|
||||
this.publishDynamicHeight(item);
|
||||
return item.size - size;
|
||||
}
|
||||
|
||||
@@ -1678,12 +1675,19 @@ export class ListView<T> implements IListView<T> {
|
||||
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<T>): void {
|
||||
if (item.size > 0) {
|
||||
this.virtualDelegate.setDynamicHeight?.(item.element, item.size);
|
||||
}
|
||||
}
|
||||
|
||||
getElementDomId(index: number): string {
|
||||
return `${this.domId}_${index}`;
|
||||
}
|
||||
|
||||
@@ -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<string, string> | undefined {
|
||||
if (!entries?.length) {
|
||||
@@ -46,8 +50,14 @@ export function extraKnownMarketplacesToConfigDict(entries: readonly (string | I
|
||||
const obj: Record<string, string> = {};
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -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<TestElement> {
|
||||
protected estimateHeight() { return 100; }
|
||||
getTemplateId() { return 'template'; }
|
||||
hasDynamicHeight() { return true; }
|
||||
getMeasuredHeight(element: TestElement) { return this.getCachedHeight(element); }
|
||||
};
|
||||
const renderer: IListRenderer<TestElement, HTMLElement> = {
|
||||
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<TestElement>(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<TestElement, number>();
|
||||
const delegate: IListVirtualDelegate<TestElement> = {
|
||||
getHeight() { return 100; },
|
||||
getTemplateId() { return 'template'; },
|
||||
getDynamicHeight(element) { return element.height; },
|
||||
setDynamicHeight(element, height) { publishedHeights.set(element, height); }
|
||||
};
|
||||
const renderer: IListRenderer<TestElement, void> = {
|
||||
templateId: 'template',
|
||||
renderTemplate() { },
|
||||
renderElement() { },
|
||||
disposeTemplate() { }
|
||||
};
|
||||
|
||||
const elements: TestElement[] = [{ height: 0 }, { height: 40 }, { height: 100 }, { height: 160 }];
|
||||
const listView = new ListView<TestElement>(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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ export const enum AccessibleViewProviderId {
|
||||
OutputFindHelp = 'outputFindHelp',
|
||||
ProblemsFilterHelp = 'problemsFilterHelp',
|
||||
SessionsChat = 'sessionsChat',
|
||||
SessionsChanges = 'sessionsChanges',
|
||||
Survey = 'survey',
|
||||
}
|
||||
|
||||
|
||||
@@ -1092,6 +1092,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 `<session>/changeset/branch`. Self-defers when the
|
||||
|
||||
@@ -792,6 +792,13 @@ export interface IAgentCreateSessionConfig {
|
||||
*/
|
||||
readonly turnIdMapping?: ReadonlyMap<string, string>;
|
||||
};
|
||||
/**
|
||||
* 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. */
|
||||
|
||||
@@ -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,17 @@ 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 [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: branchChangesetLabel(),
|
||||
description: gitState
|
||||
? formatBranchChangesetDescription(gitState)
|
||||
: undefined,
|
||||
uriTemplate: buildBranchChangesetUri(sessionUri),
|
||||
changeKind: ChangesetKind.Branch
|
||||
},
|
||||
|
||||
@@ -1 +1 @@
|
||||
80454fd
|
||||
e793e92
|
||||
|
||||
@@ -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 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 ChatActivityChangedAction, 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';
|
||||
|
||||
|
||||
// ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ─────────────────
|
||||
@@ -103,6 +103,7 @@ export type ChatAction =
|
||||
| ChatTurnCompleteAction
|
||||
| ChatTurnCancelledAction
|
||||
| ChatErrorAction
|
||||
| ChatActivityChangedAction
|
||||
| ChatUsageAction
|
||||
| ChatReasoningAction
|
||||
| ChatPendingMessageSetAction
|
||||
@@ -141,6 +142,7 @@ export type ServerChatAction =
|
||||
| ChatToolCallReadyAction
|
||||
| ChatTurnCompleteAction
|
||||
| ChatErrorAction
|
||||
| ChatActivityChangedAction
|
||||
| ChatUsageAction
|
||||
| ChatReasoningAction
|
||||
| ChatInputRequestedAction
|
||||
@@ -290,6 +292,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,
|
||||
|
||||
@@ -386,6 +386,23 @@ export interface ChatErrorAction {
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -626,6 +643,7 @@ export type ChatAction =
|
||||
| ChatTurnCompleteAction
|
||||
| ChatTurnCancelledAction
|
||||
| ChatErrorAction
|
||||
| ChatActivityChangedAction
|
||||
| ChatUsageAction
|
||||
| ChatReasoningAction
|
||||
| ChatTruncatedAction
|
||||
|
||||
@@ -312,6 +312,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:
|
||||
|
||||
@@ -143,3 +143,82 @@ export interface SessionSummaryChangedParams {
|
||||
*/
|
||||
changes: Partial<SessionSummary>;
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -223,11 +223,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`).
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerm
|
||||
|
||||
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 { 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 } from '../channels-chat/actions.js';
|
||||
|
||||
import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js';
|
||||
|
||||
@@ -51,6 +51,7 @@ 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',
|
||||
@@ -176,6 +177,7 @@ export type StateAction =
|
||||
| ChatTurnCompleteAction
|
||||
| ChatTurnCancelledAction
|
||||
| ChatErrorAction
|
||||
| ChatActivityChangedAction
|
||||
| ChatUsageAction
|
||||
| ChatReasoningAction
|
||||
| ChatPendingMessageSetAction
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -112,6 +112,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',
|
||||
@@ -178,6 +179,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',
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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 { 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 { isAhpChatChannel, 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);
|
||||
}
|
||||
|
||||
@@ -329,82 +333,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
@@ -120,7 +120,7 @@ export class AgentHostChangesetOperationService extends Disposable implements IA
|
||||
|
||||
this._stateManager.dispatchServerAction(changeset, {
|
||||
type: ActionType.ChangesetOperationsChanged,
|
||||
operations: operations ? [...operations] : undefined,
|
||||
operations: [...operations],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +29,7 @@ import {
|
||||
type URI as ProtocolURI,
|
||||
readSessionGitState,
|
||||
isDefaultChatUri,
|
||||
SessionLifecycle,
|
||||
} from '../common/state/sessionState.js';
|
||||
import { AgentHostStateManager } from './agentHostStateManager.js';
|
||||
import { IAgentConfigurationService } from './agentConfigurationService.js';
|
||||
@@ -85,7 +87,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(
|
||||
@@ -304,6 +306,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);
|
||||
|
||||
@@ -32,13 +32,13 @@ export class AgentHostCommitOperationContribution extends Disposable implements
|
||||
return store;
|
||||
}
|
||||
|
||||
getOperations({ changesetKind, gitHubState, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined {
|
||||
getOperations({ changesetKind, gitHubState, gitState }: IChangesetOperationContext): ChangesetOperation[] {
|
||||
if ((gitState?.uncommittedChanges ?? 0) <= 0) {
|
||||
return undefined;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!gitHubState?.pullRequestUrl && changesetKind !== 'uncommitted') {
|
||||
return undefined;
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
|
||||
@@ -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 [{
|
||||
|
||||
@@ -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';
|
||||
@@ -29,7 +29,7 @@ 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 { ProtocolServerHandler } from './protocolServerHandler.js';
|
||||
@@ -132,6 +132,9 @@ async function startAgentHost(): Promise<void> {
|
||||
// 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<IAgentSdkDownloadProgress> | undefined;
|
||||
try {
|
||||
// Build the DI container early so the git service can be created via
|
||||
// `createInstance` (it needs IFileService + INativeEnvironmentService).
|
||||
@@ -162,8 +165,9 @@ async function startAgentHost(): Promise<void> {
|
||||
// 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);
|
||||
sdkDownloadProgress = agentSdkDownloader.onDidDownloadProgress;
|
||||
const copilotApiService = instantiationService.createInstance(CopilotApiService, undefined);
|
||||
diServices.set(ICopilotApiService, copilotApiService);
|
||||
diServices.set(ICopilotBranchNameGenerator, instantiationService.createInstance(CopilotBranchNameGenerator));
|
||||
@@ -227,6 +231,23 @@ async function startAgentHost(): Promise<void> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -41,7 +42,7 @@ 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';
|
||||
@@ -249,6 +250,7 @@ async function main(): Promise<void> {
|
||||
diServices.set(IAgentService, agentService);
|
||||
|
||||
// Register agents
|
||||
let sdkDownloadProgress: Event<IAgentSdkDownloadProgress> | undefined;
|
||||
if (!options.quiet) {
|
||||
// Production agents (require DI)
|
||||
const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService);
|
||||
@@ -273,8 +275,9 @@ async function main(): Promise<void> {
|
||||
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);
|
||||
@@ -311,6 +314,22 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }) => {
|
||||
|
||||
@@ -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 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';
|
||||
@@ -1354,4 +1354,21 @@ 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<ProgressParams, 'channel'>): void {
|
||||
this._onDidEmitNotification.fire({
|
||||
type: 'root/progress',
|
||||
channel: ROOT_STATE_URI,
|
||||
...progress,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IAgentSdkDownloader>('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<IAgentSdkDownloadProgress>;
|
||||
|
||||
/**
|
||||
* 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<boolean>;
|
||||
}
|
||||
|
||||
// #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<IAgentSdkDownloadProgress>());
|
||||
readonly onDidDownloadProgress: Event<IAgentSdkDownloadProgress> = this._onDidDownloadProgress.event;
|
||||
|
||||
/**
|
||||
* In-flight downloads keyed by the destination `cacheDir` (which
|
||||
* already encodes `<pkg>/<sdkVersion>/<sdkTarget>`). 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<boolean> {
|
||||
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<string> {
|
||||
// 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<void> {
|
||||
private async _fetch(
|
||||
url: string,
|
||||
dest: string,
|
||||
token: CancellationToken,
|
||||
onBytes?: (receivedBytes: number, totalBytes: number | undefined) => void,
|
||||
): Promise<void> {
|
||||
// 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<void>((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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ServiceCollection } from '../../instantiation/common/serviceCollection.
|
||||
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 { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js';
|
||||
import { buildDefaultChangesetCatalogue, parseChangesetUri } from '../common/changesetUri.js';
|
||||
import { 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 type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js';
|
||||
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
|
||||
@@ -119,6 +119,18 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
private readonly _providers = new Map<AgentProvider, IAgent>();
|
||||
/** Maps each active session URI (toString) to its owning provider. */
|
||||
private readonly _sessionToProvider = new Map<string, AgentProvider>();
|
||||
/**
|
||||
* 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<AgentProvider, Set<string>>();
|
||||
/** Subscriptions to provider progress events; cleared when providers change. */
|
||||
private readonly _providerSubscriptions = this._register(new DisposableStore());
|
||||
private readonly _authService: AgentHostAuthenticationService;
|
||||
@@ -606,6 +618,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<string>();
|
||||
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
|
||||
@@ -688,19 +713,6 @@ 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) {
|
||||
@@ -819,6 +831,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}`);
|
||||
@@ -848,16 +863,58 @@ 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.
|
||||
*/
|
||||
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 _persistConfigValues(session: URI, values: Record<string, unknown>): void {
|
||||
let ref;
|
||||
try {
|
||||
@@ -922,6 +979,7 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
if (provider) {
|
||||
await provider.disposeSession(session);
|
||||
this._sessionToProvider.delete(session.toString());
|
||||
this._clearDownloadProgressInterest(session.toString());
|
||||
}
|
||||
this._changesetCoordinator.onSessionDisposed(session.toString());
|
||||
this._sideEffects.cancelSessionTitleGeneration(session.toString());
|
||||
@@ -1643,9 +1701,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
|
||||
@@ -2197,6 +2252,7 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
}
|
||||
await Promise.all(promises);
|
||||
this._sessionToProvider.clear();
|
||||
this._downloadProgressInterest.clear();
|
||||
}
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
@@ -990,6 +990,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -449,6 +449,7 @@ interface IConnectionReady {
|
||||
*/
|
||||
export const CodexSdkPackage: IAgentSdkPackage = {
|
||||
id: 'codex',
|
||||
displayName: 'Codex',
|
||||
devOverrideEnvVar: AgentHostCodexAgentSdkRootEnvVar,
|
||||
hasSeparateMuslLinuxPackage: false,
|
||||
};
|
||||
@@ -1766,7 +1767,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 +1785,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)}`);
|
||||
});
|
||||
}
|
||||
@@ -2243,6 +2253,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', {
|
||||
|
||||
@@ -18,13 +18,14 @@ import type { ModelSelection, ToolDefinition } from '../../common/state/protocol
|
||||
import type { ActiveClientToolSet } from '../activeClientState.js';
|
||||
import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
|
||||
import { ShellManager, createShellTools, type IUnsandboxedCommandConfirmationRequest } from './copilotShellTools.js';
|
||||
import { toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSessionCustomAgents, toSdkSkillDirectories } from './copilotPluginConverters.js';
|
||||
import { toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSessionCustomAgents, toSdkSkillDirectories } from './copilotPluginConverters.js';
|
||||
import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js';
|
||||
import type { ITypedPermissionRequest } from './copilotToolDisplay.js';
|
||||
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';
|
||||
/**
|
||||
@@ -245,13 +246,14 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher {
|
||||
}
|
||||
|
||||
try {
|
||||
this._logService.info(`[Copilot:${plan.sessionId}] Calling SDK resumeSession...`);
|
||||
const stopWatch = new StopWatch();
|
||||
this._logService.trace(`[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`);
|
||||
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) {
|
||||
@@ -374,13 +376,10 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher {
|
||||
return {
|
||||
clientName: 'vscode',
|
||||
enableMcpApps: true,
|
||||
enableFileHooks: true,
|
||||
onPermissionRequest: request => runtime.handlePermissionRequest(request),
|
||||
onUserInputRequest: (request, invocation) => runtime.handleUserInputRequest(request, invocation),
|
||||
onElicitationRequest: context => runtime.handleElicitationRequest(context),
|
||||
hooks: toSdkHooks(pluginsWithoutDirs.flatMap(p => p.hooks), {
|
||||
onPreToolUse: input => runtime.handlePreToolUse(input),
|
||||
onPostToolUse: input => runtime.handlePostToolUse(input),
|
||||
}),
|
||||
mcpServers: { ...toSdkMcpServersFromConfigMap(plan.snapshot.mcpServers), ...toSdkMcpServers(pluginsWithoutDirs.flatMap(p => p.mcpServers)) },
|
||||
onExitPlanModeRequest: (request, invocation) => runtime.handleExitPlanModeRequest(request, invocation),
|
||||
workingDirectory: plan.workingDirectory?.fsPath,
|
||||
|
||||
@@ -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.
|
||||
@@ -248,38 +249,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 +267,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 +443,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 +456,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 +480,7 @@ export class SessionCustomizationDiscovery extends Disposable {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
for (const [type, files] of filesByType.entries()) {
|
||||
if (files.length > 0) {
|
||||
@@ -492,7 +509,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 +524,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[] = [];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: () => { },
|
||||
@@ -451,6 +451,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);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ 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';
|
||||
@@ -77,7 +77,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, });
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ suite.skip('AgentHostChangesetService', () => {
|
||||
modifiedAt: new Date().toISOString(),
|
||||
workingDirectory,
|
||||
});
|
||||
localStateManager.setSessionChangesets(sessionStr, buildDefaultChangesetCatalogue(sessionStr));
|
||||
localStateManager.setSessionChangesets(sessionStr, buildDefaultChangesetCatalog(sessionStr));
|
||||
return sessionStr;
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,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 { }
|
||||
|
||||
@@ -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), undefined);
|
||||
assert.deepStrictEqual(operations?.map(op => op.id), []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ class FakeChangesetService implements IAgentHostChangesetService {
|
||||
isStaticChangesetComputeActive(): boolean { return false; }
|
||||
getListMetadataKeys() { return undefined; }
|
||||
computeListEntryChanges() { return undefined; }
|
||||
refreshChangesetCatalog(): void { }
|
||||
refreshBranchChangeset(): void { }
|
||||
refreshSessionChangeset(): void { }
|
||||
onWorkingDirectoryAvailable(): void { }
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -18,7 +18,7 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio
|
||||
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 { 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';
|
||||
@@ -60,6 +60,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 */ }
|
||||
@@ -156,7 +157,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, });
|
||||
}
|
||||
|
||||
|
||||
@@ -356,6 +356,10 @@ class ProxyRoundTripSdkService implements IClaudeAgentSdkService {
|
||||
return [];
|
||||
}
|
||||
|
||||
async canLoadWithoutDownload(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getSessionInfo(_sessionId: string): Promise<SDKSessionInfo | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -236,6 +236,10 @@ class FakeClaudeAgentSdkService implements IClaudeAgentSdkService {
|
||||
return this.sessionList;
|
||||
}
|
||||
|
||||
async canLoadWithoutDownload(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake for {@link IClaudeAgentSdkService.getSessionInfo}. Tests stage
|
||||
* `sessionList` and the fake searches it by id; setting
|
||||
@@ -788,7 +792,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'); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'); }
|
||||
|
||||
@@ -23,12 +23,12 @@
|
||||
*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises';
|
||||
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 { MessageAttachmentKind, buildDefaultChatUri, ToolCallConfirmationReason, type MessageAttachment } from '../../../common/state/sessionState.js';
|
||||
import { ActionType, type ChatUsageAction } from '../../../common/state/sessionActions.js';
|
||||
import { CustomizationType, MessageAttachmentKind, buildDefaultChatUri, ToolCallConfirmationReason, type DirectoryCustomization, type MessageAttachment } from '../../../common/state/sessionState.js';
|
||||
import { ActionType, SessionCustomizationsChangedAction, type ChatUsageAction } from '../../../common/state/sessionActions.js';
|
||||
import {
|
||||
createRealSession, defineSharedRealSdkTests, dispatchTurn, driveTurnWithAttachmentsToCompletion,
|
||||
type IRealSdkProviderConfig,
|
||||
@@ -164,6 +164,85 @@ defineSharedRealSdkTests(COPILOT_CONFIG);
|
||||
assert.match(result.responseText, /\bsubtract\b/i, `expected the model to identify the attached blob function; got: ${JSON.stringify(result.responseText)}`);
|
||||
});
|
||||
|
||||
test('detects workspace agents, instructions, skills, and hooks via session/customizationsChanged after hello', async function () {
|
||||
this.timeout(180_000);
|
||||
|
||||
const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-test-`);
|
||||
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');
|
||||
|
||||
await Promise.all([
|
||||
mkdir(agentsDir, { recursive: true }),
|
||||
mkdir(instructionsDir, { recursive: true }),
|
||||
mkdir(skillsDir, { recursive: true }),
|
||||
mkdir(hooksDir, { 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)),
|
||||
]);
|
||||
|
||||
const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-customizations', createdSessions, URI.file(workspaceDir).toString());
|
||||
client.dispatch({
|
||||
channel: sessionUri,
|
||||
clientSeq: 1,
|
||||
action: {
|
||||
type: ActionType.SessionActiveClientSet,
|
||||
activeClient: {
|
||||
clientId: 'real-sdk-customizations-client',
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
client.clearReceived();
|
||||
dispatchTurn(client, sessionUri, 'turn-customizations', 'hello', 2);
|
||||
|
||||
const [customizationsNotif] = await Promise.all([
|
||||
client.waitForNotification(n => isActionNotification(n, ActionType.SessionCustomizationsChanged) && getActionEnvelope(n).channel === sessionUri, 120_000),
|
||||
client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete') && getActionEnvelope(n).channel === buildDefaultChatUri(sessionUri), 120_000),
|
||||
]);
|
||||
|
||||
const customizationsAction = getActionEnvelope(customizationsNotif).action as SessionCustomizationsChangedAction;
|
||||
const directories = customizationsAction.customizations.filter((customization): customization is DirectoryCustomization => customization.type === CustomizationType.Directory);
|
||||
const expectChildType = (directoryUri: string, expectedType: CustomizationType, expectedName: string): void => {
|
||||
const directory = directories.find(customization => customization.uri === directoryUri);
|
||||
assert.ok(directory, `expected discovered directory ${directoryUri}`);
|
||||
const matchingChildren = directory.children?.filter(child => child.type === expectedType && child.name === expectedName) ?? [];
|
||||
assert.ok(
|
||||
matchingChildren.length === 1,
|
||||
`expected ${directoryUri} to contain a ${expectedType} customization with name ${expectedName}; got: ${JSON.stringify(directory.children)}`,
|
||||
);
|
||||
};
|
||||
expectChildType(URI.file(agentsDir).toString(), CustomizationType.Agent, 'Hello Agent');
|
||||
expectChildType(URI.file(instructionsDir).toString(), CustomizationType.Rule, 'policy');
|
||||
expectChildType(URI.file(join(githubDir, 'skills')).toString(), CustomizationType.Skill, 'Hello Skill');
|
||||
expectChildType(URI.file(hooksDir).toString(), CustomizationType.Hook, 'pre-tool.json');
|
||||
});
|
||||
|
||||
test('strips redundant `cd <workingDirectory> &&` prefix from shell tool calls', async function () {
|
||||
this.timeout(180_000);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,6 +93,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 {
|
||||
@@ -222,20 +247,20 @@ export interface IManagedSettingsSelection {
|
||||
/**
|
||||
* Select the authoritative managed-settings bag from the available delivery channels.
|
||||
*
|
||||
* Precedence (highest first): server-delivered → native MDM → file on disk. The channels are
|
||||
* Precedence (highest first): native MDM → server-delivered → 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.
|
||||
* wins outright. The parameter order matches that precedence so call sites read top-to-bottom.
|
||||
* 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.
|
||||
*/
|
||||
export function selectManagedSettings(server: ManagedSettingsData | undefined, nativeMdm: ManagedSettingsData | undefined, file: ManagedSettingsData | undefined): IManagedSettingsSelection {
|
||||
if (server && !isEmptyObject(server)) {
|
||||
return { source: 'server', values: server };
|
||||
}
|
||||
export function selectManagedSettings(nativeMdm: ManagedSettingsData | undefined, server: ManagedSettingsData | undefined, file: ManagedSettingsData | undefined): IManagedSettingsSelection {
|
||||
if (nativeMdm && !isEmptyObject(nativeMdm)) {
|
||||
return { source: 'nativeMdm', values: nativeMdm };
|
||||
}
|
||||
if (server && !isEmptyObject(server)) {
|
||||
return { source: 'server', values: server };
|
||||
}
|
||||
if (file && !isEmptyObject(file)) {
|
||||
return { source: 'file', values: file };
|
||||
}
|
||||
@@ -301,18 +326,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
|
||||
|
||||
@@ -108,23 +108,23 @@ suite('Copilot managed settings projection', () => {
|
||||
assert.strictEqual(warnings.length, 1);
|
||||
});
|
||||
|
||||
test('selectManagedSettings: server wins over native MDM and file, never merged', () => {
|
||||
test('selectManagedSettings: native MDM wins over server and file, never merged', () => {
|
||||
const selection = selectManagedSettings(
|
||||
{ 'permissions.x': 'server' },
|
||||
{ 'permissions.y': 'native' },
|
||||
{ 'permissions.x': 'server' },
|
||||
{ 'permissions.z': 'file' },
|
||||
);
|
||||
assert.deepStrictEqual(selection, { source: 'server', values: { 'permissions.x': 'server' } });
|
||||
assert.deepStrictEqual(selection, { source: 'nativeMdm', values: { 'permissions.y': 'native' } });
|
||||
});
|
||||
|
||||
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('selectManagedSettings: falls through to server when native MDM is absent or empty', () => {
|
||||
const fromUndefined = selectManagedSettings(undefined, { 'permissions.x': 'server' }, undefined);
|
||||
const fromEmptyObject = selectManagedSettings({}, { 'permissions.x': 'server' }, undefined);
|
||||
assert.deepStrictEqual(fromUndefined, { source: 'server', values: { 'permissions.x': 'server' } });
|
||||
assert.deepStrictEqual(fromEmptyObject, { source: 'server', values: { 'permissions.x': 'server' } });
|
||||
});
|
||||
|
||||
test('selectManagedSettings: falls through to file when server and native MDM are absent or empty', () => {
|
||||
test('selectManagedSettings: falls through to file when native MDM and server 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' } });
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -37,6 +37,7 @@ export const Menus = {
|
||||
SessionWorkspaceManage: new MenuId('Sessions.SessionWorkspaceManage'),
|
||||
SessionBarToolbar: new MenuId('SessionsSessionBarToolbar'),
|
||||
SessionConversations: new MenuId('SessionsSessionConversations'),
|
||||
SessionChatTab: new MenuId('SessionsSessionChatTab'),
|
||||
SessionChatTabBar: new MenuId('SessionsSessionChatTabBar'),
|
||||
SessionHeaderMeta: new MenuId('SessionsSessionHeaderMeta'),
|
||||
SessionHeaderContext: MenuId.SessionHeaderContext,
|
||||
|
||||
@@ -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 { 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';
|
||||
@@ -203,18 +203,17 @@ export class ChatCompositeBar extends Disposable {
|
||||
// chat. The strip's own trailing "New Chat" action follows this visibility.
|
||||
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);
|
||||
this._rebuildTabs(visibleOpenChats, activeChatUri, mainChatUri);
|
||||
const tabs = session.visibleChatTabs.read(reader);
|
||||
this._rebuildTabs(tabs, activeChatUri, mainChatUri);
|
||||
|
||||
// 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._setVisible(session.isCreated.read(reader) && visibleOpenChats.length > 1);
|
||||
this._setVisible(session.isCreated.read(reader) && tabs.length > 1);
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -304,33 +303,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);
|
||||
|
||||
@@ -19,9 +19,10 @@ import { AbstractChatView, ChatViewKind, IChatViewOptions } from './chatView.js'
|
||||
import { ChatCompositeBar } from './chatCompositeBar.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';
|
||||
|
||||
@@ -84,6 +85,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 +94,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(
|
||||
|
||||
@@ -29,11 +29,13 @@ export const SessionIsMaximizedContext = new RawContextKey<boolean>('sessionIsMa
|
||||
export const SessionSupportsMultipleChatsContext = new RawContextKey<boolean>('sessionSupportsMultipleChats', false, localize('sessionSupportsMultipleChats', "Whether the session view's session supports multiple chats"));
|
||||
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 SessionActiveChatIsClosableContext = new RawContextKey<boolean>('sessionActiveChatIsClosable', false, localize('sessionActiveChatIsClosable', "Whether the session's active chat can be closed/deleted from the tab strip (i.e. it is not the main chat). Used to scope the close-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"));
|
||||
|
||||
//#endregion
|
||||
|
||||
@@ -75,6 +77,7 @@ 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
|
||||
|
||||
|
||||
@@ -571,6 +571,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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -21,9 +21,13 @@ 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';
|
||||
|
||||
registerSingleton(ISessionChangesService, SessionChangesService, InstantiationType.Delayed);
|
||||
|
||||
AccessibleViewRegistry.register(new SessionsChangesAccessibilityHelp());
|
||||
|
||||
|
||||
const changesViewIcon = registerIcon('changes-view-icon', Codicon.gitCompare, localize2('changesViewIcon', 'View icon for the Changes view.').value);
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ 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';
|
||||
@@ -363,6 +365,7 @@ 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;
|
||||
|
||||
@@ -537,6 +540,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.");
|
||||
|
||||
// Session 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));
|
||||
|
||||
@@ -548,6 +554,7 @@ export class ChangesViewPane extends ViewPane {
|
||||
|
||||
// Shared constants for pane sizing
|
||||
const ciMinHeight = CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.MIN_BODY_HEIGHT;
|
||||
const sessionFilesMinHeight = SessionFilesWidget.HEADER_HEIGHT + SessionFilesWidget.MIN_BODY_HEIGHT;
|
||||
const treeMinHeight = 3 * ChangesTreeDelegate.ROW_HEIGHT;
|
||||
|
||||
// Top pane: file tree
|
||||
@@ -562,6 +569,21 @@ export class ChangesViewPane extends ViewPane {
|
||||
},
|
||||
};
|
||||
|
||||
// Middle pane: session files
|
||||
const sessionFilesElement = this.sessionFilesWidget.element;
|
||||
const sessionFilesWidget = this.sessionFilesWidget;
|
||||
const sessionFilesPane: IView = {
|
||||
element: sessionFilesElement,
|
||||
get minimumSize() { return sessionFilesWidget.collapsed ? SessionFilesWidget.HEADER_HEIGHT : sessionFilesMinHeight; },
|
||||
get maximumSize() { return sessionFilesWidget.collapsed ? SessionFilesWidget.HEADER_HEIGHT : Number.POSITIVE_INFINITY; },
|
||||
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;
|
||||
@@ -578,7 +600,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 = () => {
|
||||
@@ -588,39 +611,15 @@ export class ChangesViewPane extends ViewPane {
|
||||
updateSplitViewStyles();
|
||||
this._register(this.themeService.onDidColorThemeChange(updateSplitViewStyles));
|
||||
|
||||
// Initially hide CI pane until checks arrive
|
||||
// Initially hide the session 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();
|
||||
}));
|
||||
// Session files pane (index 1)
|
||||
this._wireSectionPane(this.sessionFilesWidget, 1, SessionFilesWidget.HEADER_HEIGHT, SessionFilesWidget.HEADER_HEIGHT + SessionFilesWidget.PREFERRED_BODY_HEIGHT);
|
||||
|
||||
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, CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT);
|
||||
|
||||
this._register(this.onDidChangeBodyVisibility(visible => {
|
||||
if (visible) {
|
||||
@@ -793,6 +792,14 @@ export class ChangesViewPane extends ViewPane {
|
||||
this.renderDisposables.add(this.ciStatusWidget.setInput(checksViewModel));
|
||||
}
|
||||
|
||||
// Session 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);
|
||||
@@ -896,6 +903,51 @@ export class ChangesViewPane extends ViewPane {
|
||||
this.splitView.layout(availableHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires a collapsible section widget (CI checks / session 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,
|
||||
preferredHeight: number,
|
||||
): void {
|
||||
let savedPaneHeight = preferredHeight;
|
||||
|
||||
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);
|
||||
}
|
||||
this.layoutSplitView();
|
||||
}));
|
||||
}
|
||||
|
||||
private getTreeSelection(): IChangesFileItem[] {
|
||||
const selection = this.tree?.getSelection() ?? [];
|
||||
return selection.filter(item => !!item && isChangesFileItem(item));
|
||||
|
||||
@@ -108,13 +108,24 @@ export class ChangesViewService extends Disposable implements IChangesViewServic
|
||||
const activeSessionChangesets = this.activeSessionChangesetsObs.read(reader) ?? [];
|
||||
|
||||
// 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.activeSessionChangesetOperationsObs = derived(reader => {
|
||||
|
||||
@@ -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.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/* Session 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 badge in the header */
|
||||
.session-files-widget-count {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding-right: 8px;
|
||||
font-size: var(--vscode-agents-fontSize-body2);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.session-files-widget-header:hover .session-files-widget-count,
|
||||
.session-files-widget-header:focus-visible .session-files-widget-count,
|
||||
.session-files-widget-header.collapsed .session-files-widget-count {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* 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 0 0 6px;
|
||||
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;
|
||||
}
|
||||
@@ -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 "Session 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { 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, dirname } 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 { FileKind } 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 { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.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 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 _labelService: ILabelService,
|
||||
) { }
|
||||
|
||||
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, { supportIcons: true }));
|
||||
return { label, templateDisposables };
|
||||
}
|
||||
|
||||
renderElement(element: ISessionFile, _index: number, templateData: ISessionFileTemplateData): void {
|
||||
const parent = dirname(element.uri);
|
||||
templateData.label.setResource({
|
||||
resource: element.uri,
|
||||
name: basename(element.uri),
|
||||
description: this._labelService.getUriLabel(parent, { noPrefix: true }),
|
||||
}, {
|
||||
fileKind: FileKind.FILE,
|
||||
strikethrough: element.operation === SessionFileOperation.Deleted,
|
||||
title: getSessionFileTitle(element, this._labelService),
|
||||
});
|
||||
}
|
||||
|
||||
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 = 32; // 5px section margin + 6px header margin + 28px header
|
||||
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,
|
||||
) {
|
||||
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';
|
||||
|
||||
// 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', "Session Files");
|
||||
this._countNode = dom.append(this._titleNode, $('.session-files-widget-count'));
|
||||
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 Session Files"));
|
||||
this._headerNode.setAttribute('aria-expanded', 'true');
|
||||
this._headerNode.tabIndex = 0;
|
||||
|
||||
this._register(this._hoverService.setupManagedHover(
|
||||
getDefaultHoverDelegate('element'),
|
||||
this._headerNode,
|
||||
localize('sessionFiles.hover', "Files created or edited 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(),
|
||||
[new SessionFileListRenderer(this._labels, this._labelService)],
|
||||
{
|
||||
multipleSelectionSupport: false,
|
||||
openOnSingleClick: true,
|
||||
accessibilityProvider: {
|
||||
getWidgetAriaLabel: () => localize('sessionFiles.listAriaLabel', "Session 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._renderHeader(files);
|
||||
this._renderBody(files);
|
||||
|
||||
if (this._fileCount !== oldCount) {
|
||||
this._onDidChangeHeight.fire();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _renderHeader(files: readonly ISessionFile[]): void {
|
||||
this._countNode.textContent = `${files.length}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
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.
|
||||
if (file.operation === SessionFileOperation.Modified && 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);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -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 — Session 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: Session 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 Session 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 Session 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 { 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 { 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 });
|
||||
// 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 }),
|
||||
}),
|
||||
|
||||
});
|
||||
@@ -100,6 +100,7 @@ export function makeSession(resource: URI, opts?: {
|
||||
sticky: observableValue('sticky', false),
|
||||
openChats: observableValue('openChats', [chat]),
|
||||
closedChats: constObservable([]),
|
||||
visibleChatTabs: constObservable([chat]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export function createChangesets(
|
||||
return sessionChangesets;
|
||||
}
|
||||
|
||||
function createActiveSessionSubscriptionObs<T>(
|
||||
export function createActiveSessionSubscriptionObs<T>(
|
||||
options: IAgentHostAdapterOptions,
|
||||
isActiveSessionObs: IObservable<boolean>,
|
||||
component: StateComponents,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user