diff --git a/.github/instructions/sessions.instructions.md b/.github/instructions/sessions.instructions.md
index ef9dd0066c7..6dec29dbb1c 100644
--- a/.github/instructions/sessions.instructions.md
+++ b/.github/instructions/sessions.instructions.md
@@ -11,3 +11,10 @@ When working on files under `src/vs/sessions/`, use these skills for detailed gu
- **`sessions`** skill — covers the full architecture: layering, folder structure, chat widget, menus, contributions, entry points, and development guidelines
- **`agent-sessions-layout`** skill — covers the fixed layout structure, grid configuration, part visibility, editor modal, titlebar, sidebar footer, and implementation requirements
+
+## Touch & iOS Compatibility
+
+The Agents window can run on touch-capable platforms (notably iOS). Follow these rules for all DOM interaction code:
+
+- Do not use `EventType.MOUSE_DOWN`, `EventType.MOUSE_UP`, or `EventType.MOUSE_MOVE` with `addDisposableListener` directly — on iOS, these events don't fire because the platform uses pointer events. Use `addDisposableGenericMouseDownListener`, `addDisposableGenericMouseUpListener`, or `addDisposableGenericMouseMoveListener` instead, which automatically select the correct event type per platform.
+- Add `touch-action: manipulation` in CSS on custom clickable elements (e.g. picker triggers, title bar pills, or other `
`/`
` elements styled as buttons) to eliminate the 300ms tap delay on touch devices. This is not needed for native `` elements or standard VS Code widgets (quick picks, context menus, action bar items) which already handle touch behavior.
diff --git a/.github/skills/heap-snapshot-analysis/SKILL.md b/.github/skills/heap-snapshot-analysis/SKILL.md
index 41a906c079b..2f49c9966fd 100644
--- a/.github/skills/heap-snapshot-analysis/SKILL.md
+++ b/.github/skills/heap-snapshot-analysis/SKILL.md
@@ -33,6 +33,8 @@ If the user needs the agent to launch VS Code, drive a scenario, and capture sna
Use the helpers in [parseSnapshot.ts](./helpers/parseSnapshot.ts) to load snapshots. The files are often >500MB and too large for `JSON.parse` as a string — the helpers use Buffer-based extraction. In scratchpad scripts, import helpers from `../helpers/*.ts`.
+For very large snapshots, the helper may still be too eager. Node cannot create a Buffer larger than roughly 2 GiB, so snapshots above that size can fail with `ERR_FS_FILE_TOO_LARGE` even before parsing. In that case, do not try to raise `--max-old-space-size` and retry the same full-file read. Switch to a streaming script.
+
```typescript
import { parseSnapshot, buildGraph } from '../helpers/parseSnapshot.ts';
@@ -40,6 +42,44 @@ const data = parseSnapshot('/path/to/snapshot.heapsnapshot');
const graph = buildGraph(data);
```
+#### Snapshots Larger Than 2 GiB
+
+When a snapshot is too large to load into a single Buffer, write scratchpad scripts that scan and parse only the sections needed for the question. Use [streamSnapshot.mjs](./helpers/streamSnapshot.mjs) for the common streaming primitives instead of copying them between scratch scripts.
+
+Useful tricks:
+
+- Find top-level section offsets first. Scan the file as bytes for markers like `"nodes":`, `"edges":`, `"strings":`, and `"trace_function_infos":`. This lets follow-up scripts jump directly to the large arrays instead of searching the whole file repeatedly.
+- Parse `snapshot.meta` separately from the small header at the start of the file. Use `meta.node_fields`, `meta.node_types`, `meta.edge_fields`, and `meta.edge_types` to avoid hard-coding tuple widths.
+- Stream numeric arrays in chunks. For `nodes` and `edges`, keep a small carryover string between chunks, split on commas, and process complete numeric tokens as they arrive.
+- Avoid materializing the full `strings` table unless the investigation truly needs it. If you only need suspicious names, collect string indexes from matching nodes/edges first, then resolve only those indexes in a second streaming pass.
+- If you do need many strings, store only short previews and category counters. Full source strings, ref-listing strings, and prompt payloads can dominate memory and make the analyzer become the leak.
+- Write intermediate outputs to files in the scratchpad. Large heap analysis is iterative and slow; cached node ids, offsets, and retainer traces save repeated multi-minute passes.
+- Prefer self-size attribution and field-level ownership for huge graphs. Full retained-size walks can wildly overcount shared services, roots, maps, and singleton caches.
+- When quantifying a suspected owner, count obvious owned fields separately: wrapper object, key arrays, array elements, direct strings, and parent strings of sliced/concatenated strings. This often gives a better lower-bound than a single direct string bucket.
+- Be explicit about approximation boundaries. A field-level subtotal usually undercounts listeners/watchers/back-references but avoids the much worse problem of attributing the whole runtime to one object.
+
+Example large-snapshot workflow:
+
+```javascript
+import { findArrayStart, findTokenOffsets, parseMeta, streamNumberTuples } from '../../helpers/streamSnapshot.mjs';
+
+const { size, offsets } = findTokenOffsets(snapshotPath);
+const meta = parseMeta(snapshotPath);
+const nodeFieldCount = meta.node_fields.length;
+const nodesStart = findArrayStart(snapshotPath, offsets.get('"nodes"'));
+
+streamNumberTuples(snapshotPath, nodesStart, offsets.get('"edges"'), nodeFieldCount, (node, nodeIndex) => {
+ // node is reused for speed; copy it before storing.
+});
+```
+
+```bash
+cd .github/skills/heap-snapshot-analysis
+node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/findOffsets.mjs /path/to/Heap.heapsnapshot
+node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/streamAnalyze.mjs /path/to/Heap.heapsnapshot > scratchpad/YYYY-MM-DD-topic/streamAnalyze.out
+node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/traceNodes.mjs /path/to/Heap.heapsnapshot 12345 67890 > scratchpad/YYYY-MM-DD-topic/traceNodes.out
+```
+
### 2. Compare Before/After
Use [compareSnapshots.ts](./helpers/compareSnapshots.ts) to diff two snapshots:
@@ -134,6 +174,7 @@ override dispose() {
### False Retainers to Watch For
+- **DevTools debugger global handles**: If the snapshot was captured after opening DevTools, large source strings, compiled scripts, preview data, inspected objects, or debugger bookkeeping can be retained by paths like `DevTools debugger(internal)` → `synthetic::(Global handles)` → GC roots. Treat these as debugger-induced until proven otherwise. They may not exist in the app before DevTools opens, and they should not be confused with application-owned leaks.
- **`DevToolsLogger._aliveInstances`** (Map): Enabled by `VSCODE_DEV_DEBUG_OBSERVABLES` env var. Retains ALL observed observables. Check if this is active before investigating observable-rooted paths.
- **`GCBasedDisposableTracker` (FinalizationRegistry)**: If `register(target, held, target)` is used (target === unregister token), creates a strong self-reference preventing GC. Currently commented out in production.
- **WeakMap backing arrays**: Show up in retainer paths but don't prevent collection.
diff --git a/.github/skills/heap-snapshot-analysis/helpers/streamSnapshot.mjs b/.github/skills/heap-snapshot-analysis/helpers/streamSnapshot.mjs
new file mode 100644
index 00000000000..242f9afc5a0
--- /dev/null
+++ b/.github/skills/heap-snapshot-analysis/helpers/streamSnapshot.mjs
@@ -0,0 +1,260 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { closeSync, openSync, readSync, statSync } from 'fs';
+
+export const defaultTopLevelTokens = [
+ '"meta"',
+ '"nodes"',
+ '"edges"',
+ '"trace_function_infos"',
+ '"trace_tree"',
+ '"samples"',
+ '"locations"',
+ '"strings"'
+];
+
+export function formatBytes(bytes) {
+ if (Math.abs(bytes) < 1024) {
+ return `${bytes} B`;
+ }
+ if (Math.abs(bytes) < 1024 * 1024) {
+ return `${(bytes / 1024).toFixed(1)} KB`;
+ }
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}
+
+export function findTokenOffsets(path, tokens = defaultTopLevelTokens, options = {}) {
+ const stat = statSync(path);
+ const fd = openSync(path, 'r');
+ const chunkSize = options.chunkSize ?? 8 * 1024 * 1024;
+ const overlap = options.overlap ?? 256;
+ const found = new Map();
+ let previous = Buffer.alloc(0);
+ let position = 0;
+
+ try {
+ while (position < stat.size && found.size < tokens.length) {
+ const toRead = Math.min(chunkSize, stat.size - position);
+ const chunk = Buffer.allocUnsafe(toRead);
+ const bytesRead = readSync(fd, chunk, 0, toRead, position);
+ if (bytesRead <= 0) {
+ break;
+ }
+
+ const combined = Buffer.concat([previous, chunk.subarray(0, bytesRead)]);
+
+ for (const token of tokens) {
+ if (found.has(token)) {
+ continue;
+ }
+
+ const index = combined.indexOf(token);
+ if (index !== -1) {
+ found.set(token, position - previous.length + index);
+ }
+ }
+
+ previous = combined.subarray(Math.max(0, combined.length - overlap));
+ position += bytesRead;
+ }
+ } finally {
+ closeSync(fd);
+ }
+
+ return { size: stat.size, offsets: found };
+}
+
+export function readRange(path, start, length) {
+ const fd = openSync(path, 'r');
+ const buffer = Buffer.allocUnsafe(length);
+ let offset = 0;
+
+ try {
+ while (offset < length) {
+ const bytesRead = readSync(fd, buffer, offset, length - offset, start + offset);
+ if (bytesRead === 0) {
+ return buffer.subarray(0, offset);
+ }
+ offset += bytesRead;
+ }
+ return buffer;
+ } finally {
+ closeSync(fd);
+ }
+}
+
+export function parseMeta(path, options = {}) {
+ const maxBytes = options.maxBytes ?? 1024 * 1024;
+ const buffer = readRange(path, 0, maxBytes);
+ const metaPosition = buffer.indexOf(Buffer.from('"meta"'));
+ if (metaPosition === -1) {
+ throw new Error('Unable to find snapshot meta section');
+ }
+
+ const start = buffer.indexOf(Buffer.from('{'), metaPosition);
+ if (start === -1) {
+ throw new Error('Unable to find snapshot meta object start');
+ }
+
+ let depth = 0;
+ for (let i = start; i < buffer.length; i++) {
+ if (buffer[i] === 0x22) {
+ i++;
+ while (i < buffer.length) {
+ if (buffer[i] === 0x5c) {
+ i += 2;
+ continue;
+ }
+ if (buffer[i] === 0x22) {
+ break;
+ }
+ i++;
+ }
+ continue;
+ }
+
+ if (buffer[i] === 0x7b) {
+ depth++;
+ } else if (buffer[i] === 0x7d) {
+ depth--;
+ if (depth === 0) {
+ return JSON.parse(buffer.subarray(start, i + 1).toString('utf8'));
+ }
+ }
+ }
+
+ throw new Error(`Unable to parse snapshot meta within first ${formatBytes(maxBytes)}`);
+}
+
+export function findArrayStart(path, tokenOffset, options = {}) {
+ const windowSize = options.windowSize ?? 4096;
+ const buffer = readRange(path, tokenOffset, windowSize);
+ const bracket = buffer.indexOf(Buffer.from('['));
+ if (bracket === -1) {
+ throw new Error(`Unable to find array start near offset ${tokenOffset}`);
+ }
+ return tokenOffset + bracket + 1;
+}
+
+export function streamNumberArray(path, start, end, onNumber, options = {}) {
+ const fd = openSync(path, 'r');
+ const chunkSize = options.chunkSize ?? 16 * 1024 * 1024;
+ const buffer = Buffer.allocUnsafe(chunkSize);
+ let position = start;
+ let number = 0;
+ let inNumber = false;
+ let numberIndex = 0;
+
+ try {
+ while (position < end) {
+ const toRead = Math.min(chunkSize, end - position);
+ const bytesRead = readSync(fd, buffer, 0, toRead, position);
+ if (bytesRead <= 0) {
+ break;
+ }
+
+ for (let i = 0; i < bytesRead; i++) {
+ const code = buffer[i];
+ if (code >= 0x30 && code <= 0x39) {
+ number = number * 10 + code - 0x30;
+ inNumber = true;
+ } else if (inNumber) {
+ onNumber(number, numberIndex++);
+ number = 0;
+ inNumber = false;
+ if (code === 0x5d) {
+ return numberIndex;
+ }
+ } else if (code === 0x5d) {
+ return numberIndex;
+ }
+ }
+
+ position += bytesRead;
+ }
+
+ if (inNumber) {
+ onNumber(number, numberIndex++);
+ }
+ return numberIndex;
+ } finally {
+ closeSync(fd);
+ }
+}
+
+/**
+ * Streams fixed-size tuples from a number array.
+ *
+ * By default, the same mutable tuple array instance is reused for each callback
+ * invocation to avoid per-tuple allocations. Callers must not retain that array
+ * reference after onTuple returns unless options.copyTuple is enabled.
+ */
+export function streamNumberTuples(path, start, end, tupleSize, onTuple, options = {}) {
+ const tuple = new Array(tupleSize);
+ const copyTuple = options.copyTuple === true;
+ let tupleIndex = 0;
+ let fieldIndex = 0;
+
+ const numberCount = streamNumberArray(path, start, end, value => {
+ tuple[fieldIndex++] = value;
+ if (fieldIndex === tupleSize) {
+ onTuple(copyTuple ? tuple.slice() : tuple, tupleIndex++);
+ fieldIndex = 0;
+ }
+ }, options);
+
+ if (fieldIndex !== 0) {
+ throw new Error(`Number array ended with an incomplete tuple: ${fieldIndex}/${tupleSize}`);
+ }
+
+ return { numberCount, tupleCount: tupleIndex };
+}
+
+export function parseStrings(path, stringsTokenOffset, options = {}) {
+ const normalizedOptions = typeof options === 'number' ? { fileSize: options } : options;
+ const fileSize = normalizedOptions.fileSize ?? statSync(path).size;
+ const length = fileSize - stringsTokenOffset;
+ const maxBytes = normalizedOptions.maxBytes ?? 512 * 1024 * 1024;
+
+ if (length > maxBytes) {
+ throw new Error(`Refusing to parse ${formatBytes(length)} strings section into one Buffer. Pass a larger maxBytes value only if this is intentional.`);
+ }
+
+ const buffer = readRange(path, stringsTokenOffset, length);
+ const start = buffer.indexOf(Buffer.from('['));
+ if (start === -1) {
+ throw new Error(`Unable to find strings array near offset ${stringsTokenOffset}`);
+ }
+
+ let depth = 0;
+ for (let i = start; i < buffer.length; i++) {
+ if (buffer[i] === 0x22) {
+ i++;
+ while (i < buffer.length) {
+ if (buffer[i] === 0x5c) {
+ i += 2;
+ continue;
+ }
+ if (buffer[i] === 0x22) {
+ break;
+ }
+ i++;
+ }
+ continue;
+ }
+
+ if (buffer[i] === 0x5b) {
+ depth++;
+ } else if (buffer[i] === 0x5d) {
+ depth--;
+ if (depth === 0) {
+ return JSON.parse(buffer.subarray(start, i + 1).toString('utf8'));
+ }
+ }
+ }
+
+ throw new Error('Unable to parse strings array');
+}
diff --git a/.github/workflows/no-engineering-system-changes.yml b/.github/workflows/no-engineering-system-changes.yml
index b567d125884..77c04be0718 100644
--- a/.github/workflows/no-engineering-system-changes.yml
+++ b/.github/workflows/no-engineering-system-changes.yml
@@ -88,22 +88,54 @@ jobs:
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Allow cherry-pick bot PRs
+ id: cherry_pick_exception
+ if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.bot_field_exception.outputs.allowed != 'true' && github.event.pull_request.user.login == 'vs-code-engineering[bot]' && startsWith(github.event.pull_request.title, '[cherry-pick]') }}
+ run: |
+ # The label is applied ~2s after PR creation, so the webhook payload
+ # may not include it yet. Fetch current labels from the API with retries.
+ for attempt in 1 2 3; do
+ if gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels --jq '.[].name' | grep -qx 'cherry-pick-artifact'; then
+ echo "Cherry-pick PR by vs-code-engineering bot with cherry-pick-artifact label — allowing"
+ echo "allowed=true" >> $GITHUB_OUTPUT
+ exit 0
+ fi
+ if [ "$attempt" -lt 3 ]; then
+ echo "cherry-pick-artifact label not present yet (attempt $attempt/3); retrying in 2s"
+ sleep 2
+ fi
+ done
+ echo "Cherry-pick PR by bot but missing cherry-pick-artifact label after retries — not allowed"
+ echo "allowed=false" >> $GITHUB_OUTPUT
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Determine if engineering system changes are allowed
+ id: allowed
+ if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' }}
+ run: |
+ if [[ "${{ steps.bot_field_exception.outputs.allowed }}" == "true" || "${{ steps.cherry_pick_exception.outputs.allowed }}" == "true" ]]; then
+ echo "Engineering system changes are allowed by an exception"
+ echo "blocked=false" >> $GITHUB_OUTPUT
+ else
+ echo "No exception applies — enforcing restrictions"
+ echo "blocked=true" >> $GITHUB_OUTPUT
+ fi
- name: Prevent Copilot from modifying engineering systems
- if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.bot_field_exception.outputs.allowed != 'true' && github.event.pull_request.user.login == 'Copilot' }}
+ if: ${{ steps.allowed.outputs.blocked == 'true' && github.event.pull_request.user.login == 'Copilot' }}
run: |
echo "Copilot is not allowed to modify .github/workflows, build folder files, or package.json files."
echo "If you need to update engineering systems, please do so manually or through authorized means."
exit 1
- uses: octokit/request-action@b91aabaa861c777dcdb14e2387e30eddf04619ae # v3.0.0
id: get_permissions
- if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.bot_field_exception.outputs.allowed != 'true' && github.event.pull_request.user.login != 'Copilot' }}
+ if: ${{ steps.allowed.outputs.blocked == 'true' && github.event.pull_request.user.login != 'Copilot' }}
with:
route: GET /repos/microsoft/vscode/collaborators/${{ github.event.pull_request.user.login }}/permission
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set control output variable
id: control
- if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.bot_field_exception.outputs.allowed != 'true' && github.event.pull_request.user.login != 'Copilot' }}
+ if: ${{ steps.allowed.outputs.blocked == 'true' && github.event.pull_request.user.login != 'Copilot' }}
run: |
echo "user: ${{ github.event.pull_request.user.login }}"
echo "role: ${{ fromJson(steps.get_permissions.outputs.data).permission }}"
@@ -111,7 +143,7 @@ jobs:
echo "should_run: ${{ !contains(fromJson('["admin", "maintain", "write"]'), fromJson(steps.get_permissions.outputs.data).permission) }}"
echo "should_run=${{ !contains(fromJson('["admin", "maintain", "write"]'), fromJson(steps.get_permissions.outputs.data).permission) && github.event.pull_request.user.login != 'dependabot[bot]' }}" >> $GITHUB_OUTPUT
- name: Check for engineering system changes
- if: ${{ steps.engineering_systems_check.outputs.engineering_systems_modified == 'true' && steps.bot_field_exception.outputs.allowed != 'true' && steps.control.outputs.should_run == 'true' }}
+ if: ${{ steps.allowed.outputs.blocked == 'true' && steps.control.outputs.should_run == 'true' }}
run: |
echo "Changes to .github/workflows/, build/ folder files, or package.json files aren't allowed in PRs."
exit 1
diff --git a/.npmrc b/.npmrc
index afaf9fccd33..025e4b04d2b 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1,6 +1,6 @@
disturl="https://electronjs.org/headers"
-target="39.8.7"
-ms_build_id="13841579"
+target="39.8.8"
+ms_build_id="13870025"
runtime="electron"
ignore-scripts=false
build_from_source="true"
diff --git a/build/azure-pipelines/product-copilot-recovery.yml b/build/azure-pipelines/product-copilot-recovery.yml
index 92078b5154d..056bdedaed7 100644
--- a/build/azure-pipelines/product-copilot-recovery.yml
+++ b/build/azure-pipelines/product-copilot-recovery.yml
@@ -71,3 +71,4 @@ extends:
publishExtension: ${{ parameters.publishExtension }}
ghReleasePublishVSIX: true
+ ghTagPrefix: 'copilot/'
diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt
index 6aacfecc747..9a9269e0013 100644
--- a/build/checksums/electron.txt
+++ b/build/checksums/electron.txt
@@ -1,75 +1,75 @@
-2e2a3533f9969ded3b11eb0baa5357abeb652975d9bcaea0b0725c9bd0866061 *chromedriver-v39.8.7-darwin-arm64.zip
-c74882bcbdd53f6e8cd65906809ac446bb032dce3ce8f109e2376d49b9b394ee *chromedriver-v39.8.7-darwin-x64.zip
-a8caf72372eb47deb336dc440eb183c30d228b3fef5349dac7571d86103f117c *chromedriver-v39.8.7-linux-arm64.zip
-5d5f02b2e28e8328435d2fd83207098e69dc3e5fecbbbdc2612792370ab2c4ec *chromedriver-v39.8.7-linux-armv7l.zip
-e336dc2dce9d11d44f6eb5b5cc655d3311a9a109ea184625da3ac51181c3ad27 *chromedriver-v39.8.7-linux-x64.zip
-8c795231a7d143cb242083e466763007609ffa63b65f6c7a8b46c4e95bf04748 *chromedriver-v39.8.7-mas-arm64.zip
-5033c9550cb25a228fee9ecb2179f4258847585ee1d8609aec14f42d5aeb654b *chromedriver-v39.8.7-mas-x64.zip
-9834624a8f92bec9931a8b74ce2e1195e0527f61f88c18367129371cfeb7d87b *chromedriver-v39.8.7-win32-arm64.zip
-fffd2a04a1e3a9d0b2aeb47044c308c6ef9e361f23b2d120e19f507f4de53e1c *chromedriver-v39.8.7-win32-ia32.zip
-7b25598ba3db1b0df6253e7233ef68e4cb9764aa7f62d854fe3c620edfbb2a7c *chromedriver-v39.8.7-win32-x64.zip
-ca234cdbdf5cd724adaf5079d860a3d510d8cdfbff7c9392d7b6b0e6948593f7 *electron-api.json
-62fe91fbbe83d68e713ee48d1dbbad06dc3f2be739eb649002e390a585a4639d *electron-v39.8.7-darwin-arm64-dsym-snapshot.zip
-c837b62c12dc16dd41244fbc2c4f8c97ceb93d56ae6a41064782f6876bf01ac0 *electron-v39.8.7-darwin-arm64-dsym.zip
-e4d96c888bbe699e7be9dc90aa39b64dd23dbe3f42d86f354c490f77a9fb6c41 *electron-v39.8.7-darwin-arm64-symbols.zip
-86fa117ba10e36149ca33d7c22de2cfc3fb7490ca88b07ce953d2efe1f2a41cd *electron-v39.8.7-darwin-arm64.zip
-4182678fadb19e0d9be6b7411e18a1e8e5801d14c99fcb5faa5d8a32f3af2cab *electron-v39.8.7-darwin-x64-dsym-snapshot.zip
-765c509e1f3090bdf9610e9b618264bf8251ac708b2d7ffb4550ce75f036a6aa *electron-v39.8.7-darwin-x64-dsym.zip
-52804dec7a659502d4d2df194d4a14abc70dad315cff001712100feb640c589d *electron-v39.8.7-darwin-x64-symbols.zip
-5dfe5559fd283c3962221c674b30a5b986895b644b1b4bc179e0c7673a14f1cf *electron-v39.8.7-darwin-x64.zip
-bdc78aa93b64543885997c16e198270a2b8b8b955db3956f491681c01134f925 *electron-v39.8.7-linux-arm64-debug.zip
-8581d058382d70afd48bc0d1ace4189ada18770b5ebe1347adc667e30bb81650 *electron-v39.8.7-linux-arm64-symbols.zip
-fd721650a0e25829b76d307e944383be828533cdddd53e44a0b772e96e3e019b *electron-v39.8.7-linux-arm64.zip
-d17f1d655ca2b056da6b8ba5e59368e3061d38450e3616e5e9faa2a4e0cbbff6 *electron-v39.8.7-linux-armv7l-debug.zip
-22b4ed4f566432ff040491caae6d926d4623d24d28e96e5f818245433dab93d4 *electron-v39.8.7-linux-armv7l-symbols.zip
-5d0a75a53cdba1ecfc678910084802fe500f13f470310ae1d2c66840d3c7390b *electron-v39.8.7-linux-armv7l.zip
-b2e5d0c1025204aa0f026996490a4b33fff7e89b88eee995c88399eed4439951 *electron-v39.8.7-linux-x64-debug.zip
-5868e2cadc566968692b44bc9e2aa5815eec2b7852c4dc8474719bc90f0ae689 *electron-v39.8.7-linux-x64-symbols.zip
-233b2775f1c46e5ebd5afeb4fb95ce9fda61229bad20aef1031468eb54b3656e *electron-v39.8.7-linux-x64.zip
-350782483b59fe6a96ecf90b4095b7f5b2f941030e946140490697f29c94f85e *electron-v39.8.7-mas-arm64-dsym-snapshot.zip
-6f850ac7faf11413513bf916b336053d5f73d262220e6b4cd88f2be79a902c26 *electron-v39.8.7-mas-arm64-dsym.zip
-c505efd13d3b328f662d6853bfc13c8683bd1dd06113d403d8a58fdc0c82fd3d *electron-v39.8.7-mas-arm64-symbols.zip
-bd27cbfa54c1f816bd865b134d9b10cfbc7631adb7c21ade60d98d100e83a745 *electron-v39.8.7-mas-arm64.zip
-ac83e48c77a745e19e78b0feca136af2e8d309d6a584ea18d2d86c33258517ea *electron-v39.8.7-mas-x64-dsym-snapshot.zip
-30c8f8a7a810b39408e4e19ec6ec42ac47aa945be6085f31b9977743f001cbea *electron-v39.8.7-mas-x64-dsym.zip
-cda7da0f54c8a13fa8426320f688e51b2c4a9581998876d7d22346d6a81d4f69 *electron-v39.8.7-mas-x64-symbols.zip
-818b0d948d09f73deb55de108799e963ff8ea432f81574c8000c5377b55e4119 *electron-v39.8.7-mas-x64.zip
-e6c7fb13390a59e40bc5a26ec1d90370c2a055c964b01eddbf97520dc93f5571 *electron-v39.8.7-win32-arm64-pdb.zip
-7e1c2becc143e2af3d59cc7832fb32f5208fef866fbe729e13ff58da67d68744 *electron-v39.8.7-win32-arm64-symbols.zip
-86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.7-win32-arm64-toolchain-profile.zip
-798a54b33d0841098428809fca3aa1332b46c9858e6bb2d415a8a7ac09784f4e *electron-v39.8.7-win32-arm64.zip
-772a636300d4196205d57cb486ac6b49c6209138e78ce2a3ba97bb822855be22 *electron-v39.8.7-win32-ia32-pdb.zip
-5d3680f53a0abbf9e4caec9abf9fcf1728aa5dfb71d323c2dccf7161e10f10c9 *electron-v39.8.7-win32-ia32-symbols.zip
-86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.7-win32-ia32-toolchain-profile.zip
-669b5dd7aee565b594f0f786304827f6fa4c40710fa71b6101b3356f50f68f2a *electron-v39.8.7-win32-ia32.zip
-ebcb34179d1bda0b8be55354fb9a21a7d45093653475a23a6c84535b8e279e1d *electron-v39.8.7-win32-x64-pdb.zip
-8c386ea127b2944832053519badcb596e34d84adf7efb9f96822dd751f018a51 *electron-v39.8.7-win32-x64-symbols.zip
-86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.7-win32-x64-toolchain-profile.zip
-272b94970b8c7669c2367a2bd9e52a673665aaf33eb5e54e32ca7551497859b2 *electron-v39.8.7-win32-x64.zip
-d8c5d7fd580c05250687262c700ac4ab20c3dc366e06887a99d806079393a14e *electron.d.ts
-966ecdbe01413fb2813421c9bedf3a5ca74b561c5db3d6a4541670a38bddbef6 *ffmpeg-v39.8.7-darwin-arm64.zip
-acbab76adefccc9d2adca16d8e3942e75f11fd7c4be7775db7f8a5c304ea1e35 *ffmpeg-v39.8.7-darwin-x64.zip
-52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.7-linux-arm64.zip
-622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.7-linux-armv7l.zip
-ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.7-linux-x64.zip
-966ecdbe01413fb2813421c9bedf3a5ca74b561c5db3d6a4541670a38bddbef6 *ffmpeg-v39.8.7-mas-arm64.zip
-acbab76adefccc9d2adca16d8e3942e75f11fd7c4be7775db7f8a5c304ea1e35 *ffmpeg-v39.8.7-mas-x64.zip
-de8643e5d52bcbb39432d1d32d93a9609cd98418a603dda05b7bbac6156f7c9b *ffmpeg-v39.8.7-win32-arm64.zip
-c2be0960b6325757e401fa9926a86421cafb050a41ecdf925f657adce091d114 *ffmpeg-v39.8.7-win32-ia32.zip
-c03a89f7acdb6abc22829ab241fcaf55842c463e58fc5fcc1405326cd3d0ba29 *ffmpeg-v39.8.7-win32-x64.zip
-e06e1bd83d8d9641f614048bd60da15a6237f050bb345a62eaedab9fbeb98d33 *hunspell_dictionaries.zip
-8b5d4ca6ff70993a688414ba64b5acf34c924f480b08d4399491fd96bf060b1e *libcxx-objects-v39.8.7-linux-arm64.zip
-c40682d02395a3c585f07905a2e5a9ef9fd307a2c92cc1a76f23114cfe95564b *libcxx-objects-v39.8.7-linux-armv7l.zip
-4c6e0a4ebc40cb2db5361b40deb5acbd693d082f4bb58d84b6baf0280163d603 *libcxx-objects-v39.8.7-linux-x64.zip
-4269d16db215839a546e3f72d17f450ea5623539e85d3cab85d13f47e14e60f5 *libcxx_headers.zip
-06659d8c13cf63ef52ee06be71be0e4d83612c577539f630c97274cbe1ec9ad2 *libcxxabi_headers.zip
-222f0e94b6e61b336a437e33a7815ff70d6aaafa504e14dfc3667c2aea84c3c2 *mksnapshot-v39.8.7-darwin-arm64.zip
-15fef5c087e84569d7539ad95f51501995aed6149890bff9a05a4445e123c010 *mksnapshot-v39.8.7-darwin-x64.zip
-7ca75c9fa6a9be45298532b7718644e53b54ff2572f2208739f5eb8e4aeb1358 *mksnapshot-v39.8.7-linux-arm64-x64.zip
-6706f623f0be74d69159ed47642bffbf3e9c37730c7025a99492afbbce94b524 *mksnapshot-v39.8.7-linux-armv7l-x64.zip
-923926bb76fbaec25780fc202a662af9d02ce75dc0cbe81ae926000be75b7214 *mksnapshot-v39.8.7-linux-x64.zip
-815ac4296876b9fb769eeb75ab8c542e913d1a6eb6f5dd4669099ffe6ee3d4dd *mksnapshot-v39.8.7-mas-arm64.zip
-dbbccaf64c18da3d41c22de222d187b1b30ca3016778a962751f177a79d0d4be *mksnapshot-v39.8.7-mas-x64.zip
-d21ff9be73f0307bc67d8d62b8df5d50c0a9b7cb0f7ea3f12683af051dfad994 *mksnapshot-v39.8.7-win32-arm64-x64.zip
-e15e35f5952e88b115e30e5e8be9a003926acafaafc2f70363e5f41a2449e26a *mksnapshot-v39.8.7-win32-ia32.zip
-100471d1064189d03f68b81a68f289b06b231911ab42c715aec956d0a4a11df4 *mksnapshot-v39.8.7-win32-x64.zip
+b131777645f9e6b1052d12b0b4d5eb172d9e05d1b12ef971d99beac5b309dce9 *chromedriver-v39.8.8-darwin-arm64.zip
+062061f78f7b0080eab586cd70cc4e75fc6cb2a301a1d33b34f7df09850b18b8 *chromedriver-v39.8.8-darwin-x64.zip
+6d630e294ff7e75fb8dbe95e01709018dbe80384eafe40a9a90fed4d2b7a5902 *chromedriver-v39.8.8-linux-arm64.zip
+03a2fad20d0496c082b73b6fc0253d3e2ef8fb6e6e3be2d9059b03bc4a8b6efb *chromedriver-v39.8.8-linux-armv7l.zip
+10be127695b948e76f2bbd3f84252a1e87c5f058b1319da9d24487a011d2172f *chromedriver-v39.8.8-linux-x64.zip
+6d0e77fb39439a81e45a258758a59d9e256993e6c2daefe1ea01bbf101cf9266 *chromedriver-v39.8.8-mas-arm64.zip
+715678eb9b675196f23a52fe9bd350f9700c11a0b0515ca2b7cf2c00cb2acca4 *chromedriver-v39.8.8-mas-x64.zip
+e03ab0405f38fb872910f8e9fb6950c720408897c1ee2394b033b6451bad4899 *chromedriver-v39.8.8-win32-arm64.zip
+3eadc1375b2d6e104d870fd433033f95395b6ce99deced1508d3afcfa8bc383a *chromedriver-v39.8.8-win32-ia32.zip
+22c6b161002be93c253f884a3a9fb55748cf4377d4a556305164adf6253032b1 *chromedriver-v39.8.8-win32-x64.zip
+01f250008f92766399af70867aeb95f86a42c76f0fff90c292e162c05dda60d9 *electron-api.json
+36cbb1476b3233f1b6eba0d097fb24da07ae422af5aa5eb468f261414d23ec47 *electron-v39.8.8-darwin-arm64-dsym-snapshot.zip
+403e2b9036425388a84a354fa9d07e61a1b8a4ad7eb8e8806f26b06450c4bd3d *electron-v39.8.8-darwin-arm64-dsym.zip
+427302b282566cc7f6cd6c6318674e78f62b4bfa633916db030676c302e2ac58 *electron-v39.8.8-darwin-arm64-symbols.zip
+041eeca31b24f7fa812ec4ab94d450c7774dea46701b210810efb2a0b01c7fd4 *electron-v39.8.8-darwin-arm64.zip
+90df3fa4b65ffcb3996073db8aa10f6c0ad968fe2273987ed7041c3980d78e8c *electron-v39.8.8-darwin-x64-dsym-snapshot.zip
+e165b2ad086646bf5b1516792f673caf8aaaa39219db5020db727f8830fff310 *electron-v39.8.8-darwin-x64-dsym.zip
+a3c4083ea199f0d8c8b4d6aada677a20ceb7c08abad32986c366a231b7748e38 *electron-v39.8.8-darwin-x64-symbols.zip
+26df42ac32504a29b3accb1e180d06500f08b224def6fdef643aca4948cbd9e8 *electron-v39.8.8-darwin-x64.zip
+0d57ac3400de3d919be936c0269916354600476ec4f1d97a41c76f6b30d62a51 *electron-v39.8.8-linux-arm64-debug.zip
+1a2bfcd28975c84ab4c9d88cb4d31e8dce9818619466795a3f43ffe08a71019f *electron-v39.8.8-linux-arm64-symbols.zip
+b90477f7c2da4d61cde64cf3e55fcd98d63934b4d9d05e603dedbfc3a070f723 *electron-v39.8.8-linux-arm64.zip
+b9f317e67e26a2a30302077021e43e9ad72de2afb572d51a7c2fdcd4149a44d8 *electron-v39.8.8-linux-armv7l-debug.zip
+78af9c546b0eb29a2e2544df27dc8fdb6df08758e983ec8de746a774cbf760fb *electron-v39.8.8-linux-armv7l-symbols.zip
+ca68134f99583c6969ed60d480ec2806dde7d4d1767f54150eff4f05456b4eab *electron-v39.8.8-linux-armv7l.zip
+d4b93269f11c912eb72c3669165b81a4a64b108f9e7fe4ca7b3617486b071ba8 *electron-v39.8.8-linux-x64-debug.zip
+69ef942bfc73cd29d9bdc59c1352153be1a9ed5a1977c992f66163cb26530907 *electron-v39.8.8-linux-x64-symbols.zip
+6979c13291472608623eafa75de850472b1c7072dbb5e5f38f306efacfcb3083 *electron-v39.8.8-linux-x64.zip
+54494a304b149ee1330da42e79e3c814b92cba1dfa2b71f3c09308107967d1bb *electron-v39.8.8-mas-arm64-dsym-snapshot.zip
+cae7ee02bace1784176de5c87a3435ae5b13937d9b9b3ce750354a64192181c0 *electron-v39.8.8-mas-arm64-dsym.zip
+51494321140d1e77269d6ded608e30b438892b396648a784918fa8b9a3d69829 *electron-v39.8.8-mas-arm64-symbols.zip
+f539090fc817cac51013675d6c410d496196d770d635ff92c9c68efd5104aa42 *electron-v39.8.8-mas-arm64.zip
+7b6630399a95221bc0806d044c41f938791baecb0034e1fbd8b19ace318c575b *electron-v39.8.8-mas-x64-dsym-snapshot.zip
+cf15380b1c13f8332c8d5fca44d98ef0570ebba76954b58cadd800dfe2f68bc0 *electron-v39.8.8-mas-x64-dsym.zip
+86b44d622a77c8bfa6c95eb3e2ef4db491bb729a63b5f575e4f641a0838fcc2f *electron-v39.8.8-mas-x64-symbols.zip
+871950743b6f77d1cc085a159386efb6e024f9f0d6417ecceb0ed1dadf8c22ea *electron-v39.8.8-mas-x64.zip
+eb3331dd51c1087ad7a6a9398af78274fd3d0d5ab2e9747de1e206e9256b9589 *electron-v39.8.8-win32-arm64-pdb.zip
+06436fc2204d90751c5fa6fec7f9dcd0e6a52ca824aed16d57dad5a82ef3fd16 *electron-v39.8.8-win32-arm64-symbols.zip
+86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.8-win32-arm64-toolchain-profile.zip
+0ee0d326ab71aa0b6a4922510ea2c9c825fbf609db6d0e7f13a37f391dd7da6b *electron-v39.8.8-win32-arm64.zip
+38d60984dd8b4c4e57b223d4509cf6f98b109c7a0d0d2fdc01fad4472ba2950e *electron-v39.8.8-win32-ia32-pdb.zip
+ffce54a60c7c10959a9355e19437bdcfd40012091e0741e9f0120a63ac0caa33 *electron-v39.8.8-win32-ia32-symbols.zip
+86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.8-win32-ia32-toolchain-profile.zip
+aa252a5946b78d903e1dee22872cf94918a2b69803078967dbc25c2d537df506 *electron-v39.8.8-win32-ia32.zip
+d9b9d1f832211c853666a4d7fafaf2ccc1542bfaee0b3f0f9409a580871c5ff1 *electron-v39.8.8-win32-x64-pdb.zip
+8edac431de4a75f0758ecf03040f1e95b15955d5a3177409f55d5e5233a74947 *electron-v39.8.8-win32-x64-symbols.zip
+86f657848477077247c3cc8a29bbe68ab3c6af6c48e58ea11ccb83ae7cde09c4 *electron-v39.8.8-win32-x64-toolchain-profile.zip
+657d5dc5a71b6aaa506bf83f311cd95a3f5221fcd2c258c108bcdf2043214c86 *electron-v39.8.8-win32-x64.zip
+8f4d940c30332c7001977dec227364274b04d0c0c63e54e06b621d47c7444129 *electron.d.ts
+966ecdbe01413fb2813421c9bedf3a5ca74b561c5db3d6a4541670a38bddbef6 *ffmpeg-v39.8.8-darwin-arm64.zip
+acbab76adefccc9d2adca16d8e3942e75f11fd7c4be7775db7f8a5c304ea1e35 *ffmpeg-v39.8.8-darwin-x64.zip
+52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.8-linux-arm64.zip
+622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.8-linux-armv7l.zip
+ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.8-linux-x64.zip
+966ecdbe01413fb2813421c9bedf3a5ca74b561c5db3d6a4541670a38bddbef6 *ffmpeg-v39.8.8-mas-arm64.zip
+acbab76adefccc9d2adca16d8e3942e75f11fd7c4be7775db7f8a5c304ea1e35 *ffmpeg-v39.8.8-mas-x64.zip
+c803619834812960d1252d173017cb6eaca761474d2ac6853220a3d3e0581a21 *ffmpeg-v39.8.8-win32-arm64.zip
+1e9db6bd93a3e2ae8dc4ecb2852a7bb823e2cce2d24f426c8a2147b2a5ad71db *ffmpeg-v39.8.8-win32-ia32.zip
+3fbcaa872ea7c5ad3e55777293d31f35fec12570be684e44c795fc0b62d113ad *ffmpeg-v39.8.8-win32-x64.zip
+4c4e6394a5f6a3096afabfbdfaeeab18e488942a9688bb71b87e8c348aa7ae6a *hunspell_dictionaries.zip
+1f0d43d4c416e1672bfa8d4fd9b4f963183cebab35b01ca66485abb18844cd37 *libcxx-objects-v39.8.8-linux-arm64.zip
+4803952122557ce486223ac82585a759de77352b63fcc62514eadc1c356eef8d *libcxx-objects-v39.8.8-linux-armv7l.zip
+ffe241fedc47e564210f293c6881e32af2b6ab49619ae7e7d232e9434cefea59 *libcxx-objects-v39.8.8-linux-x64.zip
+d222c4130da916204964a2dbaa5d66195d8d1bb0e4ae0121563d6bd90bda63d0 *libcxx_headers.zip
+34e4b44f9c5e08b557a2caed55456ce7690abab910196a783a2a47b58d2b9ac9 *libcxxabi_headers.zip
+0b46b53037ec0c1539eee04897c094db24af21af37c2677184ef7f57eb315a9b *mksnapshot-v39.8.8-darwin-arm64.zip
+d042d23961d413262a88b99d3360f89956d77b5fb5e3210bc920811e0b0c0a97 *mksnapshot-v39.8.8-darwin-x64.zip
+8154b15b5910f310f3f6996e2f43ad307c4a871cb9983e21ea324fde956784b5 *mksnapshot-v39.8.8-linux-arm64-x64.zip
+b3656ffe35d7bdb49633fef7bdab96f08098e36173d41ed481cd68f1afe083c7 *mksnapshot-v39.8.8-linux-armv7l-x64.zip
+74c500a9ce98b84287e32ff51513af05dd149ea5178fa531be0674597f45ce0f *mksnapshot-v39.8.8-linux-x64.zip
+abc569f0321c8acff8207662c35f22944c41dafce527d56a921179738b217b74 *mksnapshot-v39.8.8-mas-arm64.zip
+7bc374665fa6067c8ea7e48596b3876f3073dc3ea782186a0a52029308b7dfeb *mksnapshot-v39.8.8-mas-x64.zip
+e8a2c01287ece06c04fabd5646fc479b9dd24e6e41b515c1f6a73fbba32d324b *mksnapshot-v39.8.8-win32-arm64-x64.zip
+9c44883b3cbea58a36c676a109f1e5797bc6b08a21c0c979447384d405d6c7b5 *mksnapshot-v39.8.8-win32-ia32.zip
+6d041d5829b53fb8838d7898ac0b96c293276f869503ed425b10651989b27ce2 *mksnapshot-v39.8.8-win32-x64.zip
diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts
index 2c44fc9427f..3c0e3a16d02 100644
--- a/build/gulpfile.reh.ts
+++ b/build/gulpfile.reh.ts
@@ -34,7 +34,7 @@ import * as cp from 'child_process';
import log from 'fancy-log';
import buildfile from './buildfile.ts';
import { fetchUrls, fetchGithub } from './lib/fetch.ts';
-import { getCopilotExcludeFilter, copyCopilotNativeDeps, prepareBuiltInCopilotExtensionShims } from './lib/copilot.ts';
+import { getCopilotExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts';
import jsonEditor from 'gulp-json-editor';
@@ -463,14 +463,13 @@ function patchWin32DependenciesTask(destinationFolderName: string) {
};
}
-function copyCopilotNativeDepsTaskREH(platform: string, arch: string, destinationFolderName: string) {
+function prepareCopilotRipgrepShimTaskREH(platform: string, arch: string, destinationFolderName: string) {
return async () => {
const outputDir = path.join(BUILD_ROOT, destinationFolderName);
const nodeModulesDir = path.join(outputDir, 'node_modules');
- copyCopilotNativeDeps(platform, arch, nodeModulesDir);
const builtInCopilotExtensionDir = path.join(outputDir, 'extensions', 'copilot');
- prepareBuiltInCopilotExtensionShims(platform, arch, builtInCopilotExtensionDir, nodeModulesDir);
+ prepareBuiltInCopilotRipgrepShim(platform, arch, builtInCopilotExtensionDir, nodeModulesDir);
};
}
@@ -523,7 +522,7 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) {
gulp.task(`node-${platform}-${arch}`) as task.Task,
util.rimraf(path.join(BUILD_ROOT, destinationFolderName)),
packageTask(type, platform, arch, sourceFolderName, destinationFolderName),
- copyCopilotNativeDepsTaskREH(platform, arch, destinationFolderName)
+ prepareCopilotRipgrepShimTaskREH(platform, arch, destinationFolderName)
];
if (platform === 'win32') {
diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts
index 31188a4ce9e..75ccf64fe7e 100644
--- a/build/gulpfile.vscode.ts
+++ b/build/gulpfile.vscode.ts
@@ -31,7 +31,7 @@ import minimist from 'minimist';
import { compileBuildWithoutManglingTask, compileBuildWithManglingTask } from './gulpfile.compile.ts';
import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask, compileCopilotExtensionBuildTask } from './gulpfile.extensions.ts';
import { copyCodiconsTask } from './lib/compilation.ts';
-import { getCopilotExcludeFilter, copyCopilotNativeDeps, prepareBuiltInCopilotExtensionShims } from './lib/copilot.ts';
+import { getCopilotExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts';
import type { EmbeddedProductInfo } from './lib/embeddedType.ts';
import { useEsbuildTranspile } from './buildConfig.ts';
import { promisify } from 'util';
@@ -700,7 +700,7 @@ function patchWin32DependenciesTask(destinationFolderName: string) {
};
}
-function copyCopilotNativeDepsTask(platform: string, arch: string, destinationFolderName: string) {
+function prepareCopilotRipgrepShimTask(platform: string, arch: string, destinationFolderName: string) {
const outputDir = path.join(path.dirname(root), destinationFolderName);
return async () => {
@@ -711,10 +711,9 @@ function copyCopilotNativeDepsTask(platform: string, arch: string, destinationFo
? path.join(outputDir, `${product.nameLong}.app`, 'Contents', 'Resources', 'app')
: path.join(outputDir, versionedResourcesFolder, 'resources', 'app');
const appNodeModulesDir = path.join(appBase, 'node_modules');
- copyCopilotNativeDeps(platform, arch, appNodeModulesDir);
const builtInCopilotExtensionDir = path.join(appBase, 'extensions', 'copilot');
- prepareBuiltInCopilotExtensionShims(platform, arch, builtInCopilotExtensionDir, appNodeModulesDir);
+ prepareBuiltInCopilotRipgrepShim(platform, arch, builtInCopilotExtensionDir, appNodeModulesDir);
};
}
@@ -743,7 +742,7 @@ BUILD_TARGETS.forEach(buildTarget => {
compileNativeExtensionsBuildTask,
util.rimraf(path.join(buildRoot, destinationFolderName)),
packageTask(platform, arch, sourceFolderName, destinationFolderName, opts),
- copyCopilotNativeDepsTask(platform, arch, destinationFolderName)
+ prepareCopilotRipgrepShimTask(platform, arch, destinationFolderName)
];
if (platform === 'win32') {
diff --git a/build/lib/copilot.ts b/build/lib/copilot.ts
index f0e50ce9a80..fec1c3d0d85 100644
--- a/build/lib/copilot.ts
+++ b/build/lib/copilot.ts
@@ -46,8 +46,9 @@ function toNodePlatformArch(platform: string, arch: string): { nodePlatform: str
* for architectures other than the build target.
*
* For platforms the copilot SDK doesn't natively support (e.g. alpine, armhf),
- * ALL platform packages are stripped - that's fine because the SDK doesn't ship
- * binaries for those platforms anyway, and we replace them with VS Code's own.
+ * ALL platform packages are stripped - that's fine because the copilot CLI SDK
+ * resolves `node-pty` from the embedder (VS Code) first via `hostRequire`,
+ * falling back to its bundled copy only if the embedder can't provide it.
*/
export function getCopilotExcludeFilter(platform: string, arch: string): string[] {
const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch);
@@ -55,74 +56,30 @@ export function getCopilotExcludeFilter(platform: string, arch: string): string[
const nonTargetPlatforms = copilotPlatforms.filter(p => p !== targetPlatformArch);
// Strip wrong-architecture @github/copilot-{platform} packages.
- // All copilot prebuilds are stripped by .moduleignore; VS Code's own
- // node-pty is copied into the prebuilds location by a post-packaging task.
+ // All copilot prebuilds are stripped by .moduleignore; the copilot CLI SDK
+ // resolves `node-pty` from VS Code's own node_modules via `hostRequire`.
const excludes = nonTargetPlatforms.map(p => `!**/node_modules/@github/copilot-${p}/**`);
return ['**', ...excludes];
}
/**
- * Copies VS Code's own node-pty binaries into the copilot SDK's
- * expected locations so the copilot CLI subprocess can find them at runtime.
- * The copilot-bundled prebuilds are stripped by .moduleignore;
- * this replaces them with the same binaries VS Code already ships, avoiding
- * new system dependency requirements.
- *
- * This works even for platforms the copilot SDK doesn't natively support
- * (e.g. alpine, armhf) because the SDK's native module loader simply
- * looks for `prebuilds/{process.platform}-{process.arch}/pty.node` - it
- * doesn't validate the platform against a supported list.
- *
- * Failures are logged but do not throw, to avoid breaking the build on
- * platforms where something unexpected happens.
- *
- * @param nodeModulesDir Absolute path to the node_modules directory that
- * contains both the source binaries (node-pty) and the copilot SDK
- * target directories.
- */
-export function copyCopilotNativeDeps(platform: string, arch: string, nodeModulesDir: string): void {
- const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch);
- const platformArch = `${nodePlatform}-${nodeArch}`;
-
- const copilotBase = path.join(nodeModulesDir, '@github', 'copilot');
- if (!fs.existsSync(copilotBase)) {
- console.warn(`[copyCopilotNativeDeps] @github/copilot not found at ${copilotBase}, skipping`);
- return;
- }
-
- const nodePtySource = path.join(nodeModulesDir, 'node-pty', 'build', 'Release');
- if (!fs.existsSync(nodePtySource)) {
- console.warn(`[copyCopilotNativeDeps] node-pty source not found at ${nodePtySource}, skipping`);
- return;
- }
-
- try {
- // Copy node-pty (pty.node + spawn-helper on Unix, conpty.node + conpty/ on Windows)
- // into copilot prebuilds so the SDK finds them via loadNativeModule.
- const copilotPrebuildsDir = path.join(copilotBase, 'prebuilds', platformArch);
- fs.mkdirSync(copilotPrebuildsDir, { recursive: true });
- fs.cpSync(nodePtySource, copilotPrebuildsDir, { recursive: true });
- console.log(`[copyCopilotNativeDeps] Copied node-pty from ${nodePtySource} to ${copilotPrebuildsDir}`);
- } catch (err) {
- console.warn(`[copyCopilotNativeDeps] Failed to copy node-pty for ${platformArch}: ${err}`);
- }
-}
-
-/**
- * Materializes copilot CLI shims directly inside the built-in copilot extension.
+ * Materializes the copilot CLI ripgrep shim directly inside the built-in copilot extension.
*
* This is used when copilot is shipped as a built-in extension so startup does
- * not need to create shims at runtime. The destination layout matches the
+ * not need to create the shim at runtime. The destination layout matches the
* runtime shim logic in the copilot extension:
- * - node-pty: node_modules/@github/copilot/sdk/prebuilds/{platform-arch}
* - ripgrep: node_modules/@github/copilot/sdk/ripgrep/bin/{platform-arch}
* - marker: node_modules/@github/copilot/shims.txt
*
+ * Note: `node-pty` is no longer shimmed. The copilot CLI SDK resolves
+ * `node-pty` from the embedder (VS Code) via `hostRequire` and falls back to
+ * its bundled copy only if that fails.
+ *
* Failures throw to fail the build because built-in packaging must guarantee
- * these artifacts are present.
+ * this artifact is present.
*/
-export function prepareBuiltInCopilotExtensionShims(platform: string, arch: string, builtInCopilotExtensionDir: string, appNodeModulesDir: string): void {
+export function prepareBuiltInCopilotRipgrepShim(platform: string, arch: string, builtInCopilotExtensionDir: string, appNodeModulesDir: string): void {
const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch);
const platformArch = `${nodePlatform}-${nodeArch}`;
@@ -130,33 +87,24 @@ export function prepareBuiltInCopilotExtensionShims(platform: string, arch: stri
const copilotBase = path.join(extensionNodeModules, '@github', 'copilot');
const copilotSdkBase = path.join(copilotBase, 'sdk');
if (!fs.existsSync(copilotSdkBase)) {
- throw new Error(`[prepareBuiltInCopilotExtensionShims] Copilot SDK directory not found at ${copilotSdkBase}`);
- }
-
- const nodePtySource = path.join(appNodeModulesDir, 'node-pty', 'build', 'Release');
- if (!fs.existsSync(nodePtySource)) {
- throw new Error(`[prepareBuiltInCopilotExtensionShims] node-pty source not found at ${nodePtySource}`);
+ throw new Error(`[prepareBuiltInCopilotRipgrepShim] Copilot SDK directory not found at ${copilotSdkBase}`);
}
const ripgrepSource = path.join(appNodeModulesDir, '@vscode', 'ripgrep', 'bin');
if (!fs.existsSync(ripgrepSource)) {
- throw new Error(`[prepareBuiltInCopilotExtensionShims] ripgrep source not found at ${ripgrepSource}`);
+ throw new Error(`[prepareBuiltInCopilotRipgrepShim] ripgrep source not found at ${ripgrepSource}`);
}
- const nodePtyDest = path.join(copilotSdkBase, 'prebuilds', platformArch);
const ripgrepDest = path.join(copilotSdkBase, 'ripgrep', 'bin', platformArch);
const shimMarkerPath = path.join(copilotBase, 'shims.txt');
try {
- fs.mkdirSync(nodePtyDest, { recursive: true });
- fs.cpSync(nodePtySource, nodePtyDest, { recursive: true });
-
fs.mkdirSync(ripgrepDest, { recursive: true });
fs.cpSync(ripgrepSource, ripgrepDest, { recursive: true });
fs.writeFileSync(shimMarkerPath, 'Shims created successfully');
- console.log(`[prepareBuiltInCopilotExtensionShims] Materialized shims for ${platformArch} in ${builtInCopilotExtensionDir}`);
+ console.log(`[prepareBuiltInCopilotRipgrepShim] Materialized ripgrep shim for ${platformArch} in ${builtInCopilotExtensionDir}`);
} catch (err) {
- throw new Error(`[prepareBuiltInCopilotExtensionShims] Failed to materialize shims for ${platformArch}: ${err}`);
+ throw new Error(`[prepareBuiltInCopilotRipgrepShim] Failed to materialize ripgrep shim for ${platformArch}: ${err}`);
}
}
diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json
index 960c4f10529..d8d04044494 100644
--- a/build/lib/i18n.resources.json
+++ b/build/lib/i18n.resources.json
@@ -636,6 +636,10 @@
"name": "vs/sessions/contrib/agentFeedback",
"project": "vscode-sessions"
},
+ {
+ "name": "vs/sessions/contrib/agentHost",
+ "project": "vscode-sessions"
+ },
{
"name": "vs/sessions/contrib/aiCustomizationTreeView",
"project": "vscode-sessions"
@@ -672,10 +676,10 @@
"name": "vs/sessions/contrib/files",
"project": "vscode-sessions"
},
- {
- "name": "vs/sessions/contrib/policyBlocked",
- "project": "vscode-sessions"
- },
+ {
+ "name": "vs/sessions/contrib/policyBlocked",
+ "project": "vscode-sessions"
+ },
{
"name": "vs/sessions/contrib/git",
"project": "vscode-sessions"
@@ -684,10 +688,6 @@
"name": "vs/sessions/contrib/logs",
"project": "vscode-sessions"
},
- {
- "name": "vs/sessions/contrib/localAgentHost",
- "project": "vscode-sessions"
- },
{
"name": "vs/sessions/contrib/remoteAgentHost",
"project": "vscode-sessions"
@@ -711,6 +711,10 @@
{
"name": "vs/sessions/contrib/tunnelHost",
"project": "vscode-sessions"
+ },
+ {
+ "name": "vs/sessions/contrib/editor",
+ "project": "vscode-sessions"
}
]
}
diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json
index 59dfc331f35..a46bef0bbab 100644
--- a/build/lib/stylelint/vscode-known-variables.json
+++ b/build/lib/stylelint/vscode-known-variables.json
@@ -61,6 +61,9 @@
"--vscode-chat-avatarForeground",
"--vscode-chat-checkpointSeparator",
"--vscode-chat-editedFileForeground",
+ "--vscode-chat-inputWorkingBorderColor1",
+ "--vscode-chat-inputWorkingBorderColor2",
+ "--vscode-chat-inputWorkingBorderColor3",
"--vscode-chat-linesAddedForeground",
"--vscode-chat-linesRemovedForeground",
"--vscode-chat-requestBackground",
@@ -941,6 +944,7 @@
"--inline-chat-frame-progress",
"--insert-border-color",
"--last-tab-margin-right",
+ "--last-tab-layout-actions-width",
"--list-scroll-right-offset",
"--monaco-monospace-font",
"--monaco-monospace-font",
@@ -1036,6 +1040,8 @@
"--monaco-editor-warning-decoration",
"--animation-angle",
"--animation-opacity",
+ "--chat-input-anim-angle",
+ "--chat-send-button-anim-angle",
"--chat-setup-dialog-glow-angle",
"--vscode-chat-font-family",
"--vscode-chat-font-size-body-l",
diff --git a/cgmanifest.json b/cgmanifest.json
index 41466b8883d..5dfcbbfadca 100644
--- a/cgmanifest.json
+++ b/cgmanifest.json
@@ -529,13 +529,13 @@
"git": {
"name": "electron",
"repositoryUrl": "https://github.com/electron/electron",
- "commitHash": "2d7e11a76ca841e08e31eb0121056d875f731f30",
- "tag": "39.8.7"
+ "commitHash": "c0435f7a9fc498b1ece041d209c6c74d4a7e201b",
+ "tag": "39.8.8"
}
},
"isOnlyProductionDependency": true,
"license": "MIT",
- "version": "39.8.7"
+ "version": "39.8.8"
},
{
"component": {
diff --git a/eslint.config.js b/eslint.config.js
index 8b6235587f8..ca35a088c17 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -159,21 +159,7 @@ export default tseslint.config(
}
],
'jsdoc/no-types': 'warn',
- 'local/code-no-static-self-ref': 'warn'
- }
- },
- // vscode TS
- {
- files: [
- 'src/**/*.ts',
- ],
- languageOptions: {
- parser: tseslint.parser,
- },
- plugins: {
- '@typescript-eslint': tseslint.plugin,
- },
- rules: {
+ 'local/code-no-static-self-ref': 'warn',
'@typescript-eslint/naming-convention': [
'warn',
{
diff --git a/extensions/copilot/.esbuild.ts b/extensions/copilot/.esbuild.ts
index c5c11434b85..68dd4703578 100644
--- a/extensions/copilot/.esbuild.ts
+++ b/extensions/copilot/.esbuild.ts
@@ -183,7 +183,6 @@ const nodeExtHostBuildOptions = {
{ in: './src/platform/parser/node/parserWorker.ts', out: 'worker2' },
{ in: './src/platform/tokenizer/node/tikTokenizerWorker.ts', out: 'tikTokenizerWorker' },
{ in: './src/platform/diff/node/diffWorkerMain.ts', out: 'diffWorker' },
- { in: './src/platform/tfidf/node/tfidfWorker.ts', out: 'tfidfWorker' },
{ in: './src/extension/chatSessions/copilotcli/node/copilotCLITodoWorker.ts', out: 'copilotCLITodoWorker' },
{ in: './src/extension/onboardDebug/node/copilotDebugWorker/index.ts', out: 'copilotDebugCommand' },
{ in: './src/extension/chatSessions/vscode-node/copilotCLIShim.ts', out: 'copilotCLIShim' },
diff --git a/extensions/copilot/.vscodeignore b/extensions/copilot/.vscodeignore
index b506880f18b..85fb8d7f7ca 100644
--- a/extensions/copilot/.vscodeignore
+++ b/extensions/copilot/.vscodeignore
@@ -9,7 +9,6 @@ assets/walkthroughs/**
!dist/*.bpe
!dist/*.tiktoken
!dist/node_modules/**
-!dist/tfidfWorker.js
!dist/worker2.js
!dist/tikTokenizerWorker.js
!dist/diffWorker.js
diff --git a/extensions/copilot/build/pre-release.yml b/extensions/copilot/build/pre-release.yml
index 00c83bbbafd..06fb5999002 100644
--- a/extensions/copilot/build/pre-release.yml
+++ b/extensions/copilot/build/pre-release.yml
@@ -257,3 +257,4 @@ extends:
publishExtension: ${{ parameters.publishExtension }}
ghReleasePublishVSIX: true
+ ghTagPrefix: 'copilot/'
diff --git a/extensions/copilot/build/release.yml b/extensions/copilot/build/release.yml
index 7e3022928ed..7bfcd30b6c7 100644
--- a/extensions/copilot/build/release.yml
+++ b/extensions/copilot/build/release.yml
@@ -243,3 +243,4 @@ extends:
publishExtension: ${{ parameters.publishExtension }}
ghReleasePublishVSIX: true
+ ghTagPrefix: 'copilot/'
diff --git a/extensions/copilot/chat-lib/package-lock.json b/extensions/copilot/chat-lib/package-lock.json
index cc1de521b7e..7e8ac762088 100644
--- a/extensions/copilot/chat-lib/package-lock.json
+++ b/extensions/copilot/chat-lib/package-lock.json
@@ -30,7 +30,6 @@
"@anthropic-ai/sdk": "^0.82.0",
"@octokit/types": "^14.1.0",
"@types/node": "^22.16.3",
- "@types/vscode": "^1.109.0",
"copyfiles": "^2.4.1",
"dotenv": "^17.2.0",
"npm-run-all": "^4.1.5",
@@ -1221,13 +1220,6 @@
"resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.0.3.tgz",
"integrity": "sha512-F/IjUGnV6pIN7R4ZV4npHJVoNtaLZWvb+2/9gctxjb99wkpI7Ozg8VPogwDiTRyjLwZXAYxjvdg1KS8LTHKdDA=="
},
- "node_modules/@types/vscode": {
- "version": "1.109.0",
- "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz",
- "integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -1566,9 +1558,9 @@
"dev": true
},
"node_modules/brace-expansion": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
- "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+ "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3234,9 +3226,9 @@
}
},
"node_modules/minimatch/node_modules/brace-expansion": {
- "version": "5.0.3",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
- "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4830,13 +4822,13 @@
}
},
"node_modules/vite": {
- "version": "7.2.2",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
- "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
+ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "esbuild": "^0.25.0",
+ "esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@@ -4927,6 +4919,490 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/vite/node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
diff --git a/extensions/copilot/eslint.config.mjs b/extensions/copilot/eslint.config.mjs
index 4006797351e..7edfb1982d9 100644
--- a/extensions/copilot/eslint.config.mjs
+++ b/extensions/copilot/eslint.config.mjs
@@ -338,7 +338,6 @@ export default tseslint.config(
ignores: [
'src/util/vs/**/*.ts', // vendored code
'src/**/*.spec.ts', // allow in tests
- './src/extension/agents/copilotcli/node/nodePtyShim.ts',
'./src/extension/byok/common/anthropicMessageConverter.ts',
'./src/extension/byok/common/geminiFunctionDeclarationConverter.ts',
'./src/extension/byok/common/geminiMessageConverter.ts',
@@ -489,9 +488,6 @@ export default tseslint.config(
'./src/platform/test/node/telemetry.ts',
'./src/platform/test/node/testWorkbenchService.ts',
'./src/platform/testing/common/nullWorkspaceMutationManager.ts',
- './src/platform/tfidf/node/tfidf.ts',
- './src/platform/tfidf/node/tfidfMessaging.ts',
- './src/platform/tfidf/node/tfidfWorker.ts',
'./src/platform/thinking/common/thinking.ts',
'./src/platform/tokenizer/node/tikTokenizerWorker.ts',
'./src/platform/tokenizer/node/tokenizer.ts',
diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json
index d2e50eb11bd..e7b122547ef 100644
--- a/extensions/copilot/package-lock.json
+++ b/extensions/copilot/package-lock.json
@@ -1,19 +1,19 @@
{
"name": "copilot-chat",
- "version": "0.45.0",
+ "version": "0.46.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "copilot-chat",
- "version": "0.45.0",
+ "version": "0.46.0",
"hasInstallScript": true,
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
- "@anthropic-ai/claude-agent-sdk": "0.2.98",
+ "@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.28",
+ "@github/copilot": "^1.0.34",
"@google/genai": "^1.22.0",
"@humanwhocodes/gitignore-to-minimatch": "1.0.2",
"@microsoft/tiktokenizer": "^1.0.10",
@@ -153,7 +153,7 @@
"engines": {
"node": ">=22.14.0",
"npm": ">=9.0.0",
- "vscode": "^1.117.0"
+ "vscode": "^1.118.0"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -180,13 +180,13 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
- "version": "0.2.98",
- "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.98.tgz",
- "integrity": "sha512-pWUx+xY21rKy5wvX0eBZja7p8J5ykOYaHsykvdj9nkTbAVXmP1WusI1mP6jbBByJ8uBJeBc4beAPSZIFcdIpTA==",
+ "version": "0.2.112",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.112.tgz",
+ "integrity": "sha512-vMFoiDKlOive8p3tphpV1gQaaytOipwGJ+uw9mvvaLQUODSC2+fCdRDAY25i2Tsv+lOtxzXBKctmaDuWqZY7ig==",
"license": "SEE LICENSE IN README.md",
"dependencies": {
- "@anthropic-ai/sdk": "^0.80.0",
- "@modelcontextprotocol/sdk": "^1.27.1"
+ "@anthropic-ai/sdk": "^0.81.0",
+ "@modelcontextprotocol/sdk": "^1.29.0"
},
"engines": {
"node": ">=18.0.0"
@@ -207,9 +207,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/sdk": {
- "version": "0.80.0",
- "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.80.0.tgz",
- "integrity": "sha512-WeXLn7zNVk3yjeshn+xZHvld6AoFUOR3Sep6pSoHho5YbSi6HwcirqgPA5ccFuW8QTVJAAU7N8uQQC6Wa9TG+g==",
+ "version": "0.81.0",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz",
+ "integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
@@ -3203,26 +3203,26 @@
"license": "MIT"
},
"node_modules/@github/copilot": {
- "version": "1.0.28",
- "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.28.tgz",
- "integrity": "sha512-S1Y+KnhywjIsK1DzskoCqPVC3uURohvCRyDkGPWXvMw+lXO5ryOJvHFZDDw7MSRjT7ea7T0m8e3yKdK0OxJhnw==",
+ "version": "1.0.34",
+ "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.34.tgz",
+ "integrity": "sha512-jFYulj1v00b3j43Er9+WwhZ/XldGq7+gti2s2pRhrdPwYEd1PMvscDZwRa/1iUBz/XQ5HUGac1tD8P7+VUpWjg==",
"license": "SEE LICENSE IN LICENSE.md",
"bin": {
"copilot": "npm-loader.js"
},
"optionalDependencies": {
- "@github/copilot-darwin-arm64": "1.0.28",
- "@github/copilot-darwin-x64": "1.0.28",
- "@github/copilot-linux-arm64": "1.0.28",
- "@github/copilot-linux-x64": "1.0.28",
- "@github/copilot-win32-arm64": "1.0.28",
- "@github/copilot-win32-x64": "1.0.28"
+ "@github/copilot-darwin-arm64": "1.0.34",
+ "@github/copilot-darwin-x64": "1.0.34",
+ "@github/copilot-linux-arm64": "1.0.34",
+ "@github/copilot-linux-x64": "1.0.34",
+ "@github/copilot-win32-arm64": "1.0.34",
+ "@github/copilot-win32-x64": "1.0.34"
}
},
"node_modules/@github/copilot-darwin-arm64": {
- "version": "1.0.28",
- "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.28.tgz",
- "integrity": "sha512-Bkis5dkOsdgaK95j/8mgIGSxHlRuL211Wa3S4MeeYGrilZweaG20sa0jktzagL6XFxfPRKBC87E+fDFyXz1L3g==",
+ "version": "1.0.34",
+ "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.34.tgz",
+ "integrity": "sha512-g94EhSLd3a6fckZ6xb/zP2DZJZEx7kONWdOoDiHXUtSqc4RiZ7OBq1EwT4WrPY1lsmy9sioJIcZSGzJd0C1M7Q==",
"cpu": [
"arm64"
],
@@ -3236,9 +3236,9 @@
}
},
"node_modules/@github/copilot-darwin-x64": {
- "version": "1.0.28",
- "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.28.tgz",
- "integrity": "sha512-0RIabmr05KgPPUcD4kpKNBGg/eRwJF2NrYtibDUCIRFWKZu7q0m9c9EURpW0wOO32cXZtAQ+BmJIGlqfCkt6gA==",
+ "version": "1.0.34",
+ "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.34.tgz",
+ "integrity": "sha512-tIgFEZV0ohCF/VgTODJWre3xURsvEd+6IPN/HPKWxG6AXtJOxzjlr5kLYYdPHdNlHNmSxGQw8fWsN2FZ4nyDdw==",
"cpu": [
"x64"
],
@@ -3252,9 +3252,9 @@
}
},
"node_modules/@github/copilot-linux-arm64": {
- "version": "1.0.28",
- "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.28.tgz",
- "integrity": "sha512-A/zQ4ifN+FSSEHdPHajv5UwygS5BOQ8l1AJMYdVBnnuqVX9bCcRAJJ4S/F60AnaDimzDvVuYSe3lYXRYxz3M5A==",
+ "version": "1.0.34",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.34.tgz",
+ "integrity": "sha512-feqjEetrlqBUhYskIsPmwACQOWO99cvRpKwIFl3OlEjWoj+//HA7yXh49UIe0gD8wQUI8hy05uVz3K2/xti2nQ==",
"cpu": [
"arm64"
],
@@ -3268,9 +3268,9 @@
}
},
"node_modules/@github/copilot-linux-x64": {
- "version": "1.0.28",
- "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.28.tgz",
- "integrity": "sha512-0VqoW9hj7qKj+eH2un9E7zn9AbassTZHkKQPsd8yPvLsmPaNJgsHMYDrCCNZNol2ZSGt/XskTfmWQaQM6BoBfg==",
+ "version": "1.0.34",
+ "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.34.tgz",
+ "integrity": "sha512-3l0rZZqmceklHizJaaO+Iy2PsAZpVZS9Mn9VYnVcY/8Yzt4Y2hmXSFcKVfc4l+JlhFsPs7trhMdIkfwkjaKPLg==",
"cpu": [
"x64"
],
@@ -3284,9 +3284,9 @@
}
},
"node_modules/@github/copilot-win32-arm64": {
- "version": "1.0.28",
- "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.28.tgz",
- "integrity": "sha512-f28NKudBtIXTpIliHGJbRhEfCItsXKWNzXzgqgmP8FZB+JYrqG/ysU2qCUCxhpv3PLjMLWqnsWs+mIvVLTH9zw==",
+ "version": "1.0.34",
+ "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.34.tgz",
+ "integrity": "sha512-06kEJO3iyohmAqF4iIbOxOfWLFSIpLDJ1L1oEHRtouMrH2Ll1wrUjsoQT1gXgBOv7rifl25qx/Avx5zKqvuORw==",
"cpu": [
"arm64"
],
@@ -3300,9 +3300,9 @@
}
},
"node_modules/@github/copilot-win32-x64": {
- "version": "1.0.28",
- "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.28.tgz",
- "integrity": "sha512-b9ZEx2i5P7DZTP66FXTfwf81r5kbAqs2GEJjDdevCwxH7cRexqM9eBxQGj1zGtm4qXF7JGK2eH6Ay7NC28m1Iw==",
+ "version": "1.0.34",
+ "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.34.tgz",
+ "integrity": "sha512-QLL8pS4q2TTyQbClEXxqXtQGPr4lk+pwc8hPMUL7iw7HGDOvs1WCLMT1ZSDPPcxSrTnR/dURX5za1NMA8uF/fw==",
"cpu": [
"x64"
],
diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json
index ad6d82a7892..8525cd4b5cb 100644
--- a/extensions/copilot/package.json
+++ b/extensions/copilot/package.json
@@ -2,7 +2,7 @@
"name": "copilot-chat",
"displayName": "GitHub Copilot Chat",
"description": "AI chat features powered by Copilot",
- "version": "0.45.0",
+ "version": "0.46.0",
"build": "1",
"internalAIKey": "1058ec22-3c95-4951-8443-f26c1f325911",
"completionsCoreVersion": "1.378.1799",
@@ -23,7 +23,7 @@
"icon": "assets/copilot.png",
"pricing": "Trial",
"engines": {
- "vscode": "^1.117.0",
+ "vscode": "^1.118.0",
"npm": ">=9.0.0",
"node": ">=22.14.0"
},
@@ -3968,15 +3968,6 @@
{
"id": "advanced",
"properties": {
- "github.copilot.chat.sessionSearch.cloudSync.enabled": {
- "type": "boolean",
- "default": false,
- "markdownDescription": "%github.copilot.config.sessionSearch.cloudSync.enabled%",
- "tags": [
- "advanced",
- "onExp"
- ]
- },
"github.copilot.chat.reasoningEffortOverride": {
"type": [
"string",
@@ -4231,6 +4222,33 @@
"onExp"
]
},
+ "github.copilot.chat.inlineChat.reasoningEffort": {
+ "type": "string",
+ "default": "low",
+ "enum": [
+ "none",
+ "minimal",
+ "low",
+ "medium",
+ "high"
+ ],
+ "markdownDescription": "%github.copilot.config.inlineChat.reasoningEffort%",
+ "tags": [
+ "advanced",
+ "experimental",
+ "onExp"
+ ]
+ },
+ "github.copilot.chat.inlineChat.enableThinking": {
+ "type": "boolean",
+ "default": false,
+ "markdownDescription": "%github.copilot.config.inlineChat.enableThinking%",
+ "tags": [
+ "advanced",
+ "experimental",
+ "onExp"
+ ]
+ },
"github.copilot.chat.debug.requestLogger.maxEntries": {
"type": "number",
"default": 100,
@@ -5691,7 +5709,7 @@
{
"command": "github.copilot.sessions.discardChanges",
"when": "chatSessionType == copilotcli && isSessionsWindow && sessions.hasGitRepository && sessions.changesVersionMode == branchChanges",
- "group": "navigation@1"
+ "group": "navigation@2"
}
],
"chat/customizations/create": [
@@ -6180,62 +6198,89 @@
"chatPromptFiles": [
{
"path": "./assets/prompts/plan.prompt.md",
- "sessionTypes": ["local"]
+ "sessionTypes": [
+ "local"
+ ]
}
],
"chatSkills": [
{
"path": "./assets/prompts/skills/project-setup-info-local/SKILL.md",
"when": "config.github.copilot.chat.projectSetupInfoSkill.enabled && !config.github.copilot.chat.newWorkspace.useContext7",
- "sessionTypes": ["local"]
+ "sessionTypes": [
+ "local"
+ ]
},
{
"path": "./assets/prompts/skills/project-setup-info-context7/SKILL.md",
"when": "config.github.copilot.chat.projectSetupInfoSkill.enabled && config.github.copilot.chat.newWorkspace.useContext7",
- "sessionTypes": ["local"]
+ "sessionTypes": [
+ "local"
+ ]
},
{
"path": "./assets/prompts/skills/install-vscode-extension/SKILL.md",
"when": "config.github.copilot.chat.installExtensionSkill.enabled && config.github.copilot.chat.newWorkspaceCreation.enabled",
- "sessionTypes": ["local"]
+ "sessionTypes": [
+ "local"
+ ]
},
{
"path": "./assets/prompts/skills/get-search-view-results/SKILL.md",
"when": "config.github.copilot.chat.getSearchViewResultsSkill.enabled",
- "sessionTypes": ["local"]
+ "sessionTypes": [
+ "local"
+ ]
},
{
"path": "./assets/prompts/skills/troubleshoot/SKILL.md",
- "sessionTypes": ["local", "copilotcli"]
+ "sessionTypes": [
+ "local",
+ "copilotcli"
+ ]
},
{
"path": "./assets/prompts/skills/agent-customization/SKILL.md",
- "sessionTypes": ["local", "copilotcli"]
+ "sessionTypes": [
+ "local",
+ "copilotcli"
+ ]
},
{
"path": "./assets/prompts/skills/init/SKILL.md",
- "sessionTypes": ["local"]
+ "sessionTypes": [
+ "local"
+ ]
},
{
"path": "./assets/prompts/skills/create-prompt/SKILL.md",
- "sessionTypes": ["local"]
+ "sessionTypes": [
+ "local"
+ ]
},
{
"path": "./assets/prompts/skills/create-instructions/SKILL.md",
- "sessionTypes": ["local"]
-
+ "sessionTypes": [
+ "local"
+ ]
},
{
"path": "./assets/prompts/skills/create-skill/SKILL.md",
- "sessionTypes": ["local"]
+ "sessionTypes": [
+ "local"
+ ]
},
{
"path": "./assets/prompts/skills/create-agent/SKILL.md",
- "sessionTypes": ["local"]
+ "sessionTypes": [
+ "local"
+ ]
},
{
"path": "./assets/prompts/skills/create-hook/SKILL.md",
- "sessionTypes": ["local"]
+ "sessionTypes": [
+ "local"
+ ]
}
],
"terminal": {
@@ -6390,10 +6435,10 @@
"zod": "3.25.76"
},
"dependencies": {
- "@anthropic-ai/claude-agent-sdk": "0.2.98",
+ "@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.28",
+ "@github/copilot": "^1.0.34",
"@google/genai": "^1.22.0",
"@humanwhocodes/gitignore-to-minimatch": "1.0.2",
"@microsoft/tiktokenizer": "^1.0.10",
diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json
index 48568dfeee7..7d7f9d690d3 100644
--- a/extensions/copilot/package.nls.json
+++ b/extensions/copilot/package.nls.json
@@ -173,7 +173,7 @@
"copilot.chronicle.tips.description": "Get personalized tips based on your Copilot usage patterns",
"github.copilot.config.sessionSearch.enabled": "Enable session search and /chronicle commands. This is a team-internal setting.",
"github.copilot.config.sessionSearch.localIndex.enabled": "Enable local session tracking. When enabled, Copilot tracks session data locally for /chronicle commands.",
- "github.copilot.config.sessionSearch.cloudSync.enabled": "Enable cloud sync for session data. When enabled, chat session data is synced to the cloud for cross-device querying.",
+ "github.copilot.config.sessionSearch.cloudSync.enabled": "Enable cloud sync for session data. When enabled, session data is synced to your Copilot account for cross-device access.",
"github.copilot.config.sessionSearch.cloudSync.excludeRepositories": "Repository patterns to exclude from cloud sync. Use exact `owner/repo` names or glob patterns like `my-org/*`. Sessions from matching repos will only be stored locally.",
"copilot.workspace.explain.description": "Explain how the code in your active editor works",
"copilot.workspace.edit.description": "Edit files in your workspace",
@@ -374,6 +374,8 @@
"github.copilot.config.localWorkspaceRecording.enabled": "Enable local workspace recording for analysis.",
"github.copilot.config.editRecording.enabled": "Enable edit recording for analysis.",
"github.copilot.config.inlineChat.selectionRatioThreshold": "Threshold at which to switch editing strategies for inline chat. When a selection portion of code matches a parse tree node, only that is presented to the language model. This speeds up response times but might have lower quality results. Requires having a parse tree for the document and the `inlineChat.enableV2` setting. Values must be between 0 and 1, where 0 means off and 1 means the selection perfectly matches a parse tree node.",
+ "github.copilot.config.inlineChat.reasoningEffort": "Controls the reasoning effort level for inline chat requests. Lower values result in faster responses with fewer reasoning tokens. Supported values depend on the model.",
+ "github.copilot.config.inlineChat.enableThinking": "Controls whether thinking/reasoning is enabled for inline chat requests. When disabled, reasoning summaries are suppressed for faster responses.",
"github.copilot.config.debug.requestLogger.maxEntries": "Maximum number of entries to keep in the request logger for debugging purposes.",
"github.copilot.config.chat.agentDebugLog.enabled": "Deprecated: use `github.copilot.chat.agentDebugLog.fileLogging.enabled` instead.",
"github.copilot.config.chat.agentDebugLog.enabled.deprecated": "This setting has been merged into `github.copilot.chat.agentDebugLog.fileLogging.enabled`. Please use this setting instead.",
diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionSchema.ts b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionSchema.ts
index 8035e48cbfc..7d3e1c3cd0b 100644
--- a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionSchema.ts
+++ b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/claudeSessionSchema.ts
@@ -517,6 +517,8 @@ export interface IClaudeCodeSessionInfo {
readonly folderName?: string;
/** Current working directory of the session */
readonly cwd?: string;
+ /** Git branch of the session */
+ readonly gitBranch?: string;
}
// #endregion
diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/sdkSessionAdapter.ts b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/sdkSessionAdapter.ts
index f074a576e8a..725d562fbc4 100644
--- a/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/sdkSessionAdapter.ts
+++ b/extensions/copilot/src/extension/chatSessions/claude/node/sessionParser/sdkSessionAdapter.ts
@@ -89,7 +89,8 @@ export function sdkSessionInfoToSessionInfo(
created: info.createdAt ?? info.lastModified,
lastRequestEnded: info.lastModified,
folderName,
- cwd: info.cwd
+ cwd: info.cwd,
+ gitBranch: info.gitBranch,
};
}
diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts
index 78d876be13f..e38c94ebdff 100644
--- a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts
+++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts
@@ -91,6 +91,21 @@ export interface ChatSessionMetadataFile {
* session or if the session is a child session created from the Agents app.
*/
parentSessionId?: string;
+ /** Milliseconds since epoch when this metadata was first written. */
+ created?: number;
+ /** Milliseconds since epoch of the last write. Used for top-N trim sort and cross-process merge. */
+ modified?: number;
+}
+
+/**
+ * One line in `~/.copilot/vscode.session.worktree.jsonl`. Maps a session id
+ * to the path of its worktree so folder → session lookups work even when the
+ * session has been evicted from the bulk metadata cache.
+ */
+export interface WorktreeSessionEntry {
+ readonly id: string;
+ readonly path: string;
+ readonly created: number;
}
export const IChatSessionMetadataStore = createServiceIdentifier('IChatSessionMetadataStore');
@@ -124,4 +139,12 @@ export interface IChatSessionMetadataStore {
storeForkedSessionMetadata(sourceSessionId: string, targetSessionId: string, customTitle: string): Promise;
setSessionOrigin(sessionId: string): Promise;
getSessionOrigin(sessionId: string): Promise<'vscode' | 'other'>;
+ setSessionParentId(sessionId: string, parentSessionId: string): Promise;
+ getSessionParentId(sessionId: string): Promise;
+ /**
+ * Re-read the shared bulk metadata file from disk and merge into the in-memory cache.
+ * Wired to the chat-sessions UI refresh action so cross-process writes become visible
+ * on demand. Concurrent calls collapse: at most one in-flight + one pending.
+ */
+ refresh(): Promise;
}
diff --git a/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts b/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts
index 9bc85d003c1..96aa2ee811e 100644
--- a/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts
+++ b/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts
@@ -31,6 +31,10 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore {
this._requestDetails.delete(sessionId);
}
+ async refresh(): Promise {
+ // no-op in mock — there is no on-disk state to reload.
+ }
+
async storeWorktreeInfo(sessionId: string, properties: ChatSessionWorktreeProperties): Promise {
this._worktreeProperties.set(sessionId, properties);
}
@@ -143,4 +147,12 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore {
async getSessionOrigin(sessionId: string): Promise<'vscode' | 'other'> {
return this._sessionOrigins.get(sessionId) ?? 'vscode';
}
+
+ setSessionParentId(_sessionId: string, _parentSessionId: string): Promise {
+ return Promise.resolve();
+ }
+
+ getSessionParentId(_sessionId: string): Promise {
+ return Promise.resolve(undefined);
+ }
}
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/AGENTS.md b/extensions/copilot/src/extension/chatSessions/copilotcli/AGENTS.md
index eb343ccf15b..ebd7169ae23 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/AGENTS.md
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/AGENTS.md
@@ -78,7 +78,6 @@ copilotcli/
│ ├── copilotCLISkills.ts # Skills location resolution
│ ├── copilotCLIImageSupport.ts # Image attachment handling
│ ├── mcpHandler.ts # MCP server configuration for SDK sessions
-│ ├── nodePtyShim.ts # Copies VS Code's node-pty for SDK use
│ ├── userInputHelpers.ts # User question/input handling interface
│ ├── exitPlanModeHandler.ts # Plan mode exit flow with user choice
│ ├── ripgrepShim.ts # Copies VS Code's ripgrep for SDK use
@@ -313,7 +312,7 @@ Orchestrates the start and end of each chat request turn, coordinating worktree
## Critical Pitfalls
-- **Shims before SDK import**: `ensureNodePtyShim()` and `ensureRipgrepShim()` in `node/nodePtyShim.ts` / `node/ripgrepShim.ts` MUST be called before any `import('@github/copilot/sdk')`. They copy VS Code's bundled native binaries to the SDK's expected locations. See `node/copilotCli.ts` for the initialization order.
+- **Shims before SDK import**: `ensureRipgrepShim()` in `node/ripgrepShim.ts` MUST be called before any `import('@github/copilot/sdk')`. It copies VS Code's bundled ripgrep binary to the SDK's expected location. `node-pty` is no longer shimmed: the copilot CLI SDK resolves it from VS Code's own `node_modules` via `hostRequire`, falling back to its bundled copy only if that fails. See `node/copilotCli.ts` for the initialization order.
- **Delayed permission UI**: Tool invocation messages are held in `toolCallWaitingForPermissions` until permission resolves. `flushPendingInvocationMessageForToolCallId()` flushes only the specific approved tool, not all pending tools. This is intentional — don't bypass it.
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts
index dae44d37c50..8bfcbeaf367 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts
@@ -1601,6 +1601,17 @@ export async function updateTodoListFromSqlItems(
}, token);
}
+export async function clearTodoList(toolsService: IToolsService,
+ toolInvocationToken: ChatParticipantToolToken,
+ token: CancellationToken): Promise {
+ await toolsService.invokeTool(ToolName.CoreManageTodoList, {
+ input: {
+ operation: 'write',
+ todoList: []
+ } satisfies IManageTodoListToolInputParams,
+ toolInvocationToken,
+ }, token);
+}
interface IManageTodoListToolInputParams {
readonly operation?: 'write' | 'read'; // Optional in write-only mode
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts
index 3ecbf0330d5..ea9074ae5d3 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts
@@ -36,3 +36,21 @@ export function getCopilotCLISessionEventsFile(sessionId: string) {
export function getCopilotCLIWorkspaceFile(sessionId: string) {
return join(getCopilotCLISessionDir(sessionId), 'workspace.yaml');
}
+
+/**
+ * Path of the shared bulk metadata cache file. This file is shared by all VS Code
+ * installs (Stable, Insiders, OSS, Exploration) and the Agents application.
+ */
+export function getCopilotBulkMetadataFile(): string {
+ return join(getCopilotHome(), 'vscode.session.metadata.cache.json');
+}
+
+/**
+ * Path of the shared worktree-sessions JSONL index. Append-only, one
+ * {@link WorktreeSessionEntry} per line.
+ * Used as a worktree folder → session-id fallback
+ * when an entry has been evicted from the bulk cache.
+ */
+export function getCopilotWorktreeSessionsFile(): string {
+ return join(getCopilotHome(), 'vscode.session.worktree.jsonl');
+}
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts
index 72308407e8c..5ba70073994 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts
@@ -23,7 +23,6 @@ import { basename } from '../../../../util/vs/base/common/resources';
import { URI } from '../../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { getCopilotLogger } from './logger';
-import { ensureNodePtyShim } from './nodePtyShim';
import { ensureRipgrepShim } from './ripgrepShim';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
@@ -460,7 +459,7 @@ export class CopilotCLISDK implements ICopilotCLISDK {
public async getPackage(): Promise {
try {
- // Ensure the node-pty shim exists before importing the SDK (required for CLI sessions)
+ // Ensure the ripgrep shim exists before importing the SDK (required for CLI sessions)
await this._ensureShimsPromise;
return await import('@github/copilot/sdk');
} catch (error) {
@@ -497,10 +496,7 @@ export class CopilotCLISDK implements ICopilotCLISDK {
if (await checkFileExists(successfulPlaceholder)) {
return;
}
- await Promise.all([
- ensureNodePtyShim(this.extensionContext.extensionPath, this.envService.appRoot, this.logService),
- ensureRipgrepShim(this.extensionContext.extensionPath, this.envService.appRoot, this.logService)
- ]);
+ await ensureRipgrepShim(this.extensionContext.extensionPath, this.envService.appRoot, this.logService);
await fs.writeFile(successfulPlaceholder, 'Shims created successfully');
}
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
index e4c1bd69b26..96f64b577f3 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts
@@ -29,7 +29,7 @@ import { IToolsService } from '../../../tools/common/toolsService';
import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';
import { ExternalEditTracker } from '../../common/externalEditTracker';
import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../../common/workspaceInfo';
-import { enrichToolInvocationWithSubagentMetadata, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, isTodoRelatedSqlQuery, processToolExecutionComplete, processToolExecutionStart, ToolCall, updateTodoListFromSqlItems } from '../common/copilotCLITools';
+import { enrichToolInvocationWithSubagentMetadata, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, isTodoRelatedSqlQuery, processToolExecutionComplete, processToolExecutionStart, ToolCall, updateTodoListFromSqlItems, clearTodoList } from '../common/copilotCLITools';
import { getCopilotCLISessionDir } from './cliHelpers';
import type { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor';
import { ICopilotCLIImageSupport } from './copilotCLIImageSupport';
@@ -381,6 +381,9 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes
const editTracker = new ExternalEditTracker();
let sdkRequestId: string | undefined;
const toolIdEditMap = new Map>();
+ clearTodoList(this._toolsService, request.toolInvocationToken, token).catch(err => {
+ this.logService.error(err, '[CopilotCLISession] Failed to clear todo list at start of session');
+ });
/**
* The sequence of events from the SDK is as follows:
* tool.start -> About to run a terminal command
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts
index 0b9367e73d8..3d670a8797f 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts
@@ -69,6 +69,7 @@ export type ISessionOptions = {
debugTargetSessionIds?: readonly string[];
mcpServerMappings?: McpServerMappings;
additionalWorkspaces?: IWorkspaceInfo[];
+ sessionParentId?: string;
}
export type IGetSessionOptions = ISessionOptions & { sessionId: string };
export type ICreateSessionOptions = ISessionOptions & { sessionId?: string };
@@ -177,7 +178,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
this.monitorSessionFiles();
this._sessionManager = new Lazy>(async () => {
try {
- const { internal, createLocalFeatureFlagService } = await this.getSDKPackage();
+ const { internal, createLocalFeatureFlagService, AutoModeSessionManager } = await this.getSDKPackage();
// Always enable SDK OTel so the debug panel receives native spans via the bridge.
// When user OTel is disabled, we force file exporter to /dev/null so the SDK
// creates OtelSessionTracker (for debug panel) but doesn't export to any collector.
@@ -209,6 +210,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
return new internal.LocalSessionManager({
featureFlagService: createLocalFeatureFlagService(),
telemetryService: new internal.NoopTelemetryService(),
+ autoModeManager: new AutoModeSessionManager(),
}, { flushDebounceMs: undefined, settings: undefined, version: undefined });
}
catch (error) {
@@ -220,8 +222,8 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
}
private async getSDKPackage() {
- const { internal, LocalSession, createLocalFeatureFlagService } = await this.copilotCLISDK.getPackage();
- return { internal, LocalSession, createLocalFeatureFlagService };
+ const { internal, LocalSession, createLocalFeatureFlagService, AutoModeSessionManager } = await this.copilotCLISDK.getPackage();
+ return { internal, LocalSession, createLocalFeatureFlagService, AutoModeSessionManager };
}
getSessionWorkingDirectory(sessionId: string): Uri | undefined {
@@ -567,7 +569,15 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
const session = this.createCopilotSession(sdkSession, options.workspace, options.agent?.name, sessionManager);
session.object.add(mcpGateway);
+
+ // Set origin
void this._chatSessionMetadataStore.setSessionOrigin(session.object.sessionId);
+
+ // Set session parent id
+ if (options.sessionParentId) {
+ void this._chatSessionMetadataStore.setSessionParentId(session.object.sessionId, options.sessionParentId);
+ }
+
return session;
}
catch (error) {
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/nodePtyShim.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/nodePtyShim.ts
deleted file mode 100644
index c7db274412a..00000000000
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/nodePtyShim.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-/*---------------------------------------------------------------------------------------------
- * Copyright (c) Microsoft Corporation. All rights reserved.
- * Licensed under the MIT License. See License.txt in the project root for license information.
- *--------------------------------------------------------------------------------------------*/
-
-import { promises as fs } from 'fs';
-import * as path from 'path';
-import { ILogService } from '../../../../platform/log/common/logService';
-
-let shimCreated: Promise | undefined = undefined;
-
-const RETRIABLE_COPY_ERROR_CODES = new Set(['EPERM', 'EBUSY']);
-const MAX_COPY_ATTEMPTS = 6;
-const RETRY_DELAY_BASE_MS = 50;
-const RETRY_DELAY_CAP_MS = 500;
-const MATERIALIZATION_TIMEOUT_MS = 4000;
-const MATERIALIZATION_POLL_INTERVAL_MS = 100;
-
-/**
- * Copies the node-pty files from VS Code's installation into a @github/copilot location
- *
- * MUST be called before any `import('@github/copilot/sdk')` or `import('@github/copilot')`.
- *
- * @github/copilot bundles the node-pty code and its no longer possible to shim the package.
- *
- * @param extensionPath The extension's path (where to create the shim)
- * @param vscodeAppRoot VS Code's installation path (where node-pty is located)
- */
-export async function ensureNodePtyShim(extensionPath: string, vscodeAppRoot: string, logService: ILogService): Promise {
- if (shimCreated) {
- return shimCreated;
- }
-
- const creation = _ensureNodePtyShim(extensionPath, vscodeAppRoot, logService);
- shimCreated = creation.catch(error => {
- shimCreated = undefined;
- throw error;
- });
- return shimCreated;
-}
-
-async function _ensureNodePtyShim(extensionPath: string, vscodeAppRoot: string, logService: ILogService): Promise {
- const vscodeNodePtyPath = path.join(vscodeAppRoot, 'node_modules', 'node-pty', 'build', 'Release');
-
- await copyNodePtyFiles(extensionPath, vscodeNodePtyPath, logService);
-}
-
-export async function copyNodePtyFiles(extensionPath: string, sourceNodePtyPath: string, logService: ILogService): Promise {
- const nodePtyDir = path.join(extensionPath, 'node_modules', '@github', 'copilot', 'sdk', 'prebuilds', process.platform + '-' + process.arch);
- logService.info(`Creating node-pty shim: source=${sourceNodePtyPath}, dest=${nodePtyDir}`);
-
- try {
- await fs.mkdir(nodePtyDir, { recursive: true });
- const entries = await fs.readdir(sourceNodePtyPath);
- const uniqueEntries = [...new Set(entries)];
- logService.info(`Found ${uniqueEntries.length} entries to copy${uniqueEntries.length !== entries.length ? ` (${entries.length - uniqueEntries.length} duplicates ignored)` : ''}: ${uniqueEntries.join(', ')}`);
-
- await copyNodePtyWithRetries(sourceNodePtyPath, nodePtyDir, uniqueEntries, logService);
- } catch (error) {
- logService.error(`Failed to create node-pty shim (source dir: ${sourceNodePtyPath}, extension dir: ${nodePtyDir})`, error);
- throw error;
- }
-}
-
-async function copyNodePtyWithRetries(sourceDir: string, destDir: string, entries: string[], logService: ILogService): Promise {
- const primaryBinary = entries.find(entry => entry.endsWith('.node'));
- for (let attempt = 1; attempt <= MAX_COPY_ATTEMPTS; attempt++) {
- try {
- await fs.cp(sourceDir, destDir, {
- recursive: true,
- dereference: true,
- force: true,
- filter: async (srcPath) => shouldCopyEntry(srcPath, logService)
- });
- logService.trace(`Copied node-pty prebuilds to ${destDir} (attempt ${attempt})`);
- return;
- } catch (error) {
- if (await waitForMaterializedShim(destDir, primaryBinary, logService)) {
- logService.trace(`Detected node-pty shim materialized at ${destDir} by another extension host`);
- return;
- }
-
- if (!RETRIABLE_COPY_ERROR_CODES.has(error?.code) || attempt === MAX_COPY_ATTEMPTS) {
- throw error;
- }
-
- const delayMs = Math.min(RETRY_DELAY_BASE_MS * Math.pow(2, attempt - 1), RETRY_DELAY_CAP_MS);
- logService.warn(`Retryable error (${error.code}) copying node-pty shim. Retrying in ${delayMs}ms (attempt ${attempt + 1}/${MAX_COPY_ATTEMPTS})`);
- await new Promise(resolve => setTimeout(resolve, delayMs));
- }
- }
-}
-
-async function shouldCopyEntry(srcPath: string, logService: ILogService): Promise {
- try {
- const stat = await fs.stat(srcPath);
- if (stat.isDirectory()) {
- return true;
- }
-
- if (stat.size === 0) {
- logService.trace(`Skipping ${path.basename(srcPath)}: zero-byte file (likely symlink or special file)`);
- return false;
- }
-
- return true;
- } catch (error) {
- logService.warn(`Failed to stat ${srcPath}: ${error?.message ?? error}`);
- return false;
- }
-}
-
-async function waitForMaterializedShim(destDir: string, primaryBinary: string | undefined, logService: ILogService): Promise {
- const deadline = Date.now() + MATERIALIZATION_TIMEOUT_MS;
- while (Date.now() <= deadline) {
- if (await isShimMaterialized(destDir, primaryBinary)) {
- logService.trace(`Reusing node-pty shim that materialized at ${destDir}`);
- return true;
- }
-
- await new Promise(resolve => setTimeout(resolve, MATERIALIZATION_POLL_INTERVAL_MS));
- }
-
- return false;
-}
-
-async function isShimMaterialized(destDir: string, primaryBinary: string | undefined): Promise {
- if (primaryBinary) {
- const binaryStat = await fs.stat(path.join(destDir, primaryBinary)).catch(() => undefined);
- if (binaryStat && binaryStat.isFile() && binaryStat.size > 0) {
- return true;
- }
- }
-
- const entries = await fs.readdir(destDir).catch(() => []);
- for (const entry of entries) {
- const stat = await fs.stat(path.join(destDir, entry)).catch(() => undefined);
- if (stat && stat.isFile() && stat.size > 0) {
- return true;
- }
- }
-
- return false;
-}
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts
index db8bea59cc5..0d927355433 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/permissionHelpers.ts
@@ -28,7 +28,7 @@ type CoreTerminalConfirmationToolParams = {
command: string | undefined;
isBackground: boolean;
};
-}
+};
type CoreConfirmationToolParams = {
tool: ToolName.CoreConfirmationTool;
@@ -37,7 +37,7 @@ type CoreConfirmationToolParams = {
message: string;
confirmationType: 'basic';
};
-}
+};
/**
* The result of requesting permissions — the full union accepted by `Session.respondToPermission`.
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts
index d5018b35e17..f41992be205 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts
@@ -15,9 +15,11 @@ import { NullChatDebugFileLoggerService } from '../../../../../platform/chat/com
import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService';
import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService';
import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService';
+import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService';
import { ILogService } from '../../../../../platform/log/common/logService';
import { NullMcpService } from '../../../../../platform/mcp/common/mcpService';
import { NoopOTelService, resolveOTelConfig } from '../../../../../platform/otel/common/index';
+import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';
import { NullRequestLogger } from '../../../../../platform/requestLogger/node/nullRequestLogger';
import { NullWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
import { mock } from '../../../../../util/common/test/simpleMock';
@@ -41,8 +43,6 @@ import { CopilotCLISessionService, CopilotCLISessionWorkspaceTracker, ICopilotCL
import { CopilotCLIMCPHandler } from '../mcpHandler';
import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../userInputHelpers';
import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from './testHelpers';
-import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';
-import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService';
// Re-export for backward compatibility with other spec files
export { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from './testHelpers';
@@ -108,7 +108,7 @@ describe('CopilotCLISessionService', () => {
beforeEach(async () => {
vi.useRealTimers();
const sdk = {
- getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} })),
+ getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })),
getRequestId: vi.fn(() => undefined),
} as unknown as ICopilotCLISDK;
@@ -341,7 +341,7 @@ describe('CopilotCLISessionService', () => {
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
const fileSystem = new MockFileSystemService();
const sdk = {
- getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} }))
+ getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} }))
} as unknown as ICopilotCLISDK;
const services = createExtensionUnitTestingServices();
disposables.add(services);
@@ -380,7 +380,7 @@ describe('CopilotCLISessionService', () => {
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
const fileSystem = new MockFileSystemService();
const sdk = {
- getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} }))
+ getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} }))
} as unknown as ICopilotCLISDK;
const services = createExtensionUnitTestingServices();
disposables.add(services);
@@ -445,7 +445,7 @@ describe('CopilotCLISessionService', () => {
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
const fileSystem = new MockFileSystemService();
const sdk = {
- getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} }))
+ getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} }))
} as unknown as ICopilotCLISDK;
const services = createExtensionUnitTestingServices();
disposables.add(services);
@@ -491,7 +491,7 @@ describe('CopilotCLISessionService', () => {
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
const fileSystem = new MockFileSystemService();
const sdk = {
- getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} }))
+ getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} }))
} as unknown as ICopilotCLISDK;
const services = createExtensionUnitTestingServices();
disposables.add(services);
@@ -755,7 +755,7 @@ describe('CopilotCLISessionService', () => {
const storeMetadataSpy = vi.spyOn(metadataStore, 'storeForkedSessionMetadata');
const sdk = {
- getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} })),
+ getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })),
getRequestId: vi.fn(() => undefined),
} as unknown as ICopilotCLISDK;
const services = disposables.add(createExtensionUnitTestingServices());
@@ -796,7 +796,7 @@ describe('CopilotCLISessionService', () => {
manager.sessions.set(sourceId, sdkSession);
const sdk = {
- getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} })),
+ getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })),
getRequestId: vi.fn(() => ({ vscodeRequestId: 'vsc-req-1', copilotRequestId: 'sdk-event-1' })),
} as unknown as ICopilotCLISDK;
const services = disposables.add(createExtensionUnitTestingServices());
diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts
index 25d0dfdeeae..010be09de1a 100644
--- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts
@@ -397,7 +397,7 @@ describe('CopilotCLISession', () => {
const attachments = [{ type: 'file' as const, path: attachedFilePath, displayName: 'attached-file.ts' }];
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, attachments as any, undefined, authInfo, CancellationToken.None);
expect(result).toEqual({ kind: 'denied-interactively-by-user' });
- expect(toolsService.invokeToolCalls).toHaveLength(1);
+ expect(toolsService.invokeToolCalls).toHaveLength(2);
});
it('auto-approves read permission inside working directory without external handler', async () => {
@@ -487,8 +487,8 @@ describe('CopilotCLISession', () => {
// Path must be absolute within workspace, should auto-approve
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);
expect(result).toEqual({ kind: 'denied-interactively-by-user' });
- expect(toolsService.invokeToolCalls).toHaveLength(1);
- expect(toolsService.invokeToolCalls[0].input).toMatchObject({
+ expect(toolsService.invokeToolCalls).toHaveLength(2);
+ expect(toolsService.invokeToolCalls[1].input).toMatchObject({
title: 'Read file(s)',
message: 'Read file'
});
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts
index 6a28e040e6c..fc5790906f7 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts
@@ -10,30 +10,65 @@ import { createDirectoryIfNotExists, IFileSystemService } from '../../../platfor
import { ILogService } from '../../../platform/log/common/logService';
import { findLast } from '../../../util/vs/base/common/arraysFind';
import { SequencerByKey, ThrottledDelayer } from '../../../util/vs/base/common/async';
-import { Lazy } from '../../../util/vs/base/common/lazy';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { dirname, isEqual } from '../../../util/vs/base/common/resources';
-import { ChatSessionMetadataFile, IChatSessionMetadataStore, RepositoryProperties, RequestDetails, WorkspaceFolderEntry } from '../common/chatSessionMetadataStore';
-import { ChatSessionWorktreeData, ChatSessionWorktreeProperties } from '../common/chatSessionWorktreeService';
+import { ChatSessionMetadataFile, IChatSessionMetadataStore, RepositoryProperties, RequestDetails, WorkspaceFolderEntry, WorktreeSessionEntry } from '../common/chatSessionMetadataStore';
+import { ChatSessionWorktreeProperties } from '../common/chatSessionWorktreeService';
import { isUntitledSessionId } from '../common/utils';
import { IWorkspaceInfo } from '../common/workspaceInfo';
-import { getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers';
+import { getCopilotBulkMetadataFile, getCopilotCLISessionDir, getCopilotCLISessionStateDir, getCopilotWorktreeSessionsFile } from '../copilotcli/node/cliHelpers';
import { ICopilotCLIAgents } from '../copilotcli/node/copilotCli';
+import { WorktreeSessionIndex } from './worktreeSessionIndex';
-const WORKSPACE_FOLDER_MEMENTO_KEY = 'github.copilot.cli.sessionWorkspaceFolders';
-const WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees';
-const BULK_METADATA_FILENAME = 'copilotcli.session.metadata.json';
+// const WORKSPACE_FOLDER_MEMENTO_KEY = 'github.copilot.cli.sessionWorkspaceFolders';
+// const WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees';
+const LEGACY_BULK_METADATA_FILENAME = 'copilotcli.session.metadata.json';
+const LEGACY_BULK_MIGRATED_KEY = 'github.copilot.cli.legacyBulkMigrated';
+const JSONL_SCAN_DONE_KEY = 'github.copilot.cli.events.jsonl.scaned';
const REQUEST_MAPPING_FILENAME = 'vscode.requests.metadata.json';
+const SESSION_SCAN_BATCH_SIZE = 20;
+
+/**
+ * Maximum number of sessions kept in the shared bulk metadata cache file
+ * (`~/.copilot/vscode.session.metadata.cache.json`). Older entries (by `modified`)
+ * are evicted from the file but remain available via the per-session metadata files
+ * (`~/.copilot/session-state/{id}/vscode.metadata.json`) and the JSONL worktree index.
+ */
+const MAX_BULK_STORAGE_ENTRIES = 1000;
+
+/** Single-key sequencer key used to serialize bulk-file flush against {@link refresh}. */
+const BULK_SEQUENCER_KEY = 'bulk';
export class ChatSessionMetadataStore extends Disposable implements IChatSessionMetadataStore {
declare _serviceBrand: undefined;
+
+ /**
+ * In-memory mirror of the bulk metadata file plus on-demand entries hydrated by
+ * {@link getSessionMetadata}. Always retains everything it has seen; only the on-disk
+ * file is trimmed to {@link MAX_BULK_STORAGE_ENTRIES}.
+ */
private _cache: Record = {};
- private readonly _cacheDirectory: Uri;
- private readonly _cacheFile: Uri;
- private readonly _intialize: Lazy>;
+
+ /** Maps session id → JSONL entry and folder path → session id. Owns JSONL file persistence. */
+ private readonly _worktreeSessions: WorktreeSessionIndex;
+
+ /** Path of the shared bulk metadata cache file in `~/.copilot/`. */
+ private readonly _cacheFile = Uri.file(getCopilotBulkMetadataFile());
+
+ /**
+ * Single-promise gate. Initially set to `initializeStorage()`; {@link refresh} chains
+ * a {@link reloadBulkFromDisk} call onto it so concurrent refreshes collapse to at
+ * most one in-flight + one pending. Reads and writes both `await` this so they queue
+ * behind any in-flight refresh.
+ */
+ private _ready: Promise;
+
private readonly _updateStorageDebouncer = this._register(new ThrottledDelayer(1_000));
private readonly _requestMappingWriteSequencer = new SequencerByKey();
private readonly _metadataWriteSequencer = new SequencerByKey();
+ /** Serializes bulk-file flush against {@link reloadBulkFromDisk}. */
+ private readonly _bulkSequencer = new SequencerByKey();
+
constructor(
@IFileSystemService private readonly fileSystemService: IFileSystemService,
@ILogService private readonly logService: ILogService,
@@ -42,103 +77,52 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
) {
super();
- this._cacheDirectory = Uri.joinPath(this.extensionContext.globalStorageUri, 'copilotcli');
- this._cacheFile = Uri.joinPath(this._cacheDirectory, BULK_METADATA_FILENAME);
- this._intialize = new Lazy>(this.initializeStorage.bind(this));
- this._intialize.value.catch(error => {
+ this._worktreeSessions = new WorktreeSessionIndex(
+ this.fileSystemService,
+ this.logService,
+ getCopilotWorktreeSessionsFile(),
+ );
+
+ this._ready = this.initializeStorage();
+ this._ready.catch(error => {
this.logService.error('[ChatSessionMetadataStore] Initialization failed: ', error);
});
}
+ public refresh(): Promise {
+ // Chain onto the existing `_ready` — concurrent calls collapse to at most one
+ // in-flight + one pending. `.catch(() => undefined)` ensures a failed prior
+ // step does not poison subsequent reads/writes.
+ this._ready = this._ready.catch(() => undefined).then(() => this.reloadBulkFromDisk());
+ return this._ready;
+ }
+
private async initializeStorage(): Promise {
- try {
- this._cache = await this.getGlobalStorageData();
- // In case user closed vscode early or we couldn't save the session information for some reason.
- for (const [sessionId, metadata] of Object.entries(this._cache)) {
- if (sessionId.startsWith('untitled-')) {
- delete this._cache[sessionId];
- continue;
- }
- if (!metadata.writtenToDisc) {
- if ((metadata.workspaceFolder || metadata.worktreeProperties || metadata.additionalWorkspaces?.length)) {
- this.updateSessionMetadata(sessionId, metadata, false).catch(ex => {
- this.logService.error(ex, `[ChatSessionMetadataStore] Failed to write metadata for session ${sessionId} to session state: `);
- });
- } else {
- // invalid data, we don't need this in our cache.
- delete this._cache[sessionId];
- }
- }
- }
- // Dont' exit from here, keep reaading from global storage.
- // Possible we had a bug and we missed writing some metadata to disc.
- } catch {
- //
- }
-
- let cacheUpdated = false;
- // Collect workspace folder entries from global state
- const workspaceFolderData = this.extensionContext.globalState.get>>(WORKSPACE_FOLDER_MEMENTO_KEY, {});
- for (const [sessionId, entry] of Object.entries(workspaceFolderData)) {
- if (typeof entry === 'string' || !entry.folderPath || !entry.timestamp) {
- continue;
- }
- if (sessionId.startsWith('untitled-')) {
- continue;
- }
- if (sessionId in this._cache && this._cache[sessionId].workspaceFolder) {
- continue;
- }
- cacheUpdated = true;
- this._cache[sessionId] = { workspaceFolder: { folderPath: entry.folderPath, timestamp: entry.timestamp } };
- }
-
- // Collect worktree entries from global state
- const worktreeData = this.extensionContext.globalState.get>(WORKTREE_MEMENTO_KEY, {});
- for (const [sessionId, value] of Object.entries(worktreeData)) {
- if (typeof value === 'string') {
- continue;
- }
- if (sessionId.startsWith('untitled-')) {
- continue;
- }
- if (sessionId in this._cache && this._cache[sessionId].worktreeProperties) {
- const parsedData: ChatSessionWorktreeProperties = value.version === 1 ? { ...JSON.parse(value.data), version: 1 } : JSON.parse(value.data);
- const changesInFileStorage = this._cache[sessionId].worktreeProperties?.changes;
- const changesInGlobalState = parsedData.changes;
- // There was a bug that resulted in changes not being written to file storage, but they were written to global state.
- // In that case we want to keep the changes from global state, otherwise we might lose data.
- if ((changesInGlobalState || []).length === (changesInFileStorage || []).length) {
- continue;
- }
- }
- cacheUpdated = true;
- {
- const parsedData: ChatSessionWorktreeProperties = value.version === 1 ? { ...JSON.parse(value.data), version: 1 } : JSON.parse(value.data);
- this._cache[sessionId] = { ...this._cache[sessionId], workspaceFolder: undefined, worktreeProperties: parsedData, writtenToDisc: false };
- }
- }
+ // One-time migration from the legacy per-install bulk file in
+ // globalStorageUri to the shared `~/.copilot/` location.
+ await this.migrateLegacyBulkFile();
+ this._cache = await this.getGlobalStorageData().catch(() => ({} as Record));
+ // In case user closed vscode early or we couldn't save the session information for some reason.
for (const [sessionId, metadata] of Object.entries(this._cache)) {
- // These promises can run in background and no need to wait for them.
- // Even if user exits early we have all the data in the global storage and we'll restore from that next time.
- if (!metadata.writtenToDisc) {
- if ((metadata.workspaceFolder || metadata.worktreeProperties || metadata.additionalWorkspaces?.length)) {
- this.updateSessionMetadata(sessionId, metadata, false).catch(ex => {
- this.logService.error(ex, `[ChatSessionMetadataStore] Failed to write metadata for session ${sessionId} to session state: `);
- });
- }
+ if (sessionId.startsWith('untitled-')) {
+ delete this._cache[sessionId];
+ continue;
+ }
+ if (!(metadata.workspaceFolder || metadata.worktreeProperties || metadata.additionalWorkspaces?.length)) {
+ // invalid data, we don't need this in our cache.
+ delete this._cache[sessionId];
}
}
- if (cacheUpdated) {
- // Writing to file is most important.
- await this.writeToGlobalStorage(this._cache);
- }
-
- // To be enabled after testing. So we dont' blow away the data.
- // this.extensionContext.globalState.update(WORKSPACE_FOLDER_MEMENTO_KEY, undefined);
// this.extensionContext.globalState.update(WORKTREE_MEMENTO_KEY, undefined);
+ // this.extensionContext.globalState.update(WORKSPACE_FOLDER_MEMENTO_KEY, undefined);
+
+
+ // Ensure every cached session with a worktreePath has a JSONL
+ // entry. Only appends entries that are missing; falls back to a full rewrite when
+ // the load detected duplicates or malformed lines.
+ await this.topUpJsonlIndexFromCache();
}
public getMetadataFileUri(sessionId: string): vscode.Uri {
@@ -150,16 +134,19 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
}
async deleteSessionMetadata(sessionId: string): Promise {
- await this._intialize.value;
+ await this._ready;
if (sessionId in this._cache) {
delete this._cache[sessionId];
- const data = await this.getGlobalStorageData();
+ const data = await this.getGlobalStorageData().catch(() => ({} as Record));
delete data[sessionId];
await this.writeToGlobalStorage(data);
}
try {
- await this.fileSystemService.delete(this.getMetadataFileUri(sessionId));
- await this.fileSystemService.delete(this.getRequestMappingFileUri(sessionId));
+ await Promise.allSettled([
+ this._worktreeSessions.removeAndWriteToDisk(sessionId),
+ this.fileSystemService.delete(this.getMetadataFileUri(sessionId)),
+ this.fileSystemService.delete(this.getRequestMappingFileUri(sessionId))
+ ]);
} catch {
// File may not exist, ignore.
}
@@ -169,11 +156,14 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
if (isUntitledSessionId(sessionId)) {
return;
}
- await this._intialize.value;
+ await this._ready;
+ // Optimistically update in-memory cache so callers in the same process observe
+ // the change immediately. We pass only the partial `fields` to
+ // `updateSessionMetadata` — that method reads fresh from disk and merges, so it
+ // cannot stomp fields written by other processes (Step 3b: stale-cache fix).
const existing = this._cache[sessionId] ?? {};
- const metadata: ChatSessionMetadataFile = { ...existing, ...fields };
- this._cache[sessionId] = metadata;
- await this.updateSessionMetadata(sessionId, metadata);
+ this._cache[sessionId] = { ...existing, ...fields };
+ await this.updateSessionMetadata(sessionId, fields);
this.updateGlobalStorage();
}
@@ -197,30 +187,44 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
getWorktreeProperties(sessionId: string): Promise;
getWorktreeProperties(folder: Uri): Promise;
async getWorktreeProperties(sessionId: string | Uri): Promise {
- await this._intialize.value;
+ await this._ready;
if (typeof sessionId === 'string') {
const metadata = await this.getSessionMetadata(sessionId);
return metadata?.worktreeProperties;
- } else {
- const folder = sessionId;
- for (const metadata of Object.values(this._cache)) {
- if (!metadata.worktreeProperties?.worktreePath) {
- continue;
- }
- if (isEqual(Uri.file(metadata.worktreeProperties.worktreePath), folder)) {
- return metadata.worktreeProperties;
- }
+ }
+ const folder = sessionId;
+ // First check the in-memory cache.
+ for (const metadata of Object.values(this._cache)) {
+ if (metadata.worktreeProperties?.worktreePath && isEqual(Uri.file(metadata.worktreeProperties.worktreePath), folder)) {
+ return metadata.worktreeProperties;
}
}
+ // Fallback to the JSONL worktree index → hydrate from the per-session file.
+ const id = await this.findSessionIdForWorktree(folder);
+ if (id) {
+ const metadata = await this.getSessionMetadata(id);
+ return metadata?.worktreeProperties;
+ }
+ return undefined;
}
async getSessionIdForWorktree(folder: vscode.Uri): Promise {
- await this._intialize.value;
+ await this._ready;
for (const [sessionId, value] of Object.entries(this._cache)) {
if (value.worktreeProperties?.worktreePath && isEqual(vscode.Uri.file(value.worktreeProperties.worktreePath), folder)) {
return sessionId;
}
}
- return undefined;
+ return this.findSessionIdForWorktree(folder);
+ }
+
+ /** Looks up a session id for a worktree folder via the JSONL index, with a throttled disk reload. */
+ private async findSessionIdForWorktree(folder: vscode.Uri): Promise {
+ const cached = this._worktreeSessions.getSessionIdForFolder(folder);
+ if (cached) {
+ return cached;
+ }
+ await this._worktreeSessions.reloadIfStale();
+ return this._worktreeSessions.getSessionIdForFolder(folder);
}
async getSessionWorkspaceFolder(sessionId: string): Promise {
@@ -284,7 +288,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
}
async getRequestDetails(sessionId: string): Promise {
- await this._intialize.value;
+ await this._ready;
const fileUri = this.getRequestMappingFileUri(sessionId);
try {
const content = await this.fileSystemService.readFile(fileUri);
@@ -295,7 +299,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
}
async updateRequestDetails(sessionId: string, details: (Partial & { vscodeRequestId: string })[]): Promise {
- await this._intialize.value;
+ await this._ready;
if (isUntitledSessionId(sessionId)) {
return;
}
@@ -324,7 +328,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
}
private async writeRequestDetails(sessionId: string, details: RequestDetails[]): Promise {
- await this._intialize.value;
+ await this._ready;
if (isUntitledSessionId(sessionId)) {
return;
}
@@ -337,7 +341,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
}
async storeForkedSessionMetadata(sourceSessionId: string, targetSessionId: string, customTitle: string): Promise {
- await this._intialize.value;
+ await this._ready;
const sourceMetadata = await this.getSessionMetadata(sourceSessionId);
const forkedMetadata: ChatSessionMetadataFile = {
...sourceMetadata,
@@ -351,7 +355,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
}
public async setSessionOrigin(sessionId: string): Promise {
- await this._intialize.value;
+ await this._ready;
await this.updateMetadataFields(sessionId, { origin: 'vscode' });
}
@@ -371,33 +375,52 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
return 'other';
}
+ public async setSessionParentId(sessionId: string, parentSessionId: string): Promise {
+ await this._ready;
+ await this.updateMetadataFields(sessionId, { parentSessionId, kind: 'sub-session' });
+ }
+
+ public async getSessionParentId(sessionId: string): Promise {
+ const metadata = await this.getSessionMetadata(sessionId, false);
+ return metadata?.parentSessionId;
+ }
+
private async getSessionMetadata(sessionId: string, createMetadataFileIfNotFound = true): Promise {
if (isUntitledSessionId(sessionId)) {
return undefined;
}
- await this._intialize.value;
+ await this._ready;
if (sessionId in this._cache) {
return this._cache[sessionId];
}
- const fileUri = this.getMetadataFileUri(sessionId);
- try {
- const content = await this.fileSystemService.readFile(fileUri);
- const metadata: ChatSessionMetadataFile = JSON.parse(new TextDecoder().decode(content));
+ const metadata = await this.readSessionMetadataFile(sessionId);
+ if (metadata) {
this._cache[sessionId] = metadata;
return metadata;
+ }
+
+ // So we don't try again.
+ this._cache[sessionId] = {};
+ if (createMetadataFileIfNotFound) {
+ await this.updateSessionMetadata(sessionId, { origin: 'other' });
+ this.updateGlobalStorage();
+ }
+ return undefined;
+ }
+
+ /** Reads a per-session metadata file directly. Returns `undefined` if it doesn't exist or is invalid. */
+ private async readSessionMetadataFile(sessionId: string): Promise {
+ try {
+ const fileUri = this.getMetadataFileUri(sessionId);
+ const content = await this.fileSystemService.readFile(fileUri);
+ return JSON.parse(new TextDecoder().decode(content)) as ChatSessionMetadataFile;
} catch {
- // So we don't try again.
- this._cache[sessionId] = {};
- if (createMetadataFileIfNotFound) {
- await this.updateSessionMetadata(sessionId, { origin: 'other' });
- this.updateGlobalStorage();
- }
return undefined;
}
}
- private async updateSessionMetadata(sessionId: string, metadata: ChatSessionMetadataFile, createDirectoryIfNotFound = true): Promise {
+ private async updateSessionMetadata(sessionId: string, updates: Partial, createDirectoryIfNotFound = true): Promise {
if (isUntitledSessionId(sessionId)) {
// Don't write metadata for untitled sessions, as they are temporary and can be created in large numbers.
return;
@@ -410,17 +433,19 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
// Try to read existing file first (will succeed 99% of the time).
// This preserves data written by other processes when merging.
let existing: ChatSessionMetadataFile = {};
+ let diskFileExisted = true;
try {
const rawContent = await this.fileSystemService.readFile(fileUri);
existing = JSON.parse(new TextDecoder().decode(rawContent));
} catch {
+ diskFileExisted = false;
// File doesn't exist yet — check if the directory exists.
try {
await this.fileSystemService.stat(dirUri);
} catch {
if (!createDirectoryIfNotFound) {
// Lets not delete the session from our storage, but mark it as written to session state so that we won't try to write to session state again and again.
- this._cache[sessionId] = { ...metadata, writtenToDisc: true };
+ this._cache[sessionId] = { ...updates, writtenToDisc: true };
this.updateGlobalStorage();
return;
}
@@ -428,10 +453,13 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
}
}
- // Merge: overwrite fields that are explicitly provided, delete fields set to undefined.
- // This preserves data written by other processes.
- const merged: ChatSessionMetadataFile = { ...existing };
- for (const [key, value] of Object.entries(metadata)) {
+ // Merge order: cache (locally-known fields not yet flushed to disk)
+ // → disk existing (cross-process writes win over stale cache, Step 3b)
+ // → explicit `metadata` fields from this call (caller wins).
+ // `undefined` values in `metadata` delete the corresponding key.
+ const cacheExisting = diskFileExisted ? {} : (this._cache[sessionId] ?? {});
+ const merged: ChatSessionMetadataFile = { ...cacheExisting, ...existing };
+ for (const [key, value] of Object.entries(updates)) {
if (value === undefined) {
delete (merged as Record)[key];
} else {
@@ -439,8 +467,38 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
}
}
+ // Stamp timestamps. `created` is set only on first write; `modified` is
+ // bumped on every write.
+ const now = Date.now();
+ merged.modified = now;
+ if (merged.created === undefined) {
+ merged.created = now;
+ }
+
+ const promises: Promise[] = [];
+
+ // Maintain the JSONL worktree index based on the post-merge worktreePath:
+ // - new entry → append a line and remember it
+ // - changed path → rewrite the file (rare)
+ // - cleared path → remove via rewrite
+ const worktreePath = merged.worktreeProperties?.worktreePath;
+ const indexed = this._worktreeSessions.getSessionEntry(sessionId);
+ if (worktreePath) {
+ if (!indexed) {
+ promises.push(this._worktreeSessions.appendBatchToDisk([{ id: sessionId, path: worktreePath, created: merged.created }]));
+ } else if (indexed.path !== worktreePath && !merged.kind) {
+ this._worktreeSessions.addEntry({ id: sessionId, path: worktreePath, created: indexed.created });
+ promises.push(this._worktreeSessions.writeToDisk());
+ }
+ } else if (indexed) {
+ promises.push(this._worktreeSessions.removeAndWriteToDisk(sessionId));
+ }
+
const content = new TextEncoder().encode(JSON.stringify(merged, null, 2));
- await this.fileSystemService.writeFile(fileUri, content);
+ promises.push(this.fileSystemService.writeFile(fileUri, content));
+
+ await Promise.all(promises);
+
this._cache[sessionId] = { ...merged, writtenToDisc: true };
this.updateGlobalStorage();
this.logService.trace(`[ChatSessionMetadataStore] Wrote metadata for session ${sessionId}`);
@@ -458,34 +516,258 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
private async updateGlobalStorageImpl() {
try {
- const data = this._cache;
- try {
- const storageData = await this.getGlobalStorageData();
- for (const [sessionId, metadata] of Object.entries(storageData)) {
- if (sessionId in data) {
- // Ignore this.
- } else {
- data[sessionId] = metadata;
+ // Serialize against `refresh()` and other bulk-file flushes via the shared
+ // single-key sequencer. Inside the queue we re-read the on-disk file and
+ // merge it with the in-memory cache using last-modified-wins semantics so
+ // concurrent writers in another process do not lose data.
+ await this._bulkSequencer.queue(BULK_SEQUENCER_KEY, async () => {
+ const data: Record = { ...this._cache };
+ try {
+ const storageData = await this.getGlobalStorageData();
+ for (const [sessionId, diskEntry] of Object.entries(storageData)) {
+ const local = data[sessionId];
+ if (!local) {
+ data[sessionId] = diskEntry;
+ this._cache[sessionId] = diskEntry;
+ continue;
+ }
+ const localModified = local.modified ?? 0;
+ const diskModified = diskEntry.modified ?? 0;
+ if (diskModified > localModified) {
+ data[sessionId] = diskEntry;
+ this._cache[sessionId] = diskEntry;
+ }
}
+ } catch {
+ //
}
- } catch {
- //
- }
- await this.writeToGlobalStorage(data);
+ await this.writeToGlobalStorage(data);
+ });
} catch (error) {
this.logService.error('[ChatSessionMetadataStore] Failed to update global storage: ', error);
}
}
private async writeToGlobalStorage(allMetadata: Record): Promise {
- try {
- await this.fileSystemService.stat(this._cacheDirectory);
- } catch {
- await this.fileSystemService.createDirectory(this._cacheDirectory);
+ // Make a shallow copy and trim to the top MAX_BULK_STORAGE_ENTRIES by `modified` desc.
+ // The in-memory `_cache` is unaffected — only the on-disk file is bounded.
+ // Per-session files in `~/.copilot/session-state/{id}/vscode.metadata.json` remain
+ // the source of truth for evicted entries.
+ const entries = Object.entries(allMetadata);
+ let toWrite: Record;
+ if (entries.length <= MAX_BULK_STORAGE_ENTRIES) {
+ toWrite = { ...allMetadata };
+ } else {
+ entries.sort(([, a], [, b]) => (b.modified ?? 0) - (a.modified ?? 0));
+ toWrite = Object.fromEntries(entries.slice(0, MAX_BULK_STORAGE_ENTRIES));
}
- const content = new TextEncoder().encode(JSON.stringify(allMetadata, null, 2));
+ const dirUri = dirname(this._cacheFile);
+ try {
+ await this.fileSystemService.stat(dirUri);
+ } catch {
+ await this.fileSystemService.createDirectory(dirUri);
+ }
+
+ const content = new TextEncoder().encode(JSON.stringify(toWrite, null, 2));
await this.fileSystemService.writeFile(this._cacheFile, content);
- this.logService.trace(`[ChatSessionMetadataStore] Wrote bulk metadata file with ${Object.keys(allMetadata).length} session(s)`);
+ this.logService.trace(`[ChatSessionMetadataStore] Wrote bulk metadata file with ${Object.keys(toWrite).length} session(s)`);
+ }
+
+ /**
+ * Re-reads the shared bulk file from disk and merges into `_cache` using
+ * last-modified-wins. Runs inside the bulk sequencer so it is serialized
+ * against {@link updateGlobalStorageImpl}. Never drops in-memory entries.
+ */
+ private async reloadBulkFromDisk(): Promise {
+ return this._bulkSequencer.queue(BULK_SEQUENCER_KEY, async () => {
+ let onDisk: Record;
+ try {
+ onDisk = await this.getGlobalStorageData();
+ } catch {
+ return;
+ }
+ for (const [id, diskEntry] of Object.entries(onDisk)) {
+ const local = this._cache[id];
+ if (!local) {
+ this._cache[id] = diskEntry;
+ continue;
+ }
+ const localModified = local.modified ?? 0;
+ const diskModified = diskEntry.modified ?? 0;
+ if (diskModified > localModified) {
+ this._cache[id] = diskEntry;
+ }
+ }
+ });
+ }
+
+ /**
+ * Merges the per-install legacy bulk file (`globalStorageUri/copilotcli/…`) into
+ * the shared `~/.copilot/` bulk file using last-modified-wins. This handles:
+ * - First-run: shared file missing → copy legacy content into the shared file.
+ * - Late-joiner: Process A already created the shared file → merge so entries
+ * unique to this install are not lost.
+ * - No legacy file: nothing to do.
+ */
+ private async migrateLegacyBulkFile(): Promise {
+ // Skip if this install already migrated.
+ if (this.extensionContext.globalState.get(LEGACY_BULK_MIGRATED_KEY)) {
+ return;
+ }
+
+ const legacyCacheFile = Uri.joinPath(this.extensionContext.globalStorageUri, 'copilotcli', LEGACY_BULK_METADATA_FILENAME);
+ let legacyData: Record;
+ try {
+ const raw = await this.fileSystemService.readFile(legacyCacheFile);
+ legacyData = JSON.parse(new TextDecoder().decode(raw));
+ } catch {
+ // No legacy file — mark as migrated so we don't retry.
+ await this.extensionContext.globalState.update(LEGACY_BULK_MIGRATED_KEY, true);
+ return;
+ }
+
+ try {
+ await createDirectoryIfNotExists(this.fileSystemService, dirname(this._cacheFile));
+
+ // Try to read the shared file (may or may not exist yet).
+ let sharedData: Record = {};
+ try {
+ const raw = await this.fileSystemService.readFile(this._cacheFile);
+ sharedData = JSON.parse(new TextDecoder().decode(raw));
+ } catch {
+ // Shared file doesn't exist yet — start empty.
+ }
+
+ // Merge legacy into shared using last-modified-wins.
+ let merged = false;
+ for (const [id, legacyEntry] of Object.entries(legacyData)) {
+ const sharedEntry = sharedData[id];
+ if (!sharedEntry) {
+ sharedData[id] = legacyEntry;
+ merged = true;
+ } else {
+ const sharedModified = sharedEntry.modified ?? 0;
+ const legacyModified = legacyEntry.modified ?? 0;
+ if (legacyModified > sharedModified) {
+ sharedData[id] = legacyEntry;
+ merged = true;
+ }
+ }
+ }
+
+ if (merged) {
+ const content = new TextEncoder().encode(JSON.stringify(sharedData, null, 2));
+ await this.fileSystemService.writeFile(this._cacheFile, content);
+ }
+
+ // Mark as migrated so subsequent startups skip this path.
+ await this.extensionContext.globalState.update(LEGACY_BULK_MIGRATED_KEY, true);
+ this.logService.info('[ChatSessionMetadataStore] Migrated legacy bulk metadata file to ~/.copilot/');
+ } catch (err) {
+ this.logService.error('[ChatSessionMetadataStore] Failed to migrate legacy bulk file: ', err);
+ }
+ }
+
+ /**
+ * For every cached session with a `worktreePath`, ensure a JSONL entry exists.
+ */
+ private async topUpJsonlIndexFromCache(): Promise {
+ // Load the JSONL worktree index from disk first so the scan below can
+ // tell which entries already exist and avoid re-appending duplicates.
+ let { rewriteNeeded } = await this._worktreeSessions.loadFromDisk();
+
+ const toAppend: WorktreeSessionEntry[] = [];
+ for (const [id, metadata] of Object.entries(this._cache)) {
+ const path = metadata.worktreeProperties?.worktreePath;
+ if (!path || metadata.kind) {
+ continue;
+ }
+ const existing = this._worktreeSessions.getSessionEntry(id);
+ if (existing && existing.path === path) {
+ continue;
+ }
+ const entry: WorktreeSessionEntry = { id, path, created: existing?.created ?? metadata.created ?? Date.now() };
+ this._worktreeSessions.addEntry(entry);
+ if (existing) {
+ // Path changed — a full rewrite is needed.
+ rewriteNeeded = true;
+ } else {
+ toAppend.push(entry);
+ }
+ }
+
+ if (rewriteNeeded) {
+ await this._worktreeSessions.writeToDisk();
+ } else if (toAppend.length > 0) {
+ await this._worktreeSessions.appendBatchToDisk(toAppend);
+ }
+
+ // One-time full scan of ~/.copilot/session-state/ to discover worktree
+ // sessions that were never recorded in the JSONL (e.g. sessions created
+ // before the JSONL index existed, or evicted from the bulk cache).
+ await this.scanSessionStateDirForWorktrees();
+ }
+
+ /**
+ * One-time scan of `~/.copilot/session-state/` to discover worktree sessions
+ * not yet in the JSONL index. Reads per-session metadata files in batches of
+ * {@link SESSION_SCAN_BATCH_SIZE} to avoid saturating I/O. Gated by a memento
+ * so it only runs once per install.
+ */
+ private async scanSessionStateDirForWorktrees(): Promise {
+ if (this.extensionContext.globalState.get(JSONL_SCAN_DONE_KEY)) {
+ return;
+ }
+
+ const sessionStateDir = Uri.file(getCopilotCLISessionStateDir());
+ let entries: [string, number][];
+ try {
+ entries = await this.fileSystemService.readDirectory(sessionStateDir);
+ } catch {
+ // Directory doesn't exist — nothing to scan.
+ await this.extensionContext.globalState.update(JSONL_SCAN_DONE_KEY, true);
+ return;
+ }
+
+ // Collect session IDs we don't already know about.
+ const unknownIds: string[] = [];
+ for (const [name] of entries) {
+ if (name in this._cache || this._worktreeSessions.has(name)) {
+ continue;
+ }
+ unknownIds.push(name);
+ }
+
+ if (unknownIds.length === 0) {
+ await this.extensionContext.globalState.update(JSONL_SCAN_DONE_KEY, true);
+ return;
+ }
+
+ // Read metadata files in batches.
+ let discovered = false;
+ for (let i = 0; i < unknownIds.length; i += SESSION_SCAN_BATCH_SIZE) {
+ const batch = unknownIds.slice(i, i + SESSION_SCAN_BATCH_SIZE);
+ const results = await Promise.all(batch.map(async id => {
+ const metadata = await this.readSessionMetadataFile(id);
+ return { id, metadata };
+ }));
+ for (const { id, metadata } of results) {
+ if (!metadata?.worktreeProperties?.worktreePath || metadata.kind) {
+ continue;
+ }
+ const path = metadata.worktreeProperties.worktreePath;
+ if (!this._worktreeSessions.has(id)) {
+ this._worktreeSessions.addEntry({ id, path, created: metadata.created ?? Date.now() });
+ discovered = true;
+ }
+ }
+ }
+
+ if (discovered) {
+ await this._worktreeSessions.writeToDisk();
+ }
+ await this.extensionContext.globalState.update(JSONL_SCAN_DONE_KEY, true);
+ this.logService.info(`[ChatSessionMetadataStore] Session-state scan complete: checked ${unknownIds.length} unknown session(s)`);
}
}
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts
index 0a23e98bea8..09a2aee2f37 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts
@@ -22,9 +22,9 @@ import { isEqual } from '../../../util/vs/base/common/resources';
import { generateUuid } from '../../../util/vs/base/common/uuid';
import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace';
import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore';
-import { ChatSessionWorktreeData, ChatSessionWorktreeFile, ChatSessionWorktreeProperties, ChatSessionWorktreePropertiesV2, IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
+import { ChatSessionWorktreeFile, ChatSessionWorktreeProperties, ChatSessionWorktreePropertiesV2, IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
-const CHAT_SESSION_WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees';
+// const CHAT_SESSION_WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees';
export class ChatSessionWorktreeService extends Disposable implements IChatSessionWorktreeService {
declare _serviceBrand: undefined;
@@ -42,6 +42,8 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi
@IChatSessionMetadataStore private readonly metadataStore: IChatSessionMetadataStore,
) {
super();
+ // This is not used.
+ // void this.extensionContext.globalState.update(CHAT_SESSION_WORKTREE_MEMENTO_KEY, undefined);
}
async createWorktree(repositoryPath: vscode.Uri, stream?: vscode.ChatResponseStream, baseBranch?: string, branchName?: string): Promise {
@@ -202,11 +204,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi
async setWorktreeProperties(sessionId: string, properties: ChatSessionWorktreeProperties): Promise {
this._sessionWorktrees.set(sessionId, properties);
-
- const sessionWorktreesProperties = this.extensionContext.globalState.get>(CHAT_SESSION_WORKTREE_MEMENTO_KEY, {});
- sessionWorktreesProperties[sessionId] = { data: JSON.stringify(properties), version: properties.version };
await this.metadataStore.storeWorktreeInfo(sessionId, properties);
- await this.extensionContext.globalState.update(CHAT_SESSION_WORKTREE_MEMENTO_KEY, sessionWorktreesProperties);
}
async getWorktreeRepository(sessionId: string): Promise {
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts
index 5293b7a177f..233ae18e424 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts
@@ -5,13 +5,17 @@
import * as vscode from 'vscode';
import { ChatExtendedRequestHandler } from 'vscode';
+import { PermissionMode } from '@anthropic-ai/claude-agent-sdk';
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
import { INativeEnvService } from '../../../platform/env/common/envService';
import { IGitService } from '../../../platform/git/common/gitService';
import { ILogService } from '../../../platform/log/common/logService';
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
-import { Disposable } from '../../../util/vs/base/common/lifecycle';
+import { Emitter, Event } from '../../../util/vs/base/common/event';
+import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecycle';
+import { autorun, derived, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../util/vs/base/common/observable';
+import { basename } from '../../../util/vs/base/common/resources';
import { URI } from '../../../util/vs/base/common/uri';
import { generateUuid } from '../../../util/vs/base/common/uuid';
import { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo';
@@ -25,7 +29,8 @@ import { IClaudeCodeSessionInfo } from '../claude/node/sessionParser/claudeSessi
import { IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService';
import { IChatFolderMruService } from '../common/folderRepositoryManager';
import { buildChatHistory } from './chatHistoryBuilder';
-import { ClaudeSessionOptionBuilder, FOLDER_OPTION_ID, isPermissionMode, PERMISSION_MODE_OPTION_ID } from './claudeSessionOptionBuilder';
+import { ClaudeSessionOptionBuilder, buildPermissionModeItems, FOLDER_OPTION_ID, isPermissionMode, PERMISSION_MODE_OPTION_ID } from './claudeSessionOptionBuilder';
+import { toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder';
// Import the tool permission handlers
import '../claude/vscode-node/toolPermissionHandlers/index';
@@ -33,6 +38,14 @@ import '../claude/vscode-node/toolPermissionHandlers/index';
// Import the MCP server contributors to trigger self-registration
import '../claude/vscode-node/mcpServers/index';
+interface InputStateReactivePipeline {
+ readonly permissionMode: ISettableObservable;
+ readonly folderUri: ISettableObservable;
+ readonly folderItems: ISettableObservable;
+ readonly isSessionStarted: ISettableObservable;
+ readonly store: DisposableStore;
+}
+
export class ClaudeChatSessionContentProvider extends Disposable implements vscode.ChatSessionContentProvider {
private readonly _controller: ClaudeChatSessionItemController;
@@ -87,17 +100,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
// Lock the folder group when starting a new session (permission mode stays editable)
if (isNewSession) {
- const state = chatSessionContext.inputState;
- state.groups = state.groups.map(group => {
- if (group.id !== FOLDER_OPTION_ID) {
- return group;
- }
- return {
- ...group,
- items: group.items.map(item => ({ ...item, locked: true })),
- selected: group.selected ? { ...group.selected, locked: true } : undefined,
- };
- });
+ this._controller.markSessionStarted(chatSessionContext.inputState);
}
const modelId = parseClaudeModelId(request.model.id);
@@ -176,10 +179,28 @@ export class ClaudeChatSessionItemController extends Disposable {
private readonly _inProgressItems = new Map();
private _showBadge: boolean;
+ // #region Shared Observable State
+
+ /** Whether the "bypass permissions" config is enabled — controls permission mode items. */
+ private readonly _bypassPermissionsEnabled: IObservable;
+
+ /** Current workspace folders — controls folder group items and visibility. */
+ private readonly _workspaceFolders: IObservable;
+
+ /** Disposes per-state autoruns when the state object is garbage collected. */
+ private readonly _stateAutorunRegistry = new FinalizationRegistry(
+ store => store.dispose()
+ );
+
+ /** Maps input state objects to their reactive pipelines for external updates. */
+ private readonly _statePipelines = new WeakMap();
+
+ // #endregion
+
constructor(
@IClaudeCodeSessionService private readonly _claudeCodeSessionService: IClaudeCodeSessionService,
@IClaudeSessionStateService private readonly _sessionStateService: IClaudeSessionStateService,
- @IConfigurationService private readonly _configurationService: IConfigurationService,
+ @IConfigurationService _configurationService: IConfigurationService,
@IChatFolderMruService folderMruService: IChatFolderMruService,
@IWorkspaceService private readonly _workspaceService: IWorkspaceService,
@INativeEnvService private readonly _envService: INativeEnvService,
@@ -189,6 +210,24 @@ export class ClaudeChatSessionItemController extends Disposable {
) {
super();
this._optionBuilder = new ClaudeSessionOptionBuilder(_configurationService, folderMruService, _workspaceService);
+
+ this._bypassPermissionsEnabled = observableFromEvent(
+ this,
+ Event.filter(_configurationService.onDidChangeConfiguration,
+ e => e.affectsConfiguration(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions.fullyQualifiedId)),
+ () => _configurationService.getConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions) as boolean,
+ );
+
+ // Bridge vscode.Event → internal Event for workspace folder changes
+ const workspaceFoldersEmitter = this._register(new Emitter());
+ const workspaceFoldersSubscription = _workspaceService.onDidChangeWorkspaceFolders(() => workspaceFoldersEmitter.fire());
+ this._register({ dispose: () => workspaceFoldersSubscription.dispose() });
+ this._workspaceFolders = observableFromEvent(
+ this,
+ workspaceFoldersEmitter.event,
+ () => _workspaceService.getWorkspaceFolders(),
+ );
+
this._registerCommands();
this._controller = this._register(vscode.chat.createChatSessionItemController(
ClaudeSessionUri.scheme,
@@ -277,79 +316,206 @@ export class ClaudeChatSessionItemController extends Disposable {
// #region Input State
- private _setupInputState(): void {
- const trackedStates: { ref: WeakRef }[] = [];
+ /**
+ * Creates a reactive pipeline for a single input state.
+ *
+ * Per-state observables (`permissionMode`, `folderUri`, `isSessionStarted`) are
+ * combined with shared observables (`_bypassPermissionsEnabled`, `_workspaceFolders`)
+ * into derived group computations. An autorun reads the derived groups and pushes
+ * the result to `state.groups`, which is the "UI".
+ *
+ * The `state` is only held weakly by the autoruns so it can be garbage-collected
+ * while the shared observables still reference the pipeline's observers. When the
+ * state is collected, the finalization registry disposes the store and unsubscribes.
+ *
+ * Returns the per-state observables so callers can drive external updates, plus a
+ * `DisposableStore` that owns the autorun lifecycle.
+ */
+ private _createInputStateReactivePipeline(
+ state: vscode.ChatSessionInputState,
+ ): InputStateReactivePipeline {
+ const store = new DisposableStore();
- const sweepStaleEntries = () => {
- for (let i = trackedStates.length - 1; i >= 0; i--) {
- if (!trackedStates[i].ref.deref()) {
- trackedStates.splice(i, 1);
- }
+ // Seed values are computed up front so that the first autorun pass
+ // observes fully-seeded observables and does not clobber `initialGroups`.
+ const seed = this._computeSeedValues(state.groups);
+
+ const permissionMode = observableValue(this, seed.permissionMode);
+ const folderUri = observableValue(this, seed.folderUri);
+ const folderItems = observableValue(this, seed.folderItems);
+ const isSessionStarted = observableValue(this, seed.isSessionStarted);
+
+ // When workspace folders change, update folder items reactively.
+ // Falls back to the async MRU list when the workspace becomes empty,
+ // matching the old imperative `buildNewFolderGroup` behavior.
+ store.add(autorun(reader => {
+ /** @description syncWorkspaceFolderItems */
+ const folders = this._workspaceFolders.read(reader);
+ if (folders.length !== 0) {
+ folderItems.set(
+ folders.map(f => toWorkspaceFolderOptionItem(f, this._workspaceService.getWorkspaceFolderName(f) || basename(f))),
+ undefined,
+ );
+ } else {
+ this._optionBuilder.getFolderOptionItems()
+ .then(items => folderItems.set(items, undefined))
+ .catch(e => this._logService.error(e));
}
- };
+ }));
+ const permissionModeGroup = derived(reader => {
+ /** @description permissionModeGroup */
+ const bypassEnabled = this._bypassPermissionsEnabled.read(reader);
+ const selectedMode = permissionMode.read(reader);
+ const group = buildPermissionModeItems(bypassEnabled);
+ const selectedItem = group.items.find(i => i.id === selectedMode) ?? group.items[0];
+ return { ...group, selected: selectedItem };
+ });
+
+ const folderGroup = derived(reader => {
+ /** @description folderGroup */
+ const items = folderItems.read(reader);
+ const folders = this._workspaceFolders.read(reader);
+ // Hide folder group when there's exactly one workspace folder (implicit)
+ if (folders.length === 1) {
+ return undefined;
+ }
+ const selectedFolder = folderUri.read(reader);
+ const locked = isSessionStarted.read(reader);
+ const lockedItems = locked ? items.map(i => ({ ...i, locked: true })) : items;
+ const selectedItem = selectedFolder
+ ? lockedItems.find(i => i.id === selectedFolder.fsPath)
+ : lockedItems[0];
+ return {
+ id: FOLDER_OPTION_ID,
+ name: vscode.l10n.t('Folder'),
+ description: vscode.l10n.t('Pick Folder'),
+ items: lockedItems,
+ selected: selectedItem ? (locked ? { ...selectedItem, locked: true } : selectedItem) : undefined,
+ };
+ });
+
+ const allGroups = derived(reader => {
+ /** @description allGroups */
+ const groups: vscode.ChatSessionProviderOptionGroup[] = [];
+ const folder = folderGroup.read(reader);
+ if (folder) {
+ groups.push(folder);
+ }
+ groups.push(permissionModeGroup.read(reader));
+ return groups;
+ });
+
+ // Hold `state` via a WeakRef so the autorun's closure does not retain it.
+ // Shared observables (`_workspaceFolders`, `_bypassPermissionsEnabled`) hold
+ // strong references to autoruns; without the WeakRef, `state` would transitively
+ // stay reachable forever and `_stateAutorunRegistry` could never fire.
+ const stateRef = new WeakRef(state);
+ store.add(autorun(reader => {
+ /** @description syncInputStateGroups */
+ const groups = allGroups.read(reader);
+ const currentState = stateRef.deref();
+ if (currentState) {
+ currentState.groups = groups;
+ }
+ }));
+
+ return { permissionMode, folderUri, folderItems, isSessionStarted, store };
+ }
+
+ private _setupInputState(): void {
this._controller.getChatSessionInputState = async (sessionResource, context, token) => {
if (context.previousInputState) {
const state = this._controller.createChatSessionInputState([...context.previousInputState.groups]);
- trackedStates.push({ ref: new WeakRef(state) });
+ const pipeline = this._createInputStateReactivePipeline(state);
+ this._statePipelines.set(state, pipeline);
+ this._stateAutorunRegistry.register(state, pipeline.store);
return state;
}
const isExistingSession = sessionResource && await this._claudeCodeSessionService.getSession(sessionResource, token) !== undefined;
-
- const groups = isExistingSession
+ const initialGroups = isExistingSession
? await this._buildExistingSessionGroups(sessionResource)
: await this._optionBuilder.buildNewSessionGroups();
- const state = this._controller.createChatSessionInputState(groups);
- trackedStates.push({ ref: new WeakRef(state) });
+ const state = this._controller.createChatSessionInputState(initialGroups);
+ const pipeline = this._createInputStateReactivePipeline(state);
+
+ if (isExistingSession) {
+ pipeline.isSessionStarted.set(true, undefined);
+ }
+
+ // React to external permission mode changes for this session
+ if (sessionResource) {
+ const sessionId = ClaudeSessionUri.getSessionId(sessionResource);
+ const externalPermissionMode = observableFromEvent(
+ this,
+ Event.filter(this._sessionStateService.onDidChangeSessionState,
+ e => e.sessionId === sessionId && e.permissionMode !== undefined),
+ () => this._sessionStateService.getPermissionModeForSession(sessionId),
+ );
+ pipeline.store.add(autorun(reader => {
+ /** @description syncExternalPermissionMode */
+ pipeline.permissionMode.set(externalPermissionMode.read(reader), undefined);
+ }));
+ }
+
+ this._statePipelines.set(state, pipeline);
+ this._stateAutorunRegistry.register(state, pipeline.store);
return state;
};
+ }
- // Rebuild active input states when external conditions change
- const refreshActiveInputStates = () => {
- sweepStaleEntries();
- for (const entry of trackedStates) {
- const state = entry.ref.deref();
- if (state) {
- this._rebuildInputState(state).catch(e => this._logService.error(e));
- }
- }
- };
+ /**
+ * Extracts seed values for the per-state observables from the input groups.
+ * Pure and synchronous — runs before any autoruns are attached so the first
+ * autorun pass observes fully-seeded values and does not overwrite the
+ * carefully-constructed initial groups.
+ *
+ * Also recovers the `isSessionStarted` signal from `locked` items — required to
+ * preserve lock state when restoring a previously-started session.
+ */
+ private _computeSeedValues(groups: readonly vscode.ChatSessionProviderOptionGroup[]): {
+ readonly permissionMode: PermissionMode;
+ readonly folderUri: URI | undefined;
+ readonly folderItems: readonly vscode.ChatSessionProviderOptionItem[];
+ readonly isSessionStarted: boolean;
+ } {
+ let permissionMode: PermissionMode = this._optionBuilder.lastUsedPermissionMode;
+ const permissionGroup = groups.find(g => g.id === PERMISSION_MODE_OPTION_ID);
+ if (permissionGroup?.selected && isPermissionMode(permissionGroup.selected.id)) {
+ permissionMode = permissionGroup.selected.id;
+ }
- // Config change (bypass permissions toggle) → may add/remove permission items
- this._register(this._configurationService.onDidChangeConfiguration(e => {
- if (e.affectsConfiguration(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions.fullyQualifiedId)) {
- refreshActiveInputStates();
+ let folderUri: URI | undefined;
+ let folderItems: readonly vscode.ChatSessionProviderOptionItem[] = [];
+ let isSessionStarted = false;
+ const folderGroup = groups.find(g => g.id === FOLDER_OPTION_ID);
+ if (folderGroup) {
+ if (folderGroup.items.length > 0) {
+ folderItems = folderGroup.items;
}
- }));
+ if (folderGroup.selected) {
+ folderUri = URI.file(folderGroup.selected.id);
+ }
+ // Restore the "started" signal: if any items (or the selected item) carry
+ // `locked: true`, the session was previously started and must stay locked.
+ if (folderGroup.selected?.locked || folderGroup.items.some(i => i.locked)) {
+ isSessionStarted = true;
+ }
+ }
- // Workspace folder changes → may add/remove folder group
- this._register(this._workspaceService.onDidChangeWorkspaceFolders(() => {
- refreshActiveInputStates();
- }));
+ return { permissionMode, folderUri, folderItems, isSessionStarted };
+ }
- // Session state service changes (e.g., permission mode changed externally)
- this._register(this._sessionStateService.onDidChangeSessionState(e => {
- if (e.permissionMode === undefined) {
- return;
- }
- for (const entry of trackedStates) {
- const state = entry.ref.deref();
- if (state?.sessionResource) {
- const stateSessionId = ClaudeSessionUri.getSessionId(state.sessionResource);
- if (stateSessionId === e.sessionId) {
- const permissionGroup = this._optionBuilder.buildPermissionModeGroup();
- const selectedItem = permissionGroup.items.find(i => i.id === e.permissionMode);
- if (selectedItem) {
- const updatedGroup = { ...permissionGroup, selected: selectedItem };
- state.groups = state.groups.map(g =>
- g.id === PERMISSION_MODE_OPTION_ID ? updatedGroup : g
- );
- }
- }
- }
- }
- }));
+ /**
+ * Marks the input state as "started", which locks the folder group.
+ * Called by the content provider when a new session begins.
+ */
+ markSessionStarted(inputState: vscode.ChatSessionInputState): void {
+ const pipeline = this._statePipelines.get(inputState);
+ if (pipeline) {
+ pipeline.isSessionStarted.set(true, undefined);
+ }
}
private async _buildExistingSessionGroups(sessionResource: vscode.Uri): Promise {
@@ -374,14 +540,6 @@ export class ClaudeChatSessionItemController extends Disposable {
return this._optionBuilder.buildExistingSessionGroups(permissionMode, folderUri);
}
- private async _rebuildInputState(state: vscode.ChatSessionInputState): Promise {
- if (state.sessionResource) {
- state.groups = await this._buildExistingSessionGroups(state.sessionResource);
- } else {
- state.groups = await this._optionBuilder.buildNewSessionGroups(state);
- }
- }
-
// #endregion
// #region Folder Resolution
@@ -523,6 +681,10 @@ export class ClaudeChatSessionItemController extends Disposable {
lastRequestEnded: session.lastRequestEnded,
};
item.iconPath = new vscode.ThemeIcon('claude');
+ if (session.cwd) {
+ // Agents app needs this to decide the working directory for the session
+ item.metadata = { workingDirectoryPath: session.cwd };
+ }
return item;
}
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts
index 4f579a59372..87b5d2d250c 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts
@@ -42,18 +42,16 @@ export class ClaudeSessionOptionBuilder {
private readonly _workspaceService: IWorkspaceService,
) { }
- async buildNewSessionGroups(previousInputState?: vscode.ChatSessionInputState): Promise {
+ async buildNewSessionGroups(): Promise {
const groups: vscode.ChatSessionProviderOptionGroup[] = [];
- const folderGroup = await this.buildNewFolderGroup(previousInputState);
+ const folderGroup = await this.buildNewFolderGroup();
if (folderGroup) {
groups.push(folderGroup);
}
const permissionGroup = this.buildPermissionModeGroup();
- const previousPermission = previousInputState ? getSelectedOption(previousInputState.groups, PERMISSION_MODE_OPTION_ID) : undefined;
- const selectedPermissionId = previousPermission?.id ?? this._lastUsedPermissionMode;
- const selectedPermission = permissionGroup.items.find(i => i.id === selectedPermissionId);
+ const selectedPermission = permissionGroup.items.find(i => i.id === this._lastUsedPermissionMode);
groups.push({
...permissionGroup,
selected: selectedPermission ?? permissionGroup.items[0],
@@ -80,40 +78,23 @@ export class ClaudeSessionOptionBuilder {
}
buildPermissionModeGroup(): vscode.ChatSessionProviderOptionGroup {
- const items: vscode.ChatSessionProviderOptionItem[] = [
- { id: 'default', name: l10n.t('Ask before edits') },
- { id: 'acceptEdits', name: l10n.t('Edit automatically') },
- { id: 'plan', name: l10n.t('Plan mode') },
- ];
-
- if (this._configurationService.getConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions)) {
- items.push({ id: 'bypassPermissions', name: l10n.t('Bypass all permissions') });
- }
-
- return {
- id: PERMISSION_MODE_OPTION_ID,
- name: l10n.t('Permission Mode'),
- description: l10n.t('Pick Permission Mode'),
- items,
- };
+ const bypassEnabled = this._configurationService.getConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions);
+ return buildPermissionModeItems(bypassEnabled);
}
- async buildNewFolderGroup(previousInputState: vscode.ChatSessionInputState | undefined): Promise {
+ async buildNewFolderGroup(): Promise {
const workspaceFolders = this._workspaceService.getWorkspaceFolders();
if (workspaceFolders.length === 1) {
return undefined;
}
const folderItems = await this.getFolderOptionItems();
- const previousFolder = previousInputState ? getSelectedOption(previousInputState.groups, FOLDER_OPTION_ID) : undefined;
- const defaultFolderId = previousFolder?.id ?? folderItems[0]?.id;
- const selectedItem = defaultFolderId ? folderItems.find(i => i.id === defaultFolderId) : undefined;
return {
id: FOLDER_OPTION_ID,
name: l10n.t('Folder'),
description: l10n.t('Pick Folder'),
items: folderItems,
- selected: selectedItem ?? folderItems[0],
+ selected: folderItems[0],
};
}
@@ -176,3 +157,30 @@ export class ClaudeSessionOptionBuilder {
return { permissionMode, folderUri };
}
}
+
+// #region Pure group-building functions (observable-friendly)
+
+/**
+ * Build the permission mode option group from explicit inputs.
+ * Pure and synchronous — suitable for use in `derived` computations.
+ */
+export function buildPermissionModeItems(bypassEnabled: boolean): vscode.ChatSessionProviderOptionGroup {
+ const items: vscode.ChatSessionProviderOptionItem[] = [
+ { id: 'default', name: l10n.t('Ask before edits') },
+ { id: 'acceptEdits', name: l10n.t('Edit automatically') },
+ { id: 'plan', name: l10n.t('Plan mode') },
+ ];
+
+ if (bypassEnabled) {
+ items.push({ id: 'bypassPermissions', name: l10n.t('Bypass all permissions') });
+ }
+
+ return {
+ id: PERMISSION_MODE_OPTION_ID,
+ name: l10n.t('Permission Mode'),
+ description: l10n.t('Pick Permission Mode'),
+ items,
+ };
+}
+
+// #endregion
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts
index 4e15302e761..27a48a7ab4a 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts
@@ -155,6 +155,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
}
isRefreshing = true;
const stopwatch = new StopWatch();
+ void this._metadataStore.refresh().catch(error => this.logService.error(error, 'Failed to refresh session metadata store during session list refresh'));
try {
const sessions = await this.sessionService.getAllSessions(CancellationToken.None);
const items = await Promise.all(sessions.map(async session => this.toChatSessionItem(session)));
@@ -405,7 +406,10 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
workingDirectory: vscode.Uri | undefined,
): Promise<{ readonly [key: string]: unknown }> {
if (worktreeProperties) {
+ const sessionParentId = await this._metadataStore.getSessionParentId(sessionId);
+
return {
+ sessionParentId,
autoCommit: worktreeProperties.autoCommit !== false,
baseCommit: worktreeProperties?.baseCommit,
baseBranchName: worktreeProperties.version === 2
@@ -451,7 +455,8 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
} satisfies { readonly [key: string]: unknown };
}
- const [sessionRequestDetails, repositoryProperties] = await Promise.all([
+ const [sessionParentId, sessionRequestDetails, repositoryProperties] = await Promise.all([
+ this._metadataStore.getSessionParentId(sessionId),
this._metadataStore.getRequestDetails(sessionId),
this._metadataStore.getRepositoryProperties(sessionId)
]);
@@ -470,6 +475,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
: undefined;
return {
+ sessionParentId,
isolationMode: IsolationMode.Workspace,
repositoryPath: repositoryProperties?.repositoryPath,
branchName: repositoryProperties?.branchName,
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
index 0368336f844..b9f2a2ae63c 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts
@@ -61,6 +61,7 @@ const REPOSITORY_OPTION_ID = 'repository';
const _sessionWorktreeIsolationCache = new Map();
const BRANCH_OPTION_ID = 'branch';
const ISOLATION_OPTION_ID = 'isolation';
+const PARENT_SESSION_OPTION_ID = 'parentSessionId';
const LAST_USED_ISOLATION_OPTION_KEY = 'github.copilot.cli.lastUsedIsolationOption';
const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.cli.sessions.openRepository';
const OPEN_IN_COPILOT_CLI_COMMAND_ID = 'github.copilot.cli.openInCopilotCLI';
@@ -211,10 +212,15 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
}
public notifySessionsChange(): void {
+ // Refresh the bulk metadata cache from disk so cross-process writes
+ // (e.g. another VS Code window editing the same session) become visible
+ // before consumers re-read items.
+ this.chatSessionMetadataStore.refresh().catch(() => { /* logged inside */ });
this._onDidChangeChatSessionItems.fire();
}
public async refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise {
+ await this.chatSessionMetadataStore.refresh().catch(() => { /* logged inside */ });
this._onDidChangeChatSessionItems.fire();
}
@@ -308,9 +314,12 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
// repository state which we are passing along through the metadata
worktreeProperties = await this.worktreeManager.getWorktreeProperties(session.id);
+ const sessionParentId = await this.chatSessionMetadataStore.getSessionParentId(session.id);
+
if (worktreeProperties) {
// Worktree
metadata = {
+ sessionParentId,
autoCommit: worktreeProperties.autoCommit !== false,
baseCommit: worktreeProperties?.baseCommit,
baseBranchName: worktreeProperties.version === 2
@@ -373,6 +382,7 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
: undefined;
metadata = {
+ sessionParentId,
isolationMode: IsolationMode.Workspace,
repositoryPath: repositoryProperties?.repositoryPath,
branchName: repositoryProperties?.branchName,
@@ -1267,6 +1277,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
let { chatSessionContext } = context;
const disposables = new DisposableStore();
let sessionId: string | undefined = undefined;
+ let sessionParentId: string | undefined = undefined;
let sdkSessionId: string | undefined = undefined;
try {
@@ -1284,6 +1295,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
_sessionBranch.set(sessionId, value);
} else if (opt.optionId === ISOLATION_OPTION_ID && value) {
_sessionIsolation.set(sessionId, value as IsolationMode);
+ } else if (opt.optionId === PARENT_SESSION_OPTION_ID && value) {
+ sessionParentId = value;
}
}
}
@@ -1369,7 +1382,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
};
const newBranch = (isUntitled && request.prompt && this.branchNameGenerator) ? this.branchNameGenerator.generateBranchName(fakeContext, token) : undefined;
- const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent, newBranch }, disposables, token);
+ const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent, newBranch, sessionParentId }, disposables, token);
const session = sessionResult.session;
if (session) {
disposables.add(session);
@@ -1729,7 +1742,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
}
}
- private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; newBranch?: Promise }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> {
+ private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; newBranch?: Promise; sessionParentId?: string }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> {
const { resource } = chatSessionContext.chatSessionItem;
const existingSessionId = this.sessionItemProvider.untitledSessionIdMapping.get(SessionIdForCLI.parse(resource));
const id = existingSessionId ?? SessionIdForCLI.parse(resource);
@@ -1747,7 +1760,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
const debugTargetSessionIds = extractDebugTargetSessionIds(request.references);
const mcpServerMappings = buildMcpServerMappings(request.tools);
const session = isNewSession ?
- await this.sessionService.createSession({ model: model?.model, reasoningEffort: model?.reasoningEffort, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token) :
+ await this.sessionService.createSession({ model: model?.model, reasoningEffort: model?.reasoningEffort, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings, sessionParentId: options.sessionParentId }, token) :
await this.sessionService.getSession({ sessionId: id, model: model?.model, reasoningEffort: model?.reasoningEffort, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token);
this.sessionItemProvider.notifySessionsChange();
// TODO @DonJayamanne We need to refresh to add this new session, but we need a label.
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts
index 40a7bd4b9ed..4df78020aee 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts
@@ -12,23 +12,30 @@ import { ILogService } from '../../../../platform/log/common/logService';
import { mock } from '../../../../util/common/test/simpleMock';
import { Emitter } from '../../../../util/vs/base/common/event';
import { URI } from '../../../../util/vs/base/common/uri';
-import { eventToPromise } from '../../../completions-core/vscode-node/lib/src/prompt/asyncUtils';
-import { ChatSessionWorktreeData, ChatSessionWorktreeProperties } from '../../common/chatSessionWorktreeService';
+import { ChatSessionWorktreeProperties } from '../../common/chatSessionWorktreeService';
import { IWorkspaceInfo } from '../../common/workspaceInfo';
import { getCopilotCLISessionDir } from '../../copilotcli/node/cliHelpers';
import { NullCopilotCLIAgents } from '../../copilotcli/node/test/testHelpers';
import { ChatSessionMetadataStore } from '../chatSessionMetadataStoreImpl';
+// Hoisted holder lets each test point the JSONL helper at its own mock path.
+const jsonlPathHolder = vi.hoisted(() => {
+ const p = '/mock/copilot-home/worktree.jsonl';
+ return { get: () => p };
+});
+
vi.mock('../../copilotcli/node/cliHelpers', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
getCopilotCLISessionDir: (sessionId: string) => `/mock/session-state/${sessionId}`,
+ getCopilotCLISessionStateDir: () => '/mock/session-state',
+ // New shared bulk + JSONL paths — all go through the mocked IFileSystemService.
+ getCopilotBulkMetadataFile: () => '/mock/copilot-home/vscode.session.metadata.cache.json',
+ getCopilotWorktreeSessionsFile: () => jsonlPathHolder.get(),
};
});
-const WORKSPACE_FOLDER_MEMENTO_KEY = 'github.copilot.cli.sessionWorkspaceFolders';
-const WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees';
class MockGlobalState implements vscode.Memento {
private data = new Map();
@@ -82,8 +89,12 @@ class MockLogService extends mock() {
}
// Paths used by the store
-const GLOBAL_STORAGE_DIR = Uri.joinPath(Uri.file('/mock/global/storage'), 'copilotcli');
-const BULK_METADATA_FILE = Uri.joinPath(GLOBAL_STORAGE_DIR, 'copilotcli.session.metadata.json');
+// New shared bulk file (top-N cache). Lives at `~/.copilot/...` in production but is
+// mocked above so the IFileSystemService mock can intercept reads/writes.
+const BULK_METADATA_FILE = Uri.file('/mock/copilot-home/vscode.session.metadata.cache.json');
+// Legacy bulk file location in the per-install globalStorageUri — used only by the
+// one-time migration in `initializeStorage()`.
+const LEGACY_BULK_METADATA_FILE = Uri.joinPath(Uri.file('/mock/global/storage'), 'copilotcli', 'copilotcli.session.metadata.json');
function sessionDirectoryUri(sessionId: string): Uri {
return Uri.file(getCopilotCLISessionDir(sessionId));
@@ -121,9 +132,6 @@ function makeWorktreeV2Props(overrides?: Partial)
} as ChatSessionWorktreeProperties;
}
-function makeWorktreeData(props: ChatSessionWorktreeProperties): ChatSessionWorktreeData {
- return { data: JSON.stringify(props), version: props.version };
-}
class MockFileSystemServiceWithMotification extends MockFileSystemService {
onDidCreateFile = new Emitter();
@@ -145,14 +153,14 @@ describe('ChatSessionMetadataStore', () => {
let logService: MockLogService;
let extensionContext: MockExtensionContext;
- beforeEach(() => {
+ beforeEach(async () => {
vi.useFakeTimers();
mockFs = new MockFileSystemServiceWithMotification();
logService = new MockLogService();
extensionContext = new MockExtensionContext();
});
- afterEach(() => {
+ afterEach(async () => {
mockFs.dispose();
vi.useRealTimers();
vi.restoreAllMocks();
@@ -160,7 +168,7 @@ describe('ChatSessionMetadataStore', () => {
/**
* Creates the store and waits for initialization to complete.
- * Constructor eagerly triggers lazy init; we flush microtasks so it settles.
+ * Constructor eagerly triggers init; we flush microtasks so it settles.
*/
async function createStore(): Promise {
const store = new ChatSessionMetadataStore(
@@ -169,8 +177,11 @@ describe('ChatSessionMetadataStore', () => {
extensionContext,
new NullCopilotCLIAgents(),
);
- // Flush microtasks to let initialization settle
- await vi.advanceTimersByTimeAsync(0);
+ // Flush enough microtask rounds so that initializeStorage() —
+ // which chains several async I/O steps — fully settles.
+ for (let i = 0; i < 5; i++) {
+ await vi.advanceTimersByTimeAsync(0);
+ }
return store;
}
@@ -194,158 +205,6 @@ describe('ChatSessionMetadataStore', () => {
store.dispose();
});
- it('should also read global state memento keys to pick up missing entries when bulk file exists', async () => {
- // Global state has data not yet in the bulk file — it should be merged in
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-x': { folderPath: Uri.file('/from/global/state').fsPath, timestamp: 999 },
- });
- mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
-
- const getSpy = vi.spyOn(extensionContext.globalState, 'get');
- const store = await createStore();
-
- // globalState.get SHOULD now be called for the memento keys
- const mementoCalls = getSpy.mock.calls.filter(
- c => c[0] === WORKSPACE_FOLDER_MEMENTO_KEY || c[0] === WORKTREE_MEMENTO_KEY,
- );
- expect(mementoCalls.length).toBeGreaterThan(0);
-
- // session-x should now be accessible since it was merged from global state
- const folder = await store.getSessionWorkspaceFolder('session-x');
- expect(folder?.fsPath).toBe(Uri.file('/from/global/state').fsPath);
- store.dispose();
- });
-
- it('should not overwrite entries already in bulk file from global state', async () => {
- // Bulk file already has session-x with one path; global state has a different path
- const existingData = {
- 'session-x': { workspaceFolder: { folderPath: Uri.file('/from/bulk').fsPath, timestamp: 100 } },
- };
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-x': { folderPath: Uri.file('/from/global/state').fsPath, timestamp: 999 },
- });
- mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify(existingData));
-
- const store = await createStore();
-
- // Should keep the bulk file version, not the global state version
- const folder = await store.getSessionWorkspaceFolder('session-x');
- expect(folder?.fsPath).toBe(Uri.file('/from/bulk').fsPath);
- store.dispose();
- });
-
- it('should attempt to write per-session files for entries not yet writtenToDisc', async () => {
- const existingData = {
- 'session-1': { workspaceFolder: { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 } },
- };
- const fileUri = sessionMetadataFileUri('session-1');
- mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify(existingData));
- mockFs.mockDirectory(fileUri, []);
-
- // Pre-create the session directory so the write succeeds
- await mockFs.createDirectory(sessionDirectoryUri('session-1'));
- const fileCreated = eventToPromise(mockFs.onDidCreateFile.event);
-
- const store = await createStore();
-
- // wait for file to get created.
- await fileCreated;
- const rawContent = await mockFs.readFile(fileUri);
- const written = JSON.parse(new TextDecoder().decode(rawContent));
- expect(written.workspaceFolder?.folderPath).toBe(Uri.file('/workspace/a').fsPath);
- store.dispose();
- });
-
- it('should not attempt to write per-session files for entries already marked writtenToDisc', async () => {
- const existingData = {
- 'session-1': { workspaceFolder: { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 }, writtenToDisc: true },
- };
- mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify(existingData));
-
- const writeSpy = vi.spyOn(mockFs, 'writeFile');
- const store = await createStore();
- await vi.advanceTimersByTimeAsync(0);
-
- // No writes to per-session files should have occurred
- const perSessionWrites = writeSpy.mock.calls.filter(
- c => c[0].toString().includes('vscode.metadata.json'),
- );
- expect(perSessionWrites).toHaveLength(0);
- store.dispose();
- });
-
- it('should skip per-session file write gracefully when directory does not exist during retry', async () => {
- const existingData = {
- 'session-1': { workspaceFolder: { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 } },
- };
- mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify(existingData));
- // Do NOT create the session directory
-
- const createDirSpy = vi.spyOn(mockFs, 'createDirectory');
- const store = await createStore();
- await vi.advanceTimersByTimeAsync(0);
-
- // createDirectory should NOT have been called for the per-session dir
- // because createDirectoryIfNotFound=false during retry
- const perSessionDirCalls = createDirSpy.mock.calls.filter(
- c => c[0].toString().includes('session-1'),
- );
- expect(perSessionDirCalls).toHaveLength(0);
-
- // Entry should still be accessible from cache
- const folder = await store.getSessionWorkspaceFolder('session-1');
- expect(folder?.fsPath).toBe(Uri.file('/workspace/a').fsPath);
- store.dispose();
- });
-
- it('should keep worktree cache entry unchanged when global state has same number of changes', async () => {
- const cachedProps = makeWorktreeV1Props({
- changes: [{ filePath: '/a.ts', originalFilePath: '/a.ts', modifiedFilePath: '/a.ts', statistics: { additions: 1, deletions: 0 } }],
- });
- const globalStateProps = makeWorktreeV1Props({
- branchName: 'from-global-state',
- changes: [{ filePath: '/b.ts', originalFilePath: '/b.ts', modifiedFilePath: '/b.ts', statistics: { additions: 2, deletions: 1 } }],
- });
- mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
- 'session-wt': { worktreeProperties: cachedProps },
- }));
- extensionContext.globalState.seed(WORKTREE_MEMENTO_KEY, {
- 'session-wt': makeWorktreeData(globalStateProps),
- });
-
- const store = await createStore();
-
- // Cache entry should keep its original data (not replaced by global state)
- const wt = await store.getWorktreeProperties('session-wt');
- expect(wt?.branchName).toBe(cachedProps.branchName);
- store.dispose();
- });
-
- it('should update worktree cache entry when global state has more changes', async () => {
- const cachedProps = makeWorktreeV1Props({ changes: undefined });
- const globalStateProps = makeWorktreeV1Props({
- branchName: 'from-global-state',
- changes: [
- { filePath: '/a.ts', originalFilePath: '/a.ts', modifiedFilePath: '/a.ts', statistics: { additions: 1, deletions: 0 } },
- { filePath: '/b.ts', originalFilePath: '/b.ts', modifiedFilePath: '/b.ts', statistics: { additions: 2, deletions: 1 } },
- ],
- });
- mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
- 'session-wt': { worktreeProperties: cachedProps },
- }));
- extensionContext.globalState.seed(WORKTREE_MEMENTO_KEY, {
- 'session-wt': makeWorktreeData(globalStateProps),
- });
-
- const store = await createStore();
-
- // Even when global state has more changes, cache entry is preserved (both paths continue)
- const wt = await store.getWorktreeProperties('session-wt');
- expect(wt?.branchName).toBe(globalStateProps.branchName);
- expect(wt?.changes).toEqual(globalStateProps.changes);
- store.dispose();
- });
-
it('should not retry entries with no workspaceFolder, worktreeProperties, or additionalWorkspaces', async () => {
const existingData = {
'session-empty': {},
@@ -366,378 +225,6 @@ describe('ChatSessionMetadataStore', () => {
store.dispose();
});
- it('should retry entries that only have additionalWorkspaces (not delete as invalid data)', async () => {
- // A session with only additionalWorkspaces and writtenToDisc: false
- // must be retried, not deleted from cache — otherwise data is lost after a crash.
- const existingData = {
- 'session-only-additional': {
- additionalWorkspaces: [
- { workspaceFolder: { folderPath: Uri.file('/extra/workspace').fsPath, timestamp: 100 } },
- ],
- },
- };
- mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify(existingData));
-
- // Pre-create the session directory so the recovery write can succeed
- await mockFs.createDirectory(sessionDirectoryUri('session-only-additional'));
- const fileCreated = eventToPromise(mockFs.onDidCreateFile.event);
-
- const store = await createStore();
- await fileCreated;
-
- const fileUri = sessionMetadataFileUri('session-only-additional');
- const rawContent = await mockFs.readFile(fileUri);
- const written = JSON.parse(new TextDecoder().decode(rawContent));
- expect(written.additionalWorkspaces).toHaveLength(1);
- store.dispose();
- });
- });
-
- // ──────────────────────────────────────────────────────────────────────────
- // initializeStorage — migration path: bulk file missing, read from global state
- // ──────────────────────────────────────────────────────────────────────────
- describe('initializeStorage - migration from global state', () => {
- it('should migrate workspace folder entries to bulk file', async () => {
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-1': { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 },
- 'session-2': { folderPath: Uri.file('/workspace/b').fsPath, timestamp: 200 },
- });
-
- const store = await createStore();
-
- const folder1 = await store.getSessionWorkspaceFolder('session-1');
- expect(folder1?.fsPath).toBe(Uri.file('/workspace/a').fsPath);
- const folder2 = await store.getSessionWorkspaceFolder('session-2');
- expect(folder2?.fsPath).toBe(Uri.file('/workspace/b').fsPath);
- store.dispose();
- });
-
- it('should migrate worktree entries to bulk file', async () => {
- const v1Props = makeWorktreeV1Props();
- extensionContext.globalState.seed(WORKTREE_MEMENTO_KEY, {
- 'session-wt': makeWorktreeData(v1Props),
- });
-
- const store = await createStore();
-
- const wt = await store.getWorktreeProperties('session-wt');
- expect(wt).toBeDefined();
- expect(wt!.version).toBe(1);
- expect(wt!.branchName).toBe(v1Props.branchName);
- store.dispose();
- });
-
- it('should parse version 1 worktree data with explicit version override', async () => {
- // For version 1, the code spreads JSON.parse(data) and sets version: 1
- const rawProps = { baseCommit: 'c1', branchName: 'b1', repositoryPath: '/r', worktreePath: '/w', autoCommit: false };
- extensionContext.globalState.seed(WORKTREE_MEMENTO_KEY, {
- 'session-v1': { data: JSON.stringify(rawProps), version: 1 } satisfies ChatSessionWorktreeData,
- });
-
- const store = await createStore();
- const wt = await store.getWorktreeProperties('session-v1');
- expect(wt?.version).toBe(1);
- expect(wt?.baseCommit).toBe('c1');
- store.dispose();
- });
-
- it('should parse version 2 worktree data directly from data string', async () => {
- const v2Props = makeWorktreeV2Props();
- extensionContext.globalState.seed(WORKTREE_MEMENTO_KEY, {
- 'session-v2': { data: JSON.stringify(v2Props), version: 2 } satisfies ChatSessionWorktreeData,
- });
-
- const store = await createStore();
- const wt = await store.getWorktreeProperties('session-v2');
- expect(wt?.version).toBe(2);
- expect(wt?.version === 2 ? wt.baseBranchName : '').toBe('main');
- store.dispose();
- });
-
- it('should give worktree precedence over workspace folder for same session', async () => {
- const v1Props = makeWorktreeV1Props();
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-both': { folderPath: Uri.file('/workspace/shared').fsPath, timestamp: 100 },
- });
- extensionContext.globalState.seed(WORKTREE_MEMENTO_KEY, {
- 'session-both': makeWorktreeData(v1Props),
- });
-
- const store = await createStore();
-
- // worktree takes precedence: getSessionWorkspaceFolder returns undefined when worktree exists
- const folder = await store.getSessionWorkspaceFolder('session-both');
- expect(folder).toBeUndefined();
-
- const wt = await store.getWorktreeProperties('session-both');
- expect(wt).toBeDefined();
- store.dispose();
- });
-
- it.skip('should clear global state keys after successful migration', async () => {
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-1': { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 },
- });
- extensionContext.globalState.seed(WORKTREE_MEMENTO_KEY, {
- 'session-2': makeWorktreeData(makeWorktreeV1Props()),
- });
-
- const store = await createStore();
-
- expect(extensionContext.globalState.has(WORKSPACE_FOLDER_MEMENTO_KEY)).toBe(false);
- expect(extensionContext.globalState.has(WORKTREE_MEMENTO_KEY)).toBe(false);
- store.dispose();
- });
-
- it('should write migrated data to bulk metadata file', async () => {
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-1': { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 },
- });
-
- const store = await createStore();
-
- // Read the bulk file directly to verify it was written
- const rawContent = await mockFs.readFile(BULK_METADATA_FILE);
- const written = JSON.parse(new TextDecoder().decode(rawContent));
- expect(written['session-1']).toBeDefined();
- expect(written['session-1'].workspaceFolder.folderPath).toBe(Uri.file('/workspace/a').fsPath);
- store.dispose();
- });
-
- it('should write per-session metadata files during migration when directory exists', async () => {
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-1': { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 },
- });
-
- // Pre-create the session directory so the write succeeds
- // (migration uses createDirectoryIfNotFound=false)
- await mockFs.createDirectory(sessionDirectoryUri('session-1'));
-
- const store = await createStore();
- // Wait for the fire-and-forget per-session writes
- await vi.advanceTimersByTimeAsync(0);
-
- const fileUri = sessionMetadataFileUri('session-1');
- const rawContent = await mockFs.readFile(fileUri);
- const written = JSON.parse(new TextDecoder().decode(rawContent));
- expect(written.workspaceFolder?.folderPath).toBe(Uri.file('/workspace/a').fsPath);
- store.dispose();
- });
-
- it('should skip per-session file write when directory does not exist during migration', async () => {
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-1': { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 },
- });
- // Do NOT create the session directory
-
- const writeSpy = vi.spyOn(mockFs, 'writeFile');
- const store = await createStore();
- await vi.advanceTimersByTimeAsync(0);
-
- // No per-session file write should occur since dir is missing and createDirectoryIfNotFound=false
- const perSessionWrites = writeSpy.mock.calls.filter(
- c => c[0].toString().includes('session-1') && c[0].toString().includes('vscode.metadata.json'),
- );
- expect(perSessionWrites).toHaveLength(0);
-
- // Data should still be accessible from cache
- const folder = await store.getSessionWorkspaceFolder('session-1');
- expect(folder?.fsPath).toBe(Uri.file('/workspace/a').fsPath);
- store.dispose();
- });
-
- it('should mark migrated worktree entries with writtenToDisc false and attempt per-session write', async () => {
- const v1Props = makeWorktreeV1Props();
- extensionContext.globalState.seed(WORKTREE_MEMENTO_KEY, {
- 'session-wt-migrate': makeWorktreeData(v1Props),
- });
-
- // Pre-create the session directory so the retry write succeeds
- await mockFs.createDirectory(sessionDirectoryUri('session-wt-migrate'));
- const fileCreated = eventToPromise(mockFs.onDidCreateFile.event);
-
- const store = await createStore();
-
- // Wait for the per-session file write triggered by writtenToDisc: false
- await fileCreated;
- const fileUri = sessionMetadataFileUri('session-wt-migrate');
- const rawContent = await mockFs.readFile(fileUri);
- const written = JSON.parse(new TextDecoder().decode(rawContent));
- expect(written.worktreeProperties?.branchName).toBe(v1Props.branchName);
- store.dispose();
- });
- });
-
- // ──────────────────────────────────────────────────────────────────────────
- // initializeStorage — filtering edge cases
- // ──────────────────────────────────────────────────────────────────────────
- describe('initializeStorage - filtering', () => {
- it('should skip workspace folder entries with untitled- prefix', async () => {
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'untitled-session': { folderPath: Uri.file('/workspace/skip').fsPath, timestamp: 100 },
- 'session-keep': { folderPath: Uri.file('/workspace/keep').fsPath, timestamp: 200 },
- });
-
- const store = await createStore();
-
- const skipFolder = await store.getSessionWorkspaceFolder('untitled-session');
- expect(skipFolder).toBeUndefined();
- const keepFolder = await store.getSessionWorkspaceFolder('session-keep');
- expect(keepFolder?.fsPath).toBe(Uri.file('/workspace/keep').fsPath);
- store.dispose();
- });
-
- it('should skip worktree entries with untitled- prefix', async () => {
- extensionContext.globalState.seed(WORKTREE_MEMENTO_KEY, {
- 'untitled-wt': makeWorktreeData(makeWorktreeV1Props()),
- 'session-wt': makeWorktreeData(makeWorktreeV1Props()),
- });
-
- const store = await createStore();
-
- const skipWt = await store.getWorktreeProperties('untitled-wt');
- expect(skipWt).toBeUndefined();
- const keepWt = await store.getWorktreeProperties('session-wt');
- expect(keepWt).toBeDefined();
- store.dispose();
- });
-
- it('should skip workspace folder entries that are raw strings (legacy format)', async () => {
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-legacy': Uri.file('/old/string/path').fsPath,
- 'session-good': { folderPath: Uri.file('/workspace/good').fsPath, timestamp: 100 },
- });
-
- const store = await createStore();
-
- const legacy = await store.getSessionWorkspaceFolder('session-legacy');
- expect(legacy).toBeUndefined();
- const good = await store.getSessionWorkspaceFolder('session-good');
- expect(good?.fsPath).toBe(Uri.file('/workspace/good').fsPath);
- store.dispose();
- });
-
- it('should skip workspace folder entries missing folderPath', async () => {
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-no-path': { timestamp: 100 },
- });
-
- const store = await createStore();
-
- const folder = await store.getSessionWorkspaceFolder('session-no-path');
- expect(folder).toBeUndefined();
- store.dispose();
- });
-
- it('should skip workspace folder entries missing timestamp', async () => {
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-no-ts': { folderPath: Uri.file('/workspace/no-ts').fsPath },
- });
-
- const store = await createStore();
-
- const folder = await store.getSessionWorkspaceFolder('session-no-ts');
- expect(folder).toBeUndefined();
- store.dispose();
- });
-
- it('should skip worktree entries that are raw strings (legacy format)', async () => {
- extensionContext.globalState.seed(WORKTREE_MEMENTO_KEY, {
- 'session-old': 'some-old-string',
- 'session-new': makeWorktreeData(makeWorktreeV1Props()),
- });
-
- const store = await createStore();
-
- const oldWt = await store.getWorktreeProperties('session-old');
- expect(oldWt).toBeUndefined();
- const newWt = await store.getWorktreeProperties('session-new');
- expect(newWt).toBeDefined();
- store.dispose();
- });
- });
-
- // ──────────────────────────────────────────────────────────────────────────
- // initializeStorage — migration failure safety (CRITICAL)
- // ──────────────────────────────────────────────────────────────────────────
- describe('initializeStorage - failure safety', () => {
- it('should NOT clear global state when writeToGlobalStorage fails', async () => {
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-1': { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 },
- });
- extensionContext.globalState.seed(WORKTREE_MEMENTO_KEY, {
- 'session-2': makeWorktreeData(makeWorktreeV1Props()),
- });
-
- // Make writeFile throw so writeToGlobalStorage fails
- vi.spyOn(mockFs, 'writeFile').mockRejectedValue(new Error('disk full'));
-
- const store = await createStore();
-
- // Global state should be preserved since writing to file failed
- expect(extensionContext.globalState.has(WORKSPACE_FOLDER_MEMENTO_KEY)).toBe(true);
- expect(extensionContext.globalState.has(WORKTREE_MEMENTO_KEY)).toBe(true);
-
- // Initialization failure should be logged
- expect(logService.error).toHaveBeenCalled();
- store.dispose();
- });
-
- it('should NOT clear global state when createDirectory fails during writeToGlobalStorage', async () => {
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-1': { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 },
- });
-
- // stat throws (dir missing) and createDirectory also throws
- vi.spyOn(mockFs, 'stat').mockRejectedValue(new Error('ENOENT'));
- vi.spyOn(mockFs, 'createDirectory').mockRejectedValue(new Error('permission denied'));
-
- const store = await createStore();
-
- expect(extensionContext.globalState.has(WORKSPACE_FOLDER_MEMENTO_KEY)).toBe(true);
- store.dispose();
- });
-
- it.skip('should still clear global state even when per-session file writes fail', async () => {
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-1': { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 },
- });
-
- // Allow bulk file write to succeed but track per-session writes
- let writeCallCount = 0;
- const origWriteFile = mockFs.writeFile.bind(mockFs);
- vi.spyOn(mockFs, 'writeFile').mockImplementation(async (uri, content) => {
- writeCallCount++;
- // Let the bulk metadata file write succeed (first or second write)
- if (uri.toString().includes('copilotcli.session.metadata.json')) {
- return origWriteFile(uri, content);
- }
- // Fail per-session writes
- throw new Error('per-session write failed');
- });
-
- const store = await createStore();
-
- // Global state should be cleared because bulk file write succeeded
- // (per-session writes are fire-and-forget via Promise.allSettled)
- expect(extensionContext.globalState.has(WORKSPACE_FOLDER_MEMENTO_KEY)).toBe(false);
- store.dispose();
- });
-
- it('should log error when initialization fails', async () => {
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-1': { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 },
- });
- vi.spyOn(mockFs, 'writeFile').mockRejectedValue(new Error('disk full'));
-
- const store = await createStore();
-
- expect(logService.error).toHaveBeenCalledWith(
- '[ChatSessionMetadataStore] Initialization failed: ',
- expect.any(Error),
- );
- store.dispose();
- });
});
// ──────────────────────────────────────────────────────────────────────────
@@ -1125,7 +612,7 @@ describe('ChatSessionMetadataStore', () => {
const fileUri = sessionMetadataFileUri('session-no-file');
const rawContent = await mockFs.readFile(fileUri);
const written = JSON.parse(new TextDecoder().decode(rawContent));
- expect(written).toEqual({ origin: 'other' });
+ expect(written).toEqual(expect.objectContaining({ origin: 'other' }));
store.dispose();
});
});
@@ -1244,14 +731,14 @@ describe('ChatSessionMetadataStore', () => {
// Before debounce fires, count writes to bulk file
const bulkWritesBefore = writeSpy.mock.calls.filter(
- c => c[0].toString().includes('copilotcli.session.metadata.json'),
+ c => c[0].toString().includes('vscode.session.metadata.cache.json'),
).length;
// Advance past debounce
await vi.advanceTimersByTimeAsync(1_100);
const bulkWritesAfter = writeSpy.mock.calls.filter(
- c => c[0].toString().includes('copilotcli.session.metadata.json'),
+ c => c[0].toString().includes('vscode.session.metadata.cache.json'),
).length;
// Should have exactly one new bulk write after debounce (coalesced)
@@ -1530,33 +1017,19 @@ describe('ChatSessionMetadataStore', () => {
store.dispose();
});
- it('should survive crash recovery: entry with only additionalWorkspaces is re-persisted not deleted', async () => {
- // Simulate VS Code crash: bulk file has the entry but writtenToDisc is falsy
- // (updateSessionMetadata never completed before the crash).
+ it('should keep entries with additionalWorkspaces in cache even without writtenToDisc flag', async () => {
+ // Bulk file has the entry but writtenToDisc is falsy — it should still
+ // be kept in the in-memory cache and accessible via the API.
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
'session-crash': {
additionalWorkspaces: [
{ workspaceFolder: { folderPath: Uri.file('/extra/workspace').fsPath, timestamp: 100 } },
],
- // writtenToDisc intentionally absent (falsy) — simulates crash before write completed
},
}));
- // Pre-create session directory so recovery write can succeed
- await mockFs.createDirectory(sessionDirectoryUri('session-crash'));
- const fileCreated = eventToPromise(mockFs.onDidCreateFile.event);
-
const store = await createStore();
- await fileCreated;
- // Entry should have been re-persisted to per-session file
- const fileUri = sessionMetadataFileUri('session-crash');
- const rawContent = await mockFs.readFile(fileUri);
- const written = JSON.parse(new TextDecoder().decode(rawContent));
- expect(written.additionalWorkspaces).toHaveLength(1);
- expect(written.additionalWorkspaces[0].workspaceFolder?.folderPath).toBe(Uri.file('/extra/workspace').fsPath);
-
- // And still readable via the API
const result = await store.getAdditionalWorkspaces('session-crash');
expect(result).toHaveLength(1);
store.dispose();
@@ -1768,21 +1241,15 @@ describe('ChatSessionMetadataStore', () => {
// Constructor & edge cases
// ──────────────────────────────────────────────────────────────────────────
describe('constructor and edge cases', () => {
- it('should handle initialization failure gracefully when writeFile fails and cache was updated', async () => {
- // Bulk file read fails, falls through to global state
- // Global state has data, so cacheUpdated = true, writeToGlobalStorage is called but writeFile fails
- extensionContext.globalState.seed(WORKSPACE_FOLDER_MEMENTO_KEY, {
- 'session-1': { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 },
- });
+ it('should handle initialization gracefully when bulk file read fails', async () => {
+ // Bulk file read fails — store starts with empty cache, no fatal error
vi.spyOn(mockFs, 'readFile').mockRejectedValue(new Error('ENOENT'));
- vi.spyOn(mockFs, 'writeFile').mockRejectedValue(new Error('disk error'));
const store = await createStore();
- expect(logService.error).toHaveBeenCalledWith(
- '[ChatSessionMetadataStore] Initialization failed: ',
- expect.any(Error),
- );
+ // Cache should be empty but usable
+ const folder = await store.getSessionWorkspaceFolder('nonexistent');
+ expect(folder).toBeUndefined();
store.dispose();
});
@@ -1881,4 +1348,432 @@ describe('ChatSessionMetadataStore', () => {
});
});
+ // ──────────────────────────────────────────────────────────────────────────
+ // New behaviors: shared bulk file, refresh(), JSONL worktree index,
+ // last-modified-wins merge, top-N trim, legacy migration.
+ // Each test is intentionally small and self-contained.
+ // ──────────────────────────────────────────────────────────────────────────
+ describe('shared bulk file + refresh()', () => {
+ it('refresh() picks up a session written by another process to the shared bulk file', async () => {
+ vi.setSystemTime(new Date(0));
+ // Start with empty bulk file.
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+ const store = await createStore();
+ expect(await store.getSessionWorkspaceFolder('cross-proc-session')).toBeUndefined();
+
+ // Simulate "other process" rewriting the shared bulk file. `modified: 999` is
+ // guaranteed to be > anything stamped locally because we pinned the clock to 0.
+ const externalEntry = {
+ 'cross-proc-session': {
+ workspaceFolder: { folderPath: Uri.file('/external/folder').fsPath, timestamp: 42 },
+ modified: 999,
+ },
+ };
+ await mockFs.writeFile(BULK_METADATA_FILE, new TextEncoder().encode(JSON.stringify(externalEntry)));
+
+ await store.refresh();
+
+ expect((await store.getSessionWorkspaceFolder('cross-proc-session'))?.fsPath)
+ .toBe(Uri.file('/external/folder').fsPath);
+ store.dispose();
+ });
+
+ it('refresh() never drops in-memory entries that are not on disk', async () => {
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+ const store = await createStore();
+ await store.storeWorkspaceFolderInfo('local-only', { folderPath: Uri.file('/local').fsPath, timestamp: 1 });
+ await vi.advanceTimersByTimeAsync(2000); // flush debounced bulk write
+
+ // Wipe the on-disk bulk file (simulating an external truncation).
+ await mockFs.writeFile(BULK_METADATA_FILE, new TextEncoder().encode(JSON.stringify({})));
+
+ await store.refresh();
+
+ expect((await store.getSessionWorkspaceFolder('local-only'))?.fsPath)
+ .toBe(Uri.file('/local').fsPath);
+ store.dispose();
+ });
+
+ it('refresh() failure does not poison subsequent reads (chained _ready)', async () => {
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+ const store = await createStore();
+
+ // Make the next bulk read throw.
+ const readSpy = vi.spyOn(mockFs, 'readFile').mockImplementationOnce(async () => { throw new Error('boom'); });
+ await store.refresh(); // swallowed inside
+
+ readSpy.mockRestore();
+ await store.storeWorkspaceFolderInfo('after-fail', { folderPath: Uri.file('/after').fsPath, timestamp: 1 });
+ expect((await store.getSessionWorkspaceFolder('after-fail'))?.fsPath).toBe(Uri.file('/after').fsPath);
+ store.dispose();
+ });
+ });
+
+ describe('last-modified-wins merge', () => {
+ it('keeps the entry with the higher `modified` timestamp', async () => {
+ vi.setSystemTime(new Date(0));
+ // Bulk file holds an OLDER copy of session-1.
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
+ 'session-1': {
+ workspaceFolder: { folderPath: Uri.file('/old/path').fsPath, timestamp: 1 },
+ modified: 100,
+ },
+ }));
+ const store = await createStore();
+
+ // Another process writes a NEWER version directly to disk.
+ await mockFs.writeFile(BULK_METADATA_FILE, new TextEncoder().encode(JSON.stringify({
+ 'session-1': {
+ workspaceFolder: { folderPath: Uri.file('/newer/path').fsPath, timestamp: 2 },
+ modified: 5000,
+ },
+ })));
+
+ await store.refresh();
+
+ expect((await store.getSessionWorkspaceFolder('session-1'))?.fsPath)
+ .toBe(Uri.file('/newer/path').fsPath);
+ store.dispose();
+ });
+
+ it('does not overwrite a fresher in-memory entry with an older disk entry', async () => {
+ vi.setSystemTime(new Date(10_000));
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+ const store = await createStore();
+ await store.storeWorkspaceFolderInfo('session-fresh', { folderPath: Uri.file('/fresh').fsPath, timestamp: 100 });
+ await vi.advanceTimersByTimeAsync(2000);
+
+ // External writer puts an OLDER copy on disk (lower modified).
+ await mockFs.writeFile(BULK_METADATA_FILE, new TextEncoder().encode(JSON.stringify({
+ 'session-fresh': { workspaceFolder: { folderPath: Uri.file('/stale').fsPath, timestamp: 1 }, modified: 1 },
+ })));
+
+ await store.refresh();
+
+ expect((await store.getSessionWorkspaceFolder('session-fresh'))?.fsPath).toBe(Uri.file('/fresh').fsPath);
+ store.dispose();
+ });
+ });
+
+ describe('legacy bulk file migration (Step 0)', () => {
+ it('migrates from the legacy globalStorage path on first run', async () => {
+ mockFs.mockFile(LEGACY_BULK_METADATA_FILE, JSON.stringify({
+ 'legacy-session': { workspaceFolder: { folderPath: Uri.file('/legacy').fsPath, timestamp: 1 } },
+ }));
+ // New shared file does NOT exist yet.
+
+ const store = await createStore();
+
+ expect((await store.getSessionWorkspaceFolder('legacy-session'))?.fsPath)
+ .toBe(Uri.file('/legacy').fsPath);
+ // Migration wrote to the new path.
+ const newRaw = await mockFs.readFile(BULK_METADATA_FILE);
+ expect(JSON.parse(new TextDecoder().decode(newRaw))).toHaveProperty('legacy-session');
+ store.dispose();
+ });
+
+ it('merges legacy entries into an existing shared file (late-joiner scenario)', async () => {
+ // Process A already created the shared file with session-A.
+ // Process B starts with its own legacy file containing session-B.
+ // Both should be present after migration.
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
+ 'session-A': { workspaceFolder: { folderPath: Uri.file('/a').fsPath, timestamp: 1 }, modified: 100 },
+ }));
+ mockFs.mockFile(LEGACY_BULK_METADATA_FILE, JSON.stringify({
+ 'session-B': { workspaceFolder: { folderPath: Uri.file('/b').fsPath, timestamp: 2 }, modified: 200 },
+ }));
+
+ const store = await createStore();
+
+ expect((await store.getSessionWorkspaceFolder('session-A'))?.fsPath).toBe(Uri.file('/a').fsPath);
+ expect((await store.getSessionWorkspaceFolder('session-B'))?.fsPath).toBe(Uri.file('/b').fsPath);
+ store.dispose();
+ });
+
+ it('uses last-modified-wins when the same session exists in both files', async () => {
+ vi.setSystemTime(new Date(0));
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
+ 'shared-session': { workspaceFolder: { folderPath: Uri.file('/old').fsPath, timestamp: 1 }, modified: 50 },
+ }));
+ mockFs.mockFile(LEGACY_BULK_METADATA_FILE, JSON.stringify({
+ 'shared-session': { workspaceFolder: { folderPath: Uri.file('/newer').fsPath, timestamp: 2 }, modified: 200 },
+ }));
+
+ const store = await createStore();
+
+ // Legacy had higher `modified` → its version wins
+ expect((await store.getSessionWorkspaceFolder('shared-session'))?.fsPath).toBe(Uri.file('/newer').fsPath);
+ store.dispose();
+ });
+
+ it('sets memento flag after successful merge so it does not re-run', async () => {
+ mockFs.mockFile(LEGACY_BULK_METADATA_FILE, JSON.stringify({
+ 'legacy-session': { workspaceFolder: { folderPath: Uri.file('/legacy').fsPath, timestamp: 1 } },
+ }));
+
+ const store = await createStore();
+ expect(extensionContext.globalState.get('github.copilot.cli.legacyBulkMigrated')).toBe(true);
+ store.dispose();
+ });
+
+ it('skips migration when memento flag is already set', async () => {
+ extensionContext.globalState.seed('github.copilot.cli.legacyBulkMigrated', true);
+ mockFs.mockFile(LEGACY_BULK_METADATA_FILE, JSON.stringify({
+ 'legacy-session': { workspaceFolder: { folderPath: Uri.file('/legacy').fsPath, timestamp: 1 } },
+ }));
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+
+ const readSpy = vi.spyOn(mockFs, 'readFile');
+ const store = await createStore();
+
+ // Legacy file should not have been read (migration skipped).
+ const legacyReads = readSpy.mock.calls.filter(
+ c => c[0].toString().includes('copilotcli.session.metadata.json'),
+ );
+ expect(legacyReads).toHaveLength(0);
+ expect(await store.getSessionWorkspaceFolder('legacy-session')).toBeUndefined();
+ store.dispose();
+ });
+
+ it('does nothing when no legacy file exists', async () => {
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
+ 'kept-session': { workspaceFolder: { folderPath: Uri.file('/kept').fsPath, timestamp: 1 } },
+ }));
+ // No legacy file seeded
+
+ const store = await createStore();
+
+ expect((await store.getSessionWorkspaceFolder('kept-session'))?.fsPath).toBe(Uri.file('/kept').fsPath);
+ store.dispose();
+ });
+ });
+
+ describe('JSONL worktree index', () => {
+ const jsonlUri = () => Uri.file(jsonlPathHolder.get());
+
+ async function readJsonl(): Promise>> {
+ try {
+ const bytes = await mockFs.readFile(jsonlUri());
+ const raw = new TextDecoder().decode(bytes);
+ return raw.split('\n').filter(Boolean).map(l => JSON.parse(l));
+ } catch {
+ return [];
+ }
+ }
+
+ it('appends one line per worktree session and reads it back via getSessionIdForWorktree', async () => {
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+ const store = await createStore();
+
+ const folder = Uri.file('/repo/.worktrees/wt-A');
+ await store.storeWorktreeInfo('wt-session-A', makeWorktreeV1Props({ worktreePath: folder.fsPath }));
+
+ const lines = await readJsonl();
+ expect(lines).toHaveLength(1);
+ expect(lines[0]).toMatchObject({ id: 'wt-session-A', path: folder.fsPath });
+
+ // Lookup by folder works via the in-memory map.
+ expect(await store.getSessionIdForWorktree(folder)).toBe('wt-session-A');
+ store.dispose();
+ });
+
+ it('falls back to JSONL on disk for getSessionIdForWorktree when in-memory cache is cold', async () => {
+ // Pre-seed JSONL in mock fs before the store starts — simulates an entry written by another process.
+ const folder = Uri.file('/repo/.worktrees/wt-cold');
+ mockFs.mockFile(
+ jsonlUri(),
+ JSON.stringify({ id: 'wt-session-cold', path: folder.fsPath, created: 100 }) + '\n',
+ );
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+
+ const store = await createStore();
+ expect(await store.getSessionIdForWorktree(folder)).toBe('wt-session-cold');
+ store.dispose();
+ });
+
+ it('compacts duplicate JSONL lines for the same id on next rewrite', async () => {
+ // Two entries for the same id — last write wins, file should be rewritten.
+ const folder = Uri.file('/repo/.worktrees/dup');
+ mockFs.mockFile(
+ jsonlUri(),
+ JSON.stringify({ id: 'dup-id', path: '/old/path', created: 1 }) + '\n' +
+ JSON.stringify({ id: 'dup-id', path: folder.fsPath, created: 2 }) + '\n',
+ );
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+
+ const store = await createStore();
+ // Initialization should have detected the duplicate and rewritten the file.
+ const lines = await readJsonl();
+ expect(lines).toHaveLength(1);
+ expect(lines[0]).toMatchObject({ id: 'dup-id', path: folder.fsPath });
+ expect(await store.getSessionIdForWorktree(folder)).toBe('dup-id');
+ store.dispose();
+ });
+
+ it('removes the JSONL entry when a session is deleted', async () => {
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+ const store = await createStore();
+ const folder = Uri.file('/repo/.worktrees/to-delete');
+ await store.storeWorktreeInfo('to-delete', makeWorktreeV1Props({ worktreePath: folder.fsPath }));
+ expect(await readJsonl()).toHaveLength(1);
+
+ await store.deleteSessionMetadata('to-delete');
+
+ expect(await readJsonl()).toHaveLength(0);
+ expect(await store.getSessionIdForWorktree(folder)).toBeUndefined();
+ store.dispose();
+ });
+ });
+
+ describe('top-N trim (MAX_BULK_STORAGE_ENTRIES = 1000)', () => {
+ it('writes at most 1000 entries to the bulk file but keeps everything in memory', async () => {
+ // Pre-seed a bulk file with 1100 entries with varying `modified` timestamps.
+ const initial: Record = {};
+ for (let i = 0; i < 1100; i++) {
+ initial[`s-${i}`] = {
+ workspaceFolder: { folderPath: `/w/${i}`, timestamp: i },
+ modified: i, // s-0 oldest, s-1099 newest
+ };
+ }
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify(initial));
+
+ const store = await createStore();
+ // Trigger a write so the trim runs.
+ await store.storeWorkspaceFolderInfo('trigger', { folderPath: '/trigger', timestamp: Date.now() });
+ await vi.advanceTimersByTimeAsync(2000);
+
+ const onDisk = JSON.parse(new TextDecoder().decode(await mockFs.readFile(BULK_METADATA_FILE)));
+ expect(Object.keys(onDisk).length).toBeLessThanOrEqual(1000);
+ // Newest entries (highest `modified`) should still be present.
+ expect(onDisk['s-1099']).toBeTruthy();
+ expect(onDisk['trigger']).toBeTruthy();
+ // Oldest entry should have been evicted from disk.
+ expect(onDisk['s-0']).toBeUndefined();
+
+ // In-memory cache still serves the evicted entry's data via per-session file fallback,
+ // but the cache itself was hydrated at init so it still knows about s-0.
+ // (Per-session files are the source of truth for evicted entries.)
+ store.dispose();
+ });
+ });
+
+ describe('updateMetadataFields - stale cache write safety (Step 3b)', () => {
+ it('writes only the requested partial fields; other fields written by external process are preserved', async () => {
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+ const store = await createStore();
+
+ // Create the per-session file with an initial customTitle.
+ await store.setCustomTitle('shared-session', 'initial-title');
+ await vi.advanceTimersByTimeAsync(2000);
+
+ // Simulate another process writing a `firstUserMessage` to the same per-session file.
+ const sessionFile = sessionMetadataFileUri('shared-session');
+ const existingRaw = await mockFs.readFile(sessionFile);
+ const existing = JSON.parse(new TextDecoder().decode(existingRaw));
+ const externallyMerged = { ...existing, firstUserMessage: 'from-other-process' };
+ await mockFs.writeFile(sessionFile, new TextEncoder().encode(JSON.stringify(externallyMerged)));
+
+ // Now update the title from THIS process. Critically, the partial-only write
+ // must not stomp `firstUserMessage` even though our `_cache` does not know about it.
+ await store.setCustomTitle('shared-session', 'updated-title');
+ await vi.advanceTimersByTimeAsync(2000);
+
+ const finalRaw = await mockFs.readFile(sessionFile);
+ const final = JSON.parse(new TextDecoder().decode(finalRaw));
+ expect(final.customTitle).toBe('updated-title');
+ expect(final.firstUserMessage).toBe('from-other-process');
+ store.dispose();
+ });
+ });
+
+ describe('timestamps', () => {
+ it('stamps `created` once and bumps `modified` on every write', async () => {
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+ vi.setSystemTime(new Date(1_000_000));
+ const store = await createStore();
+
+ await store.setCustomTitle('ts-session', 'first');
+ await vi.advanceTimersByTimeAsync(2000);
+
+ const file = sessionMetadataFileUri('ts-session');
+ const first = JSON.parse(new TextDecoder().decode(await mockFs.readFile(file)));
+ expect(first.created).toBeTypeOf('number');
+ expect(first.modified).toBeTypeOf('number');
+ const createdAt = first.created;
+
+ vi.setSystemTime(new Date(2_000_000));
+ await store.setCustomTitle('ts-session', 'second');
+ await vi.advanceTimersByTimeAsync(2000);
+
+ const second = JSON.parse(new TextDecoder().decode(await mockFs.readFile(file)));
+ expect(second.created).toBe(createdAt); // unchanged
+ expect(second.modified).toBeGreaterThan(first.modified); // bumped
+ store.dispose();
+ });
+ });
+
+ describe('session-state directory scan', () => {
+ const sessionStateDir = Uri.file('/mock/session-state');
+
+ it('discovers worktree sessions from per-session files not in cache or JSONL', async () => {
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+ // Simulate a per-session file on disk that is NOT in the bulk cache.
+ const folder = Uri.file('/repo/.worktrees/discovered');
+ await mockFs.createDirectory(Uri.joinPath(sessionStateDir, 'orphan-session'));
+ mockFs.mockFile(
+ sessionMetadataFileUri('orphan-session'),
+ JSON.stringify({ worktreeProperties: makeWorktreeV1Props({ worktreePath: folder.fsPath }) }),
+ );
+ // readDirectory returns the session dir entries.
+ mockFs.mockDirectory(sessionStateDir, [['orphan-session', 2 /* Directory */]]);
+
+ const store = await createStore();
+
+ expect(await store.getSessionIdForWorktree(folder)).toBe('orphan-session');
+ store.dispose();
+ });
+
+ it('skips session IDs already known from the bulk cache', async () => {
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
+ 'known-session': { workspaceFolder: { folderPath: Uri.file('/known').fsPath, timestamp: 1 } },
+ }));
+ mockFs.mockDirectory(sessionStateDir, [['known-session', 2]]);
+
+ const readSpy = vi.spyOn(mockFs, 'readFile');
+ const store = await createStore();
+
+ // Per-session file for known-session should NOT have been read by the scan.
+ const scanReads = readSpy.mock.calls.filter(
+ c => c[0].toString().includes('/mock/session-state/known-session/vscode.metadata.json'),
+ );
+ expect(scanReads).toHaveLength(0);
+ store.dispose();
+ });
+
+ it('sets memento flag so the scan does not re-run on next startup', async () => {
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+ mockFs.mockDirectory(sessionStateDir, []);
+
+ await createStore();
+ expect(extensionContext.globalState.get('github.copilot.cli.events.jsonl.scaned')).toBe(true);
+ });
+
+ it('skips scan when memento flag is already set', async () => {
+ extensionContext.globalState.seed('github.copilot.cli.events.jsonl.scaned', true);
+ mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
+ // Even with a discoverable session, scan should be skipped.
+ await mockFs.createDirectory(Uri.joinPath(sessionStateDir, 'should-skip'));
+ mockFs.mockFile(
+ sessionMetadataFileUri('should-skip'),
+ JSON.stringify({ worktreeProperties: makeWorktreeV1Props() }),
+ );
+ mockFs.mockDirectory(sessionStateDir, [['should-skip', 2]]);
+
+ const store = await createStore();
+
+ expect(await store.getSessionIdForWorktree(Uri.file(makeWorktreeV1Props().worktreePath))).toBeUndefined();
+ store.dispose();
+ });
+ });
+
});
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts
index 180746f260c..2bd2a3126ab 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts
@@ -8,6 +8,7 @@ import * as path from 'path';
import type * as vscode from 'vscode';
// eslint-disable-next-line no-duplicate-imports
import * as vscodeShim from 'vscode';
+import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';
import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService';
import { ITestingServicesAccessor } from '../../../../platform/test/node/services';
@@ -202,15 +203,38 @@ function buildInputStateGroups(options?: { permissionMode?: string; folderPath?:
return groups;
}
+/**
+ * Workspace service whose folder list can be mutated at runtime so tests can
+ * exercise folder-change events through the observable pipeline.
+ */
+class MutableWorkspaceService extends TestWorkspaceService {
+ private _folders: URI[];
+
+ constructor(folders: URI[]) {
+ super(folders);
+ this._folders = [...folders];
+ }
+
+ override getWorkspaceFolders(): URI[] {
+ return this._folders;
+ }
+
+ setFolders(folders: URI[]): void {
+ this._folders = [...folders];
+ this.didChangeWorkspaceFoldersEmitter.fire({ added: [], removed: [] } as any);
+ }
+}
+
function createProviderWithServices(
store: DisposableStore,
workspaceFolders: URI[],
mocks: ReturnType,
agentManager?: ClaudeAgentManager,
+ workspaceServiceOverride?: TestWorkspaceService,
): { provider: ClaudeChatSessionContentProvider; accessor: ITestingServicesAccessor } {
const serviceCollection = store.add(createExtensionUnitTestingServices(store));
- const workspaceService = new TestWorkspaceService(workspaceFolders);
+ const workspaceService = workspaceServiceOverride ?? new TestWorkspaceService(workspaceFolders);
serviceCollection.set(IWorkspaceService, workspaceService);
serviceCollection.set(IGitService, new MockGitService());
@@ -965,6 +989,190 @@ describe('ChatSessionContentProvider', () => {
});
// #endregion
+
+ // #region Observable pipeline reactivity
+
+ /**
+ * These tests drive the input-state observable pipeline end-to-end via the
+ * external signals it observes (config change, workspace folder change,
+ * session-state change, session start) and assert the resulting
+ * `state.groups` reflect each event. This is the "series of events" testing
+ * the observable refactor was designed to enable.
+ */
+ describe('observable pipeline reactivity', () => {
+ const folderA = URI.file('/project-a');
+ const folderB = URI.file('/project-b');
+
+ async function flushMicrotasks(): Promise {
+ // Autoruns that schedule async work (e.g. MRU fetch when workspace goes empty)
+ // settle on the microtask queue. Two ticks covers chained thenables.
+ await Promise.resolve();
+ await Promise.resolve();
+ }
+
+ it('toggling bypass-permissions config adds/removes the bypass item reactively', async () => {
+ const mocks = createDefaultMocks();
+ const { accessor: localAccessor } = createProviderWithServices(store, [folderA, folderB], mocks);
+ const configService = localAccessor.get(IConfigurationService);
+
+ const state = await getInputState();
+ let permissionGroup = getGroup(state, 'permissionMode')!;
+ expect(permissionGroup.items.map(i => i.id)).not.toContain('bypassPermissions');
+
+ await configService.setConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions, true);
+ permissionGroup = getGroup(state, 'permissionMode')!;
+ expect(permissionGroup.items.map(i => i.id)).toContain('bypassPermissions');
+
+ await configService.setConfig(ConfigKey.ClaudeAgentAllowDangerouslySkipPermissions, false);
+ permissionGroup = getGroup(state, 'permissionMode')!;
+ expect(permissionGroup.items.map(i => i.id)).not.toContain('bypassPermissions');
+ });
+
+ it('workspace folder changes reshape the folder group', async () => {
+ const mocks = createDefaultMocks();
+ const mutableWs = new MutableWorkspaceService([folderA, folderB]);
+ createProviderWithServices(store, [], mocks, undefined, mutableWs);
+
+ const state = await getInputState();
+ let folderGroup = getGroup(state, 'folder');
+ expect(folderGroup).toBeDefined();
+ expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]);
+
+ // Add a third folder
+ const folderC = URI.file('/project-c');
+ mutableWs.setFolders([folderA, folderB, folderC]);
+ folderGroup = getGroup(state, 'folder');
+ expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath, folderC.fsPath]);
+
+ // Transition to a single folder → group hides
+ mutableWs.setFolders([folderA]);
+ folderGroup = getGroup(state, 'folder');
+ expect(folderGroup).toBeUndefined();
+
+ // Back to multi-root
+ mutableWs.setFolders([folderA, folderB]);
+ folderGroup = getGroup(state, 'folder');
+ expect(folderGroup!.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]);
+ });
+
+ it('emptying the workspace falls back to MRU items', async () => {
+ const mocks = createDefaultMocks();
+ const mutableWs = new MutableWorkspaceService([folderA, folderB]);
+ const mruFolder = URI.file('/recent/project');
+ mocks.mockFolderMruService.setMRUEntries([
+ { folder: mruFolder, repository: undefined, lastAccessed: Date.now() },
+ ]);
+ createProviderWithServices(store, [], mocks, undefined, mutableWs);
+
+ const state = await getInputState();
+ mutableWs.setFolders([]);
+ await flushMicrotasks();
+
+ const folderGroup = getGroup(state, 'folder');
+ expect(folderGroup).toBeDefined();
+ expect(folderGroup!.items.map(i => i.id)).toEqual([mruFolder.fsPath]);
+ });
+
+ it('external session-state permission change syncs into the input state', async () => {
+ const mocks = createDefaultMocks();
+ const { accessor: localAccessor } = createProviderWithServices(store, [workspaceFolderUri], mocks);
+ const sessionStateService = localAccessor.get(IClaudeSessionStateService);
+
+ // Mark as existing so the pipeline wires up the external permission autorun
+ const existingSession = { id: 'external-session', messages: [], subagents: [] };
+ vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue(existingSession as any);
+
+ const sessionUri = createClaudeSessionUri('external-session');
+ const state = await getInputState(sessionUri);
+ expect(getGroup(state, 'permissionMode')!.selected?.id).not.toBe('plan');
+
+ sessionStateService.setPermissionModeForSession('external-session', 'plan');
+ expect(getGroup(state, 'permissionMode')!.selected?.id).toBe('plan');
+
+ sessionStateService.setPermissionModeForSession('external-session', 'default');
+ expect(getGroup(state, 'permissionMode')!.selected?.id).toBe('default');
+ });
+
+ it('markSessionStarted locks the folder group mid-session', async () => {
+ const mocks = createDefaultMocks();
+ createProviderWithServices(store, [folderA, folderB], mocks);
+
+ const state = await getInputState();
+ let folderGroup = getGroup(state, 'folder')!;
+ expect(folderGroup.items.every(i => !i.locked)).toBe(true);
+ expect(folderGroup.selected?.locked).toBeUndefined();
+
+ // Simulate a new session starting by invoking the handler (which calls markSessionStarted)
+ // The handler is owned by the content provider — we go through it via createHandler.
+ // Easier: reach through via the exported accessor pattern — call markSessionStarted through the controller.
+ // The content provider does not export the controller, but the handler path covers it.
+ vi.mocked(mocks.mockSessionService.getSession).mockResolvedValue(undefined);
+ seedSessionItem('new-session');
+
+ const { provider: handlerProvider } = createProviderWithServices(store, [folderA, folderB], mocks);
+ const handler = handlerProvider.createHandler();
+ // The state we want to observe must be the one passed into the handler
+ const newState = await getInputState();
+ const context: vscode.ChatContext = {
+ history: [],
+ yieldRequested: false,
+ chatSessionContext: {
+ isUntitled: false,
+ chatSessionItem: {
+ resource: ClaudeSessionUri.forSessionId('new-session'),
+ label: 'New',
+ },
+ inputState: newState,
+ },
+ } as vscode.ChatContext;
+ await handler(createTestRequest('hello'), context, new MockChatResponseStream(), CancellationToken.None);
+
+ folderGroup = getGroup(newState, 'folder')!;
+ expect(folderGroup.items.every(i => i.locked === true)).toBe(true);
+ expect(folderGroup.selected?.locked).toBe(true);
+ });
+
+ it('restoring a locked previousInputState preserves the lock across workspace changes', async () => {
+ const mocks = createDefaultMocks();
+ const mutableWs = new MutableWorkspaceService([folderA, folderB]);
+ createProviderWithServices(store, [], mocks, undefined, mutableWs);
+
+ // First state — mark it as started to get locked items
+ const initialState = await getInputState();
+ const initialGroup = getGroup(initialState, 'folder')!;
+ // Synthesize a locked previousInputState (matching what a started session looks like)
+ const lockedGroups: vscode.ChatSessionProviderOptionGroup[] = initialState.groups.map(g =>
+ g.id === 'folder'
+ ? {
+ ...g,
+ items: g.items.map(i => ({ ...i, locked: true })),
+ selected: g.selected ? { ...g.selected, locked: true } : undefined,
+ }
+ : g
+ );
+ const lockedPrevious: vscode.ChatSessionInputState = {
+ groups: lockedGroups,
+ sessionResource: undefined,
+ onDidChange: Event.None,
+ };
+ // sanity check
+ expect(initialGroup.items.map(i => i.id)).toEqual([folderA.fsPath, folderB.fsPath]);
+
+ // Restore from the locked previous state
+ const restoredState = await getInputState(undefined, lockedPrevious);
+ let restoredGroup = getGroup(restoredState, 'folder')!;
+ expect(restoredGroup.items.every(i => i.locked === true)).toBe(true);
+
+ // Now workspace folders change — lock must persist
+ const folderC = URI.file('/project-c');
+ mutableWs.setFolders([folderA, folderB, folderC]);
+ restoredGroup = getGroup(restoredState, 'folder')!;
+ expect(restoredGroup.items).toHaveLength(3);
+ expect(restoredGroup.items.every(i => i.locked === true)).toBe(true);
+ });
+ });
+
+ // #endregion
});
// #region FakeGitService
@@ -994,6 +1202,7 @@ class FakeGitService extends mock() {
}
override dispose(): void {
+ super.dispose();
this._onDidOpenRepository.dispose();
this._onDidCloseRepository.dispose();
}
@@ -1006,6 +1215,7 @@ describe('ClaudeChatSessionItemController', () => {
let mockSessionService: IClaudeCodeSessionService;
let mockSdkService: IClaudeCodeSdkService;
let controller: ClaudeChatSessionItemController;
+ let lastControllerAccessor: ITestingServicesAccessor;
function getItem(sessionId: string): vscode.ChatSessionItem | undefined {
return lastCreatedItemsMap.get(ClaudeSessionUri.forSessionId(sessionId).toString());
@@ -1031,6 +1241,7 @@ describe('ClaudeChatSessionItemController', () => {
};
serviceCollection.define(IClaudeCodeSdkService, mockSdkService);
const accessor = serviceCollection.createTestingAccessor();
+ lastControllerAccessor = accessor;
const ctrl = accessor.get(IInstantiationService).createInstance(ClaudeChatSessionItemController);
store.add(ctrl);
return ctrl;
@@ -1198,6 +1409,30 @@ describe('ClaudeChatSessionItemController', () => {
// timing.created is derived from created
expect(item!.timing!.created).toBe(new Date('2024-06-01T12:00:00Z').getTime());
});
+
+ it('sets metadata with workingDirectoryPath when session has cwd', async () => {
+ const diskSession: IClaudeCodeSessionInfo = {
+ id: 'cwd-session',
+ label: 'CWD Session',
+ created: Date.now(),
+ lastRequestEnded: Date.now(),
+ folderName: 'my-project',
+ cwd: '/home/user/my-project',
+ };
+ vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any);
+
+ await controller.updateItemStatus('cwd-session', ChatSessionStatus.InProgress, 'Prompt');
+
+ const item = getItem('cwd-session');
+ expect(item!.metadata).toEqual({ workingDirectoryPath: '/home/user/my-project' });
+ });
+
+ it('does not set metadata when session has no cwd', async () => {
+ await controller.updateItemStatus('no-cwd-session', ChatSessionStatus.InProgress, 'Prompt');
+
+ const item = getItem('no-cwd-session');
+ expect(item!.metadata).toBeUndefined();
+ });
});
// #endregion
@@ -1504,12 +1739,24 @@ describe('ClaudeChatSessionItemController', () => {
label: 'Original',
});
- const result = await lastForkHandler!(sessionResource, undefined, CancellationToken.None);
+ // Seed the parent session with non-default state
+ const sessionStateService = lastControllerAccessor.get(IClaudeSessionStateService);
+ sessionStateService.setPermissionModeForSession('sess-1', 'plan');
+ sessionStateService.setFolderInfoForSession('sess-1', {
+ cwd: '/custom/folder',
+ additionalDirectories: ['/extra'],
+ });
- // The forked item should be properly structured
- expect(result.resource.toString()).toContain('forked-session-id');
- expect(result.iconPath).toBeDefined();
- expect(result.timing).toBeDefined();
+ const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession');
+ const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession');
+
+ await lastForkHandler!(sessionResource, undefined, CancellationToken.None);
+
+ expect(setPermissionSpy).toHaveBeenCalledWith('forked-session-id', 'plan');
+ expect(setFolderInfoSpy).toHaveBeenCalledWith('forked-session-id', {
+ cwd: '/custom/folder',
+ additionalDirectories: ['/extra'],
+ });
});
it('forks at the message before the specified request', async () => {
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeSessionOptionBuilder.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeSessionOptionBuilder.spec.ts
index 8621db5e8ab..a163f78a0e5 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeSessionOptionBuilder.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeSessionOptionBuilder.spec.ts
@@ -8,7 +8,6 @@ import type * as vscode from 'vscode';
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspaceService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
-import { Event } from '../../../../util/vs/base/common/event';
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
import { URI } from '../../../../util/vs/base/common/uri';
import { createExtensionUnitTestingServices } from '../../../test/node/services';
@@ -76,7 +75,7 @@ describe('ClaudeSessionOptionBuilder', () => {
it('returns undefined for single-root workspace', async () => {
builder = createBuilder([URI.file('/project')]);
- const group = await builder.buildNewFolderGroup(undefined);
+ const group = await builder.buildNewFolderGroup();
expect(group).toBeUndefined();
});
@@ -86,7 +85,7 @@ describe('ClaudeSessionOptionBuilder', () => {
const folderB = URI.file('/b');
builder = createBuilder([folderA, folderB]);
- const group = await builder.buildNewFolderGroup(undefined);
+ const group = await builder.buildNewFolderGroup();
expect(group).toBeDefined();
expect(group!.id).toBe('folder');
@@ -101,33 +100,11 @@ describe('ClaudeSessionOptionBuilder', () => {
{ folder: mruFolder, repository: undefined, lastAccessed: Date.now() },
]);
- const group = await builder.buildNewFolderGroup(undefined);
+ const group = await builder.buildNewFolderGroup();
expect(group).toBeDefined();
expect(group!.items[0].id).toBe(mruFolder.fsPath);
});
-
- it('restores previous folder selection', async () => {
- const folderA = URI.file('/a');
- const folderB = URI.file('/b');
- builder = createBuilder([folderA, folderB]);
-
- const previousInputState = {
- groups: [{
- id: 'folder',
- name: 'Folder',
- description: 'Pick Folder',
- items: [{ id: folderB.fsPath, name: 'b' }],
- selected: { id: folderB.fsPath, name: 'b' },
- }],
- sessionResource: undefined,
- onDidChange: Event.None,
- } as vscode.ChatSessionInputState;
-
- const group = await builder.buildNewFolderGroup(previousInputState);
-
- expect(group!.selected?.id).toBe(folderB.fsPath);
- });
});
describe('buildExistingFolderGroup', () => {
@@ -147,7 +124,7 @@ describe('ClaudeSessionOptionBuilder', () => {
it('includes permission mode group with default selection', async () => {
builder = createBuilder([URI.file('/project')]);
- const groups = await builder.buildNewSessionGroups(undefined);
+ const groups = await builder.buildNewSessionGroups();
const permGroup = groups.find(g => g.id === 'permissionMode');
expect(permGroup).toBeDefined();
@@ -157,7 +134,7 @@ describe('ClaudeSessionOptionBuilder', () => {
it('excludes folder group for single-root workspace', async () => {
builder = createBuilder([URI.file('/project')]);
- const groups = await builder.buildNewSessionGroups(undefined);
+ const groups = await builder.buildNewSessionGroups();
expect(groups.find(g => g.id === 'folder')).toBeUndefined();
});
@@ -165,7 +142,7 @@ describe('ClaudeSessionOptionBuilder', () => {
it('includes folder group for multi-root workspace', async () => {
builder = createBuilder([URI.file('/a'), URI.file('/b')]);
- const groups = await builder.buildNewSessionGroups(undefined);
+ const groups = await builder.buildNewSessionGroups();
expect(groups.find(g => g.id === 'folder')).toBeDefined();
});
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts
index 0279cc044e1..e190394cec3 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts
@@ -327,7 +327,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
}
});
sdk = {
- getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, createLocalFeatureFlagService: () => ({}), noopTelemetryBinder: {} })),
+ getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })),
getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'valid-token', host: 'https://github.com' })),
} as unknown as ICopilotCLISDK;
const services = disposables.add(createExtensionUnitTestingServices());
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts
index 507cdc2718f..28e14e0e2f3 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts
@@ -158,6 +158,7 @@ function createProvider() {
const metadataStore = new class extends mock() {
override getRequestDetails = vi.fn(async () => []);
override getRepositoryProperties = vi.fn(async () => undefined);
+ override getSessionParentId = vi.fn(async () => undefined);
};
const gitService = new TestGitService();
const folderRepositoryManager = new TestFolderRepositoryManager();
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLISDKUpgrade.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLISDKUpgrade.spec.ts
index a244f8f0f32..98cf628b661 100644
--- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLISDKUpgrade.spec.ts
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLISDKUpgrade.spec.ts
@@ -8,7 +8,6 @@ import { isBinaryFile } from 'isbinaryfile';
import * as path from 'path';
import { beforeAll, describe, it } from 'vitest';
import { TestLogService } from '../../../../platform/testing/common/testLogService';
-import { copyNodePtyFiles } from '../../copilotcli/node/nodePtyShim';
import { copyRipgrepShim } from '../../copilotcli/node/ripgrepShim';
describe('CopilotCLI SDK Upgrade', function () {
@@ -59,6 +58,16 @@ describe('CopilotCLI SDK Upgrade', function () {
// win32 native module (formerly win_error_mode)
path.join('prebuilds', 'win32-arm64', 'win32.node'),
path.join('prebuilds', 'win32-x64', 'win32.node'),
+ // Second copy of computer.node / win32.node re-shipped by the @github/copilot/sdk subpackage
+ // (previously hidden by a broad sdk/prebuilds/** exclusion that masked the node-pty files we used to shim in at test setup).
+ path.join('sdk', 'prebuilds', 'darwin-arm64', 'computer.node'),
+ path.join('sdk', 'prebuilds', 'darwin-x64', 'computer.node'),
+ path.join('sdk', 'prebuilds', 'linux-arm64', 'computer.node'),
+ path.join('sdk', 'prebuilds', 'linux-x64', 'computer.node'),
+ path.join('sdk', 'prebuilds', 'win32-arm64', 'computer.node'),
+ path.join('sdk', 'prebuilds', 'win32-x64', 'computer.node'),
+ path.join('sdk', 'prebuilds', 'win32-arm64', 'win32.node'),
+ path.join('sdk', 'prebuilds', 'win32-x64', 'win32.node'),
path.join('ripgrep', 'bin', 'darwin-arm64', 'rg'),
path.join('ripgrep', 'bin', 'darwin-x64', 'rg'),
path.join('ripgrep', 'bin', 'linux-x64', 'rg'),
@@ -89,15 +98,13 @@ describe('CopilotCLI SDK Upgrade', function () {
'tree-sitter-scala.wasm',
].map(p => path.join(copilotSDKPath, p)));
- // Exclude ripgrep files that we copy over in src/extension/agents/copilotcli/node/ripgrepShim.ts (until we get better API/solution from SDK)
+ // Exclude ripgrep files that we copy over in src/extension/chatSessions/copilotcli/node/ripgrepShim.ts (until we get better API/solution from SDK)
const ripgrepFilesWeCopy = path.join(copilotSDKPath, 'sdk', 'ripgrep', 'bin');
- // Exclude nodepty files that we copy over in src/extension/agents/copilotcli/node/nodePtyShim.ts (until we get better API/solution from SDK)
- const nodeptyFilesWeCopy = path.join(copilotSDKPath, 'sdk', 'prebuilds');
const errors: string[] = [];
// Look for new binaries
for (const binary of existingBinaries) {
- if (binary.startsWith(ripgrepFilesWeCopy) || binary.startsWith(nodeptyFilesWeCopy)) {
+ if (binary.startsWith(ripgrepFilesWeCopy)) {
continue;
}
const binaryName = path.basename(binary);
@@ -110,7 +117,7 @@ describe('CopilotCLI SDK Upgrade', function () {
}
// Look for removed binaries.
for (const binary of knownBinaries) {
- if (binary.startsWith(ripgrepFilesWeCopy) || binary.startsWith(nodeptyFilesWeCopy)) {
+ if (binary.startsWith(ripgrepFilesWeCopy)) {
continue;
}
if (!existingBinaries.has(binary)) {
@@ -124,19 +131,13 @@ describe('CopilotCLI SDK Upgrade', function () {
});
it('should be able to load the @github/copilot module without errors', async function () {
- await copyNodePtyFiles(
- extensionPath,
- path.join(copilotSDKPath, 'prebuilds', process.platform + '-' + process.arch),
- new TestLogService()
- );
await import('@github/copilot/sdk');
});
});
async function copyBinaries(extensionPath: string) {
- const nodePtyPrebuilds = path.join(extensionPath, 'node_modules', '@github', 'copilot', 'prebuilds', process.platform + '-' + process.arch);
- const vscodeRipgrepPath = path.join(extensionPath, 'node_modules', '@github', 'copilot', 'ripgrep', 'bin', process.platform + '-' + process.arch);
- await copyNodePtyFiles(extensionPath, nodePtyPrebuilds, new TestLogService());
+ const copilotSDKPath = path.join(extensionPath, 'node_modules', '@github', 'copilot');
+ const vscodeRipgrepPath = path.join(copilotSDKPath, 'ripgrep', 'bin', process.platform + '-' + process.arch);
await copyRipgrepShim(extensionPath, vscodeRipgrepPath, new TestLogService());
}
async function findAllBinaries(dir: string): Promise {
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/worktreeSessionIndex.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/worktreeSessionIndex.spec.ts
new file mode 100644
index 00000000000..c397ac52a18
--- /dev/null
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/worktreeSessionIndex.spec.ts
@@ -0,0 +1,190 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { Uri } from 'vscode';
+import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService';
+import { ILogService } from '../../../../platform/log/common/logService';
+import { mock } from '../../../../util/common/test/simpleMock';
+import { WorktreeSessionIndex } from '../worktreeSessionIndex';
+
+class MockLogService extends mock() {
+ override trace = vi.fn();
+ override info = vi.fn();
+ override warn = vi.fn();
+ override error = vi.fn();
+ override debug = vi.fn();
+}
+
+const JSONL_PATH = '/mock/copilot-home/worktree.jsonl';
+const JSONL_URI = Uri.file(JSONL_PATH);
+
+describe('WorktreeSessionIndex', () => {
+ let mockFs: MockFileSystemService;
+ let logService: MockLogService;
+
+ function createIndex(): WorktreeSessionIndex {
+ return new WorktreeSessionIndex(mockFs, logService, JSONL_PATH);
+ }
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // In-memory tests don't need mockFs/logService, but the constructor requires them.
+ describe('in-memory operations', () => {
+ it('adds and retrieves an entry by session id', () => {
+ mockFs = new MockFileSystemService();
+ logService = new MockLogService();
+ const index = createIndex();
+ index.addEntry({ id: 's1', path: '/a', created: 1 });
+
+ expect(index.getSessionEntry('s1')).toMatchObject({ id: 's1', path: '/a' });
+ expect(index.has('s1')).toBe(true);
+ expect(index.has('s2')).toBe(false);
+ });
+
+ it('looks up session id by folder Uri', () => {
+ mockFs = new MockFileSystemService();
+ logService = new MockLogService();
+ const index = createIndex();
+ index.addEntry({ id: 's1', path: '/a', created: 1 });
+
+ expect(index.getSessionIdForFolder(Uri.file('/a'))).toBe('s1');
+ expect(index.getSessionIdForFolder(Uri.file('/b'))).toBeUndefined();
+ });
+
+ it('deletes an entry and cleans up the folder mapping', () => {
+ mockFs = new MockFileSystemService();
+ logService = new MockLogService();
+ const index = createIndex();
+ index.addEntry({ id: 's1', path: '/a', created: 1 });
+ index.deleteEntry('s1');
+
+ expect(index.has('s1')).toBe(false);
+ expect(index.getSessionIdForFolder(Uri.file('/a'))).toBeUndefined();
+ });
+
+ it('clear() removes everything', () => {
+ mockFs = new MockFileSystemService();
+ logService = new MockLogService();
+ const index = createIndex();
+ index.addEntry({ id: 's1', path: '/a', created: 1 });
+ index.addEntry({ id: 's2', path: '/b', created: 2 });
+ index.clear();
+
+ expect(index.has('s1')).toBe(false);
+ expect(index.getEntries()).toHaveLength(0);
+ });
+
+ it('getEntries() returns all entries', () => {
+ mockFs = new MockFileSystemService();
+ logService = new MockLogService();
+ const index = createIndex();
+ index.addEntry({ id: 's1', path: '/a', created: 1 });
+ index.addEntry({ id: 's2', path: '/b', created: 2 });
+
+ const entries = index.getEntries();
+ expect(entries).toHaveLength(2);
+ expect(entries.map(e => e.id).sort()).toEqual(['s1', 's2']);
+ });
+
+ it('updating an entry with a new path removes the old path mapping', () => {
+ mockFs = new MockFileSystemService();
+ logService = new MockLogService();
+ const index = createIndex();
+ index.addEntry({ id: 's1', path: '/old', created: 1 });
+ expect(index.getSessionIdForFolder(Uri.file('/old'))).toBe('s1');
+
+ index.addEntry({ id: 's1', path: '/new', created: 1 });
+ expect(index.getSessionIdForFolder(Uri.file('/new'))).toBe('s1');
+ expect(index.getSessionIdForFolder(Uri.file('/old'))).toBeUndefined();
+ });
+ });
+
+ describe('JSONL persistence', () => {
+ it('loadFromDisk populates the index from a JSONL file', async () => {
+ mockFs = new MockFileSystemService();
+ logService = new MockLogService();
+ mockFs.mockFile(JSONL_URI,
+ JSON.stringify({ id: 's1', path: '/a', created: 1 }) + '\n' +
+ JSON.stringify({ id: 's2', path: '/b', created: 2 }) + '\n',
+ );
+ const index = createIndex();
+ await index.loadFromDisk();
+
+ expect(index.has('s1')).toBe(true);
+ expect(index.has('s2')).toBe(true);
+ expect(index.size).toBe(2);
+ });
+
+ it('loadFromDisk returns rewriteNeeded for duplicates', async () => {
+ mockFs = new MockFileSystemService();
+ logService = new MockLogService();
+ mockFs.mockFile(JSONL_URI,
+ JSON.stringify({ id: 's1', path: '/a', created: 1 }) + '\n' +
+ JSON.stringify({ id: 's1', path: '/b', created: 2 }) + '\n',
+ );
+ const index = createIndex();
+ const { rewriteNeeded } = await index.loadFromDisk();
+
+ expect(rewriteNeeded).toBe(true);
+ expect(index.size).toBe(1);
+ });
+
+ it('writeToDisk writes all entries to the JSONL file', async () => {
+ mockFs = new MockFileSystemService();
+ logService = new MockLogService();
+ const index = createIndex();
+ index.addEntry({ id: 's1', path: '/a', created: 1 });
+ index.addEntry({ id: 's2', path: '/b', created: 2 });
+ await index.writeToDisk();
+
+ const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI));
+ const lines = raw.split('\n').filter(Boolean);
+ expect(lines).toHaveLength(2);
+ });
+
+ it('appendBatchToDisk adds a single entry', async () => {
+ mockFs = new MockFileSystemService();
+ logService = new MockLogService();
+ const index = createIndex();
+ await index.appendBatchToDisk([{ id: 's1', path: '/a', created: 1 }]);
+
+ expect(index.has('s1')).toBe(true);
+ const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI));
+ expect(raw.split('\n').filter(Boolean)).toHaveLength(1);
+ });
+
+ it('appendBatchToDisk adds multiple entries in one write', async () => {
+ mockFs = new MockFileSystemService();
+ logService = new MockLogService();
+ const index = createIndex();
+ await index.appendBatchToDisk([
+ { id: 's1', path: '/a', created: 1 },
+ { id: 's2', path: '/b', created: 2 },
+ ]);
+
+ expect(index.has('s1')).toBe(true);
+ expect(index.has('s2')).toBe(true);
+ const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI));
+ expect(raw.split('\n').filter(Boolean)).toHaveLength(2);
+ });
+
+ it('removeAndWriteToDisk removes the entry and rewrites', async () => {
+ mockFs = new MockFileSystemService();
+ logService = new MockLogService();
+ const index = createIndex();
+ index.addEntry({ id: 's1', path: '/a', created: 1 });
+ index.addEntry({ id: 's2', path: '/b', created: 2 });
+ await index.removeAndWriteToDisk('s1');
+
+ expect(index.has('s1')).toBe(false);
+ const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI));
+ expect(raw.split('\n').filter(Boolean)).toHaveLength(1);
+ expect(raw).toContain('s2');
+ });
+ });
+});
diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/worktreeSessionIndex.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/worktreeSessionIndex.ts
new file mode 100644
index 00000000000..ca8586c639f
--- /dev/null
+++ b/extensions/copilot/src/extension/chatSessions/vscode-node/worktreeSessionIndex.ts
@@ -0,0 +1,221 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { Uri } from 'vscode';
+import { createDirectoryIfNotExists, IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
+import { ILogService } from '../../../platform/log/common/logService';
+import { Sequencer } from '../../../util/vs/base/common/async';
+import { ResourceMap } from '../../../util/vs/base/common/map';
+import { dirname } from '../../../util/vs/base/common/resources';
+import { WorktreeSessionEntry } from '../common/chatSessionMetadataStore';
+
+/**
+ * In-memory index that maps session ids to {@link WorktreeSessionEntry} and
+ * worktree folder URIs to session ids, with JSONL file persistence.
+ *
+ * When multiple sessions share the same folder, the first-registered session
+ * keeps the folder → session-id mapping.
+ *
+ * All file writes are serialized through an internal {@link Sequencer} so
+ * concurrent appends and rewrites cannot race.
+ */
+export class WorktreeSessionIndex {
+ /** Session id → entry. */
+ private readonly _byId = new Map();
+ /** Worktree folder URI → session id. Uses URI-aware comparison so path casing is handled correctly. */
+ private readonly _byFolder = new ResourceMap();
+ /** Serializes all JSONL file writes to prevent read-modify-write races. */
+ private readonly _writeSequencer = new Sequencer();
+ /** Timestamp of the last {@link loadFromDisk} call; used by {@link reloadIfStale}. */
+ private _lastLoadAt = 0;
+
+ constructor(
+ private readonly _fileSystemService: IFileSystemService,
+ private readonly _logService: ILogService,
+ private readonly _jsonlPath: string,
+ ) { }
+
+ getSessionEntry(sessionId: string): WorktreeSessionEntry | undefined {
+ return this._byId.get(sessionId);
+ }
+
+ getSessionIdForFolder(folder: Uri): string | undefined {
+ return this._byFolder.get(folder);
+ }
+
+ has(sessionId: string): boolean {
+ return this._byId.has(sessionId);
+ }
+
+ get size(): number {
+ return this._byId.size;
+ }
+
+ getAllSessionIds(): string[] {
+ return Array.from(this._byId.keys());
+ }
+
+ /**
+ * Adds or updates an entry. When the same folder path is already mapped to
+ * a different session, the existing mapping is preserved.
+ */
+ addEntry(entry: WorktreeSessionEntry): void {
+ const folderUri = Uri.file(entry.path);
+
+ // If this session already has an entry with a different path, clean up
+ // the old folder → session-id mapping before recording the new one.
+ const previousEntry = this._byId.get(entry.id);
+ if (previousEntry && previousEntry.path !== entry.path) {
+ const prevUri = Uri.file(previousEntry.path);
+ if (this._byFolder.get(prevUri) === entry.id) {
+ this._byFolder.delete(prevUri);
+ }
+ }
+
+ this._byId.set(entry.id, entry);
+
+ const existingIdForFolder = this._byFolder.get(folderUri);
+ if (!existingIdForFolder) {
+ this._byFolder.set(folderUri, entry.id);
+ return;
+ }
+ if (existingIdForFolder === entry.id) {
+ return;
+ }
+ const existingEntry = this._byId.get(existingIdForFolder);
+ if (existingEntry) {
+ return;
+ }
+ this._byFolder.set(folderUri, entry.id);
+ }
+
+ deleteEntry(sessionId: string): void {
+ const entry = this._byId.get(sessionId);
+ if (!entry) {
+ return;
+ }
+ this._byId.delete(sessionId);
+ const folderUri = Uri.file(entry.path);
+ if (this._byFolder.get(folderUri) === sessionId) {
+ this._byFolder.delete(folderUri);
+ for (const candidate of this._byId.values()) {
+ if (candidate.path === entry.path) {
+ this._byFolder.set(folderUri, candidate.id);
+ break;
+ }
+ }
+ }
+ }
+
+ clear(): void {
+ this._byId.clear();
+ this._byFolder.clear();
+ }
+
+ getEntries(): WorktreeSessionEntry[] {
+ return Array.from(this._byId.values());
+ }
+
+ /**
+ * Loads the JSONL worktree index from disk into the in-memory maps.
+ * Returns `rewriteNeeded` if the file contained malformed lines or
+ * duplicates that should be compacted via {@link writeToDisk}.
+ */
+ async loadFromDisk(): Promise<{ rewriteNeeded: boolean }> {
+ let rewriteNeeded = false;
+ let raw: string;
+ try {
+ const bytes = await this._fileSystemService.readFile(Uri.file(this._jsonlPath));
+ raw = new TextDecoder().decode(bytes);
+ } catch {
+ this._lastLoadAt = Date.now();
+ return { rewriteNeeded: false };
+ }
+ this.clear();
+ for (const line of raw.split('\n')) {
+ const trimmed = line.trim();
+ if (!trimmed) {
+ continue;
+ }
+ try {
+ const entry = JSON.parse(trimmed) as WorktreeSessionEntry;
+ if (!entry?.id || !entry.path) {
+ rewriteNeeded = true;
+ continue;
+ }
+ if (this._byId.has(entry.id)) {
+ rewriteNeeded = true;
+ }
+ this.addEntry(entry);
+ } catch {
+ rewriteNeeded = true;
+ }
+ }
+ this._lastLoadAt = Date.now();
+ return { rewriteNeeded };
+ }
+
+ /** Reloads from disk only if more than 1 second has passed since the last load. */
+ async reloadIfStale(): Promise {
+ if (Date.now() - this._lastLoadAt < 1000) {
+ return;
+ }
+ await this.loadFromDisk();
+ }
+
+ /** Writes the entire in-memory index to the JSONL file, replacing its contents. */
+ async writeToDisk(): Promise {
+ return this._writeSequencer.queue(async () => {
+ try {
+ const jsonlUri = Uri.file(this._jsonlPath);
+ await createDirectoryIfNotExists(this._fileSystemService, dirname(jsonlUri));
+ const lines = this._byId.size > 0
+ ? Array.from(this._byId.values()).map(e => JSON.stringify(e)).join('\n') + '\n'
+ : '';
+ await this._fileSystemService.writeFile(jsonlUri, new TextEncoder().encode(lines));
+ } catch (err) {
+ this._logService.error('[WorktreeSessionIndex] Failed to write JSONL: ', err);
+ }
+ });
+ }
+
+ /** Appends entries to the JSONL file and adds them to the in-memory index. */
+ async appendBatchToDisk(entries: WorktreeSessionEntry[]): Promise {
+ if (entries.length === 0) {
+ return;
+ }
+ return this._writeSequencer.queue(async () => {
+ try {
+ const jsonlUri = Uri.file(this._jsonlPath);
+ await createDirectoryIfNotExists(this._fileSystemService, dirname(jsonlUri));
+ let existing = '';
+ try {
+ existing = new TextDecoder().decode(await this._fileSystemService.readFile(jsonlUri));
+ } catch {
+ // File doesn't exist yet.
+ }
+ const suffix = entries.map(e => JSON.stringify(e)).join('\n') + '\n';
+ await this._fileSystemService.writeFile(
+ jsonlUri,
+ new TextEncoder().encode(existing + suffix),
+ );
+ for (const entry of entries) {
+ this.addEntry(entry);
+ }
+ } catch (err) {
+ this._logService.error('[WorktreeSessionIndex] Failed to bulk-append entries: ', err);
+ }
+ });
+ }
+
+ /** Removes an entry from the in-memory index and rewrites the JSONL file. */
+ async removeAndWriteToDisk(sessionId: string): Promise {
+ if (!this._byId.has(sessionId)) {
+ return;
+ }
+ this.deleteEntry(sessionId);
+ await this.writeToDisk();
+ }
+}
diff --git a/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts b/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts
index f5816ce9640..404adb2c5cb 100644
--- a/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts
+++ b/extensions/copilot/src/extension/chronicle/common/sessionIndexingPreference.ts
@@ -46,19 +46,9 @@ export class SessionIndexingPreference {
/**
* Check if cloud sync is enabled for a given repo.
* Returns true if cloudSync.enabled is true AND the repo is not excluded.
- * Check both new and old setting for backward compatibility.
*/
hasCloudConsent(repoNwo?: string): boolean {
- let cloudEnabled: boolean;
- if (this._configService.isConfigured(ConfigKey.Advanced.SessionSearchCloudSync)) {
- // New key explicitly set by user — authoritative
- cloudEnabled = this._configService.getConfig(ConfigKey.Advanced.SessionSearchCloudSync);
- } else {
- // Fall back to old internal key for existing users who haven't migrated yet
- cloudEnabled = this._configService.getConfig(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled);
- }
-
- if (!cloudEnabled) {
+ if (!this._configService.getConfig(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled)) {
return false;
}
diff --git a/extensions/copilot/src/extension/chronicle/common/standupPrompt.ts b/extensions/copilot/src/extension/chronicle/common/standupPrompt.ts
index f1cd693471d..eaf68d3a422 100644
--- a/extensions/copilot/src/extension/chronicle/common/standupPrompt.ts
+++ b/extensions/copilot/src/extension/chronicle/common/standupPrompt.ts
@@ -132,6 +132,7 @@ Standup for :
- Key files: list 2-3 most important files changed
- Tools used: mention key tools if visible (e.g., apply_patch, run_in_terminal, search)
- PR: [#123](link) — merged/closed (if applicable)
+ - Sessions: \`session-id-1\`, \`session-id-2\`
**🚧 In Progress**
@@ -139,6 +140,7 @@ Standup for :
- Summary of current work (1-2 sentences based on turn content)
- Key files: list 2-3 most important files being worked on
- PR: [#789](link) — draft/open (if applicable)
+ - Sessions: \`session-id\`
Formatting rules:
- Use the turn data (user messages AND assistant responses) to understand WHAT was done, not just that something happened
diff --git a/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts b/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts
index a3788ec5897..e5b07ca5751 100644
--- a/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts
+++ b/extensions/copilot/src/extension/chronicle/common/test/sessionIndexingPreference.spec.ts
@@ -9,25 +9,16 @@ import { SessionIndexingPreference } from '../sessionIndexingPreference';
function createMockConfigService(opts: {
localIndexEnabled?: boolean;
cloudSyncEnabled?: boolean;
- cloudSyncPublicEnabled?: boolean;
excludeRepositories?: string[];
} = {}) {
const configs: Record = {};
// Map by fullyQualifiedId
configs['github.copilot.chat.advanced.sessionSearch.localIndex.enabled'] = opts.localIndexEnabled ?? false;
configs['github.copilot.chat.advanced.sessionSearch.cloudSync.enabled'] = opts.cloudSyncEnabled ?? false;
- configs['github.copilot.chat.sessionSearch.cloudSync.enabled'] = opts.cloudSyncPublicEnabled ?? false;
configs['github.copilot.chat.advanced.sessionSearch.cloudSync.excludeRepositories'] = opts.excludeRepositories ?? [];
- // Track which keys are explicitly configured (set by the user)
- const configuredKeys = new Set();
- if (opts.cloudSyncPublicEnabled !== undefined) {
- configuredKeys.add('github.copilot.chat.sessionSearch.cloudSync.enabled');
- }
-
return {
getConfig: (key: { fullyQualifiedId: string }) => configs[key.fullyQualifiedId],
- isConfigured: (key: { fullyQualifiedId: string }) => configuredKeys.has(key.fullyQualifiedId),
} as unknown as import('../../../../platform/configuration/common/configurationService').IConfigurationService;
}
@@ -90,19 +81,4 @@ describe('SessionIndexingPreference', () => {
expect(pref.hasCloudConsent('private-org/repo-b')).toBe(false);
expect(pref.hasCloudConsent('public-org/repo-a')).toBe(true);
});
-
- it('hasCloudConsent uses new public key when explicitly configured', () => {
- const pref = new SessionIndexingPreference(createMockConfigService({
- cloudSyncPublicEnabled: true,
- }));
- expect(pref.hasCloudConsent()).toBe(true);
- });
-
- it('hasCloudConsent new public key overrides old internal key', () => {
- const pref = new SessionIndexingPreference(createMockConfigService({
- cloudSyncEnabled: true,
- cloudSyncPublicEnabled: false,
- }));
- expect(pref.hasCloudConsent()).toBe(false);
- });
});
diff --git a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts
index 1779504dae2..c469e4e8d0f 100644
--- a/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts
+++ b/extensions/copilot/src/extension/chronicle/vscode-node/remoteSessionExporter.ts
@@ -120,16 +120,12 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr
// Only set up span listener when both local index and cloud sync are enabled.
// Uses autorun to react if settings change at runtime.
- // Both new and old settings taken into account for backward compatibility
const localEnabled = this._configService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService);
- const cloudEnabledInternal = this._configService.getConfigObservable(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled);
- const cloudEnabledPublic = this._configService.getConfigObservable(ConfigKey.Advanced.SessionSearchCloudSync);
+ const cloudEnabled = this._configService.getConfigObservable(ConfigKey.TeamInternal.SessionSearchCloudSyncEnabled);
const spanListenerStore = this._register(new DisposableStore());
this._register(autorun(reader => {
spanListenerStore.clear();
- const publicValue = cloudEnabledPublic.read(reader);
- const cloudEnabled = this._configService.isConfigured(ConfigKey.Advanced.SessionSearchCloudSync) ? publicValue : cloudEnabledInternal.read(reader);
- if (!localEnabled.read(reader) || !cloudEnabled) {
+ if (!localEnabled.read(reader) || !cloudEnabled.read(reader)) {
return;
}
@@ -268,10 +264,9 @@ export class RemoteSessionExporter extends Disposable implements IExtensionContr
}
// Only export remotely if the user has cloud consent for this repo
- // Also require localIndex to be enabled (team-internal gate) as defense-in-depth
const repoNwo = `${repo.owner}/${repo.repo}`;
- if (!this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.SessionSearchLocalIndexEnabled, this._expService) || !this._indexingPreference.hasCloudConsent(repoNwo)) {
+ if (!this._indexingPreference.hasCloudConsent(repoNwo)) {
this._disabledSessions.add(sessionId);
return;
}
diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/codeReferencing/index.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/codeReferencing/index.ts
index 18464815c07..fc61f1fffd6 100644
--- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/codeReferencing/index.ts
+++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/codeReferencing/index.ts
@@ -21,7 +21,7 @@ export class CodeReference implements IDisposable {
constructor(
@IInstantiationService private readonly _instantiationService: IInstantiationService,
- @ICompletionsRuntimeModeService readonly _runtimeMode: ICompletionsRuntimeModeService,
+ @ICompletionsRuntimeModeService private readonly _runtimeMode: ICompletionsRuntimeModeService,
@ICompletionsLogTargetService private readonly _logTarget: ICompletionsLogTargetService,
@IAuthenticationService private readonly _authenticationService: IAuthenticationService,
) { }
diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/statusBar.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/statusBar.ts
index b936070cf03..aaf849637bc 100644
--- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/statusBar.ts
+++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/statusBar.ts
@@ -21,8 +21,8 @@ export class CopilotStatusBar extends StatusReporter implements IDisposable {
constructor(
id: string,
- @ICompletionsExtensionStatus readonly extensionStatusService: ICompletionsExtensionStatus,
- @IInstantiationService readonly instantiationService: IInstantiationService,
+ @ICompletionsExtensionStatus private readonly extensionStatusService: ICompletionsExtensionStatus,
+ @IInstantiationService private readonly instantiationService: IInstantiationService,
) {
super();
diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/experiments/featuresService.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/experiments/featuresService.ts
index 62dc7c95b38..f15af99a66c 100644
--- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/experiments/featuresService.ts
+++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/experiments/featuresService.ts
@@ -17,7 +17,7 @@ export type ContextProviderExpSettings = {
excludeRelatedFiles: boolean;
timeBudget: number;
params?: Record;
-}
+};
export const ICompletionsFeaturesService = createServiceIdentifier('ICompletionsFeaturesService');
export interface ICompletionsFeaturesService {
diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/prompt/similarFiles/openTabFiles.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/prompt/similarFiles/openTabFiles.ts
index a9dc51ffc7e..b81b6b62392 100644
--- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/prompt/similarFiles/openTabFiles.ts
+++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/prompt/similarFiles/openTabFiles.ts
@@ -15,7 +15,9 @@ import {
} from './neighborFiles';
export class OpenTabFiles implements INeighborSource {
- constructor(@ICompletionsTextDocumentManagerService readonly docManager: ICompletionsTextDocumentManagerService) { }
+ constructor(
+ @ICompletionsTextDocumentManagerService private readonly docManager: ICompletionsTextDocumentManagerService
+ ) { }
private truncateDocs(
docs: readonly TextDocumentContents[],
diff --git a/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts b/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts
index 098cb35b01d..41f331a5522 100644
--- a/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts
+++ b/extensions/copilot/src/extension/conversation/vscode-node/conversationFeature.ts
@@ -49,9 +49,9 @@ import { generateTerminalFixes, setLastCommandMatchResult } from './terminalFixG
*/
export class ConversationFeature implements IExtensionContribution {
/** Disposables that exist for the lifetime of this object */
- private _disposables = new DisposableStore();
+ private readonly _disposables = new DisposableStore();
/** Disposables that are cleared whenever feature enablement is toggled */
- private _activatedDisposables = new DisposableStore();
+ private readonly _activatedDisposables = new DisposableStore();
/** For the conversation features to be enabled, the proxy needs to return a token with k/v pair: chat=1 */
public _enabled;
/** The feature is marked as active the first time it is enabled. */
diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts
index 10ca8aa3c2f..addf7d792d5 100644
--- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts
+++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts
@@ -486,7 +486,6 @@ class LanguageModelAccessPromptBaseCountCache {
export class CopilotLanguageModelWrapper extends Disposable {
constructor(
- @IExperimentationService readonly _expService: IExperimentationService,
@ITelemetryService private readonly _telemetryService: ITelemetryService,
@IBlockedExtensionService private readonly _blockedExtensionService: IBlockedExtensionService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
diff --git a/extensions/copilot/src/extension/conversation/vscode-node/remoteAgents.ts b/extensions/copilot/src/extension/conversation/vscode-node/remoteAgents.ts
index 24c3e41dbd1..8b8539f7163 100644
--- a/extensions/copilot/src/extension/conversation/vscode-node/remoteAgents.ts
+++ b/extensions/copilot/src/extension/conversation/vscode-node/remoteAgents.ts
@@ -88,7 +88,7 @@ interface IGitHubRepositoryReference {
}
export class RemoteAgentContribution implements IDisposable {
- private disposables = new DisposableStore();
+ private readonly disposables = new DisposableStore();
private refreshRemoteAgentsP: Promise | undefined;
private enabledSkillsPromise: Promise> | undefined;
diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts
index bcb109a7dd5..290c04e472d 100644
--- a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts
+++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts
@@ -29,7 +29,7 @@ suite('CopilotLanguageModelWrapper', () => {
instaService = accessor.get(IInstantiationService);
}
- suite('validateRequest - invalid', async () => {
+ suite('validateRequest - invalid', () => {
let wrapper: CopilotLanguageModelWrapper;
let endpoint: IChatEndpoint;
setup(async () => {
@@ -59,7 +59,7 @@ suite('CopilotLanguageModelWrapper', () => {
});
});
- suite('validateRequest - valid', async () => {
+ suite('validateRequest - valid', () => {
let wrapper: CopilotLanguageModelWrapper;
let endpoint: IChatEndpoint;
setup(async () => {
diff --git a/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts b/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts
index c55cc7143a3..f077dba2564 100644
--- a/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts
+++ b/extensions/copilot/src/extension/inlineChat/node/inlineChatIntent.ts
@@ -305,6 +305,8 @@ class InlineChatEditToolsStrategy implements IInlineChatEditStrategy {
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@ILogService private readonly _logService: ILogService,
@IToolsService private readonly _toolsService: IToolsService,
+ @IConfigurationService private readonly _configurationService: IConfigurationService,
+ @IExperimentationService private readonly _experimentationService: IExperimentationService,
) { }
async executeEdit(endpoint: IChatEndpoint, conversation: Conversation, request: vscode.ChatRequest, stream: vscode.ChatResponseStream, token: CancellationToken, documentContext: IDocumentContext, chatTelemetry: ChatTelemetryBuilder): Promise {
@@ -455,6 +457,12 @@ class InlineChatEditToolsStrategy implements IInlineChatEditStrategy {
userInitiatedRequest: true,
location: ChatLocation.Editor,
requestOptions,
+ modelCapabilities: {
+ enableThinking: this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatEnableThinking, this._experimentationService),
+ reasoningEffort: typeof request.modelConfiguration?.reasoningEffort === 'string'
+ ? request.modelConfiguration.reasoningEffort
+ : this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatReasoningEffort, this._experimentationService),
+ },
telemetryProperties: {
messageId: telemetry.telemetryMessageId,
conversationId: telemetry.sessionId,
@@ -603,6 +611,8 @@ class InlineChatEditHeuristicStrategy implements IInlineChatEditStrategy {
constructor(
private readonly _intent: InlineChatIntent,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
+ @IConfigurationService private readonly _configurationService: IConfigurationService,
+ @IExperimentationService private readonly _experimentationService: IExperimentationService,
) { }
async executeEdit(endpoint: IChatEndpoint, conversation: Conversation, request: vscode.ChatRequest, stream: vscode.ChatResponseStream, token: CancellationToken, documentContext: IDocumentContext, chatTelemetry: ChatTelemetryBuilder): Promise {
@@ -651,6 +661,12 @@ class InlineChatEditHeuristicStrategy implements IInlineChatEditStrategy {
messages: renderResult.messages,
userInitiatedRequest: true,
location: ChatLocation.Editor,
+ modelCapabilities: {
+ enableThinking: this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatEnableThinking, this._experimentationService),
+ reasoningEffort: typeof request.modelConfiguration?.reasoningEffort === 'string'
+ ? request.modelConfiguration.reasoningEffort
+ : this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineChatReasoningEffort, this._experimentationService),
+ },
telemetryProperties: {
messageId: telemetry.telemetryMessageId,
conversationId: telemetry.sessionId,
diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts
index 806fd9938d3..5a31c9be113 100644
--- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts
+++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts
@@ -46,6 +46,7 @@ import { INesConfigs } from './nesConfigs';
import { CachedOrRebasedEdit, NextEditCache } from './nextEditCache';
import { LlmNESTelemetryBuilder, ReusedRequestKind } from './nextEditProviderTelemetry';
import { INextEditResult, NextEditResult } from './nextEditResult';
+import { SpeculativeCancelReason, SpeculativeRequestManager } from './speculativeRequestManager';
/**
* Computes a reduced window range that encompasses both the original window (shrunk by one line
@@ -167,28 +168,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider | null = null;
- /**
- * Tracks a speculative request for the post-edit document state.
- * When a suggestion is shown, we speculatively fetch the next edit as if the user had already accepted.
- * This allows reusing the in-flight request when the user actually accepts the suggestion.
- */
- private _speculativePendingRequest: {
- request: StatelessNextEditRequest;
- docId: DocumentId;
- postEditContent: string;
- } | null = null;
-
- /**
- * A speculative request that is deferred until the originating stream completes.
- * When a suggestion is shown while its stream is still running, we schedule the
- * speculative request here instead of firing immediately. If more edits arrive
- * from the stream, the schedule is cleared (the shown edit wasn't the last one).
- * When the stream ends, if the schedule is still present, the speculative fires.
- */
- private _scheduledSpeculativeRequest: {
- suggestion: NextEditResult;
- headerRequestId: string;
- } | null = null;
+ private readonly _specManager: SpeculativeRequestManager;
private _lastShownTime = 0;
/** The requestId of the last shown suggestion. We store only the requestId (not the object) to avoid preventing garbage collection. */
@@ -230,35 +210,48 @@ export class NextEditProvider extends Disposable implements INextEditProvider {
store.add(runOnChange(doc.value, (value) => {
this._cancelPendingRequestDueToDocChange(doc.id, value);
+ // FIXME: don't invoke before fixing false positive cancellations
+ // this._specManager.onActiveDocumentChanged(doc.id, value.value);
}));
+ // When the per-doc store is disposed, the document was removed from
+ // openDocuments. Cancel any speculative targeting it — its cached result
+ // would never be hit again.
+ store.add(toDisposable(() => this._specManager.onDocumentClosed(doc.id)));
}).recomputeInitiallyAndOnChange(this._store);
}
- private _cancelSpeculativeRequest(): void {
- this._scheduledSpeculativeRequest = null;
- if (this._speculativePendingRequest) {
- this._speculativePendingRequest.request.cancellationTokenSource.cancel();
- this._speculativePendingRequest = null;
- }
- }
-
+ /**
+ * Cancels the in-flight stateless next-edit request when the document it
+ * was issued for has diverged from the request's expected post-edit state.
+ *
+ * Invoked from the per-document `runOnChange` autorun in the constructor
+ * whenever an open document's value changes. The pending request was built
+ * against a specific snapshot (`documentAfterEdits`); if the user has since
+ * typed something that makes the current value differ from that snapshot,
+ * the result would no longer be applicable and is cancelled eagerly.
+ *
+ * Skipped when:
+ * - the `InlineEditsAsyncCompletions` experiment is enabled (that path
+ * tolerates divergence and rebases later), or
+ * - there is no pending request, or
+ * - the changed document is not the one the pending request targets.
+ *
+ * Note: this only handles the regular pending stateless request. Speculative
+ * requests have their own divergence handling via
+ * `SpeculativeRequestManager.onActiveDocumentChanged` (trajectory check).
+ */
private _cancelPendingRequestDueToDocChange(docId: DocumentId, docValue: StringText) {
- // Note: we intentionally do NOT cancel the speculative request here.
- // The speculative request's postEditContent represents a *future* document state
- // (after the user would accept the suggestion), so it will almost never match the
- // current document value while the user is still typing. Cancelling here would
- // wastefully kill and recreate the speculative request on every keystroke.
- // Instead, speculative requests are cancelled by the appropriate lifecycle handlers:
- // handleRejection, handleIgnored, _triggerSpeculativeRequest, and _executeNewNextEditRequest.
-
const isAsyncCompletions = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAsyncCompletions, this._expService);
+
if (isAsyncCompletions || this._pendingStatelessNextEditRequest === null) {
return;
}
+
const activeDoc = this._pendingStatelessNextEditRequest.getActiveDocument();
if (activeDoc.id === docId && activeDoc.documentAfterEdits.value !== docValue.value) {
this._pendingStatelessNextEditRequest.cancellationTokenSource.cancel();
@@ -546,11 +539,12 @@ export class NextEditProvider extends Disposable implements INextEditProvider 1000 && suggestion.result.edit) {
@@ -1470,9 +1473,15 @@ export class NextEditProvider extends Disposable implements INextEditProvider;
+ readonly docId: DocumentId;
+ readonly postEditContent: string;
+ /** preEditDocument[0..editStart] — the doc text before the edit window. */
+ readonly trajectoryPrefix: string;
+ /** preEditDocument[editEnd..] — the doc text after the edit window. */
+ readonly trajectorySuffix: string;
+ /** The replacement text the user would type to reach `postEditContent`. */
+ readonly trajectoryNewText: string;
+}
+
+export interface ScheduledSpeculativeRequest {
+ readonly suggestion: NextEditResult;
+ readonly headerRequestId: string;
+}
+
+/**
+ * Owns the lifecycle of NES speculative requests:
+ *
+ * - the in-flight `pending` speculative (the bet on a specific post-accept document state)
+ * - the `scheduled` speculative deferred until its originating stream completes
+ *
+ * Centralizes cancellation with typed reasons so every triggered cancellation
+ * (reject, supersede, doc-close, trajectory divergence, dispose, ...) goes through
+ * one path and is logged on the request's log context.
+ */
+export class SpeculativeRequestManager extends Disposable {
+
+ private _pending: SpeculativePendingRequest | null = null;
+ private _scheduled: ScheduledSpeculativeRequest | null = null;
+
+ constructor(private readonly _logger: ILogger) {
+ super();
+ }
+
+ get pending(): SpeculativePendingRequest | null {
+ return this._pending;
+ }
+
+ /** Replaces the current pending speculative; cancels the prior one as `Replaced`. */
+ setPending(req: SpeculativePendingRequest): void {
+ if (this._pending && this._pending.request !== req.request) {
+ this._cancelPending(SpeculativeCancelReason.Replaced);
+ }
+ this._pending = req;
+ }
+
+ /** Detaches the pending speculative without cancelling — caller is consuming it. */
+ consumePending(): void {
+ this._pending = null;
+ }
+
+ schedule(s: ScheduledSpeculativeRequest): void {
+ this._scheduled = s;
+ }
+
+ clearScheduled(): void {
+ this._scheduled = null;
+ }
+
+ /**
+ * Removes and returns the scheduled entry iff its `headerRequestId` matches.
+ * Used by the streaming path so that each stream only ever consumes its own
+ * schedule, never another stream's.
+ */
+ consumeScheduled(headerRequestId: string): ScheduledSpeculativeRequest | null {
+ if (this._scheduled?.headerRequestId !== headerRequestId) {
+ return null;
+ }
+ const s = this._scheduled;
+ this._scheduled = null;
+ return s;
+ }
+
+ cancelAll(reason: SpeculativeCancelReason): void {
+ this._scheduled = null;
+ this._cancelPending(reason);
+ }
+
+ /** Cancels the pending speculative iff `(docId, postEditContent)` doesn't match. */
+ cancelIfMismatch(docId: DocumentId, postEditContent: string, reason: SpeculativeCancelReason): void {
+ if (this._pending && (this._pending.docId !== docId || this._pending.postEditContent !== postEditContent)) {
+ this._cancelPending(reason);
+ }
+ }
+
+ /** Cancels the pending and clears any scheduled targeting this document. */
+ onDocumentClosed(docId: DocumentId): void {
+ if (this._scheduled?.suggestion.result?.targetDocumentId === docId) {
+ this._scheduled = null;
+ }
+ if (this._pending?.docId === docId) {
+ this._cancelPending(SpeculativeCancelReason.DocumentClosed);
+ }
+ }
+
+ /**
+ * Trajectory check. The pending speculative is alive iff the current document
+ * value is a *type-through prefix* toward the speculative's `postEditContent`:
+ *
+ * cur === trajectoryPrefix + middle + trajectorySuffix
+ * where middle is some prefix of trajectoryNewText
+ *
+ * If not, the user's edits cannot reach `postEditContent` via continued typing
+ * and the speculative will never be consumed — cancel now.
+ */
+ onActiveDocumentChanged(docId: DocumentId, currentDocValue: string): void {
+ const p = this._pending;
+ if (!p || p.docId !== docId) {
+ return;
+ }
+ // Cheap structural failure: doc shorter than the unedited frame.
+ if (currentDocValue.length < p.trajectoryPrefix.length + p.trajectorySuffix.length) {
+ this._cancelPending(SpeculativeCancelReason.DivergedFromTrajectoryForm);
+ return;
+ }
+ if (!currentDocValue.startsWith(p.trajectoryPrefix)) {
+ this._cancelPending(SpeculativeCancelReason.DivergedFromTrajectoryPrefix);
+ return;
+ }
+ if (!currentDocValue.endsWith(p.trajectorySuffix)) {
+ this._cancelPending(SpeculativeCancelReason.DivergedFromTrajectorySuffix);
+ return;
+ }
+ const middle = currentDocValue.slice(p.trajectoryPrefix.length, currentDocValue.length - p.trajectorySuffix.length);
+ if (!p.trajectoryNewText.startsWith(middle)) {
+ this._cancelPending(SpeculativeCancelReason.DivergedFromTrajectoryMiddle);
+ }
+ }
+
+ private _cancelPending(reason: SpeculativeCancelReason): void {
+ const p = this._pending;
+ if (!p) {
+ return;
+ }
+ this._pending = null;
+ const headerRequestId = p.request.headerRequestId;
+ this._logger.trace(`cancelling speculative request: ${reason} (headerRequestId=${headerRequestId})`);
+ p.request.logContext.addLog(`speculative request cancelled: ${reason}`);
+ const cts = p.request.cancellationTokenSource;
+ cts.cancel();
+ // Dispose to release the cancel-event listeners that the in-flight
+ // provider call hooked onto the token. Safe even though the runner may
+ // observe cancellation asynchronously — `cancel()` already fired the event.
+ cts.dispose();
+ }
+
+ override dispose(): void {
+ this.cancelAll(SpeculativeCancelReason.Disposed);
+ super.dispose();
+ }
+}
diff --git a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderSpeculative.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderSpeculative.spec.ts
index 63bbd7431f0..49a60bd5fd0 100644
--- a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderSpeculative.spec.ts
+++ b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderSpeculative.spec.ts
@@ -417,7 +417,7 @@ describe('NextEditProvider speculative requests', () => {
await statelessProvider.calls[1].completed.p;
});
- it('does not cancel speculative request when active document diverges from expected post-edit state', async () => {
+ it.skip('cancels speculative request when active document edit moves off the type-through trajectory', async () => {
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
const statelessProvider = new TestStatelessNextEditProvider();
@@ -436,30 +436,28 @@ describe('NextEditProvider speculative requests', () => {
nextEditProvider.handleShown(suggestion);
await statelessProvider.waitForCall(2);
- // Editing the active document should NOT cancel the speculative request.
- // The speculative request targets a future post-edit state, not the current
- // document value, so keystroke-level changes should not invalidate it.
+ // Inserting at the start of the document breaks the trajectory's prefix
+ // (the doc no longer starts with `pre[0..editStart]`). The speculative
+ // can no longer be reached via type-through-then-accept — cancel.
doc.applyEdit(StringEdit.insert(0, '/* diverged */\n'));
- await flushMicrotasks();
+ await statelessProvider.calls[1].cancellationRequested.p;
- expect(statelessProvider.calls[1].wasCancelled).toBe(false);
-
- // Clean up: reject so the speculative request gets cancelled properly
- nextEditProvider.handleRejection(doc.id, suggestion);
- await statelessProvider.calls[1].completed.p;
+ expect(statelessProvider.calls[1].wasCancelled).toBe(true);
});
- it('keeps speculative request alive when user types in the active document', async () => {
+ it.skip('keeps speculative alive while user types characters of the suggestion (type-through)', async () => {
await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
const statelessProvider = new TestStatelessNextEditProvider();
- statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });
+ // Suggestion inserts `'barbaz'` between `'foo'` and `'();'`.
+ // Resulting precise edit: replace [3, 3) with 'barbaz' (a pure insertion).
+ statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'foobarbaz();') });
statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });
const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
const doc = workspace.addDocument({
id: DocumentId.create(URI.file('/test/spec-typing.ts').toString()),
- initialValue: 'const value = 1;\nconsole.log(value);',
+ initialValue: 'foo();\nconsole.log();',
});
doc.setSelection([new OffsetRange(0, 0)], undefined);
@@ -468,23 +466,28 @@ describe('NextEditProvider speculative requests', () => {
nextEditProvider.handleShown(suggestion);
await statelessProvider.waitForCall(2);
- // Simulate multiple keystrokes in the active document while the speculative
- // request is in flight — none of them should cancel it.
- doc.applyEdit(StringEdit.insert(0, 'a'));
+ // User types characters of the suggestion at the edit position — each
+ // keystroke keeps the document on a type-through trajectory toward
+ // `postEditContent`, so the speculative must NOT be cancelled.
+ doc.applyEdit(StringEdit.insert(3, 'b'));
await flushMicrotasks();
expect(statelessProvider.calls[1].wasCancelled).toBe(false);
- doc.applyEdit(StringEdit.insert(1, 'b'));
+ doc.applyEdit(StringEdit.insert(4, 'a'));
await flushMicrotasks();
expect(statelessProvider.calls[1].wasCancelled).toBe(false);
- doc.applyEdit(StringEdit.insert(2, 'c'));
+ doc.applyEdit(StringEdit.insert(5, 'r'));
await flushMicrotasks();
expect(statelessProvider.calls[1].wasCancelled).toBe(false);
- // Clean up via rejection
- nextEditProvider.handleRejection(doc.id, suggestion);
- await statelessProvider.calls[1].completed.p;
+ // Now the user types a character that doesn't match the suggestion's
+ // next character (`'b'` would be expected; they typed `'X'`). The
+ // trajectory is broken — cancel.
+ doc.applyEdit(StringEdit.insert(6, 'X'));
+ await statelessProvider.calls[1].cancellationRequested.p;
+
+ expect(statelessProvider.calls[1].wasCancelled).toBe(true);
});
it('cancels mismatched speculative request when starting a request for another document', async () => {
@@ -1366,4 +1369,83 @@ describe('NextEditProvider speculative requests', () => {
}
});
});
+
+ describe('lifecycle cancellation', () => {
+ it('cancels in-flight speculative when clearCache() is called', async () => {
+ await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
+
+ const statelessProvider = new TestStatelessNextEditProvider();
+ statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });
+ statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });
+ const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
+
+ const doc = workspace.addDocument({
+ id: DocumentId.create(URI.file('/test/spec-clear-cache.ts').toString()),
+ initialValue: 'const value = 1;\nconsole.log(value);',
+ });
+ doc.setSelection([new OffsetRange(0, 0)], undefined);
+
+ const suggestion = await getNextEdit(nextEditProvider, doc.id);
+ assert(suggestion.result?.edit);
+ nextEditProvider.handleShown(suggestion);
+ await statelessProvider.waitForCall(2);
+
+ nextEditProvider.clearCache();
+ await statelessProvider.calls[1].cancellationRequested.p;
+
+ expect(statelessProvider.calls[1].wasCancelled).toBe(true);
+ });
+
+ it('cancels in-flight speculative when its target document is closed', async () => {
+ await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
+
+ const statelessProvider = new TestStatelessNextEditProvider();
+ statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });
+ statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });
+ const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
+
+ const doc = workspace.addDocument({
+ id: DocumentId.create(URI.file('/test/spec-doc-close.ts').toString()),
+ initialValue: 'const value = 1;\nconsole.log(value);',
+ });
+ doc.setSelection([new OffsetRange(0, 0)], undefined);
+
+ const suggestion = await getNextEdit(nextEditProvider, doc.id);
+ assert(suggestion.result?.edit);
+ nextEditProvider.handleShown(suggestion);
+ await statelessProvider.waitForCall(2);
+
+ // Closing the document removes it from openDocuments — the speculative's
+ // cached result would never be hit again, so cancel it.
+ doc.dispose();
+ await statelessProvider.calls[1].cancellationRequested.p;
+
+ expect(statelessProvider.calls[1].wasCancelled).toBe(true);
+ });
+
+ it('cancels in-flight speculative when the provider is disposed', async () => {
+ await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On);
+
+ const statelessProvider = new TestStatelessNextEditProvider();
+ statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') });
+ statelessProvider.enqueueBehavior({ kind: 'waitForCancellation' });
+ const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider);
+
+ const doc = workspace.addDocument({
+ id: DocumentId.create(URI.file('/test/spec-provider-dispose.ts').toString()),
+ initialValue: 'const value = 1;\nconsole.log(value);',
+ });
+ doc.setSelection([new OffsetRange(0, 0)], undefined);
+
+ const suggestion = await getNextEdit(nextEditProvider, doc.id);
+ assert(suggestion.result?.edit);
+ nextEditProvider.handleShown(suggestion);
+ await statelessProvider.waitForCall(2);
+
+ nextEditProvider.dispose();
+ await statelessProvider.calls[1].cancellationRequested.p;
+
+ expect(statelessProvider.calls[1].wasCancelled).toBe(true);
+ });
+ });
});
diff --git a/extensions/copilot/src/extension/inlineEdits/test/vscode-node/isInlineSuggestion.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/vscode-node/isInlineSuggestion.spec.ts
index cc9a7b0226b..5bb09b87c47 100644
--- a/extensions/copilot/src/extension/inlineEdits/test/vscode-node/isInlineSuggestion.spec.ts
+++ b/extensions/copilot/src/extension/inlineEdits/test/vscode-node/isInlineSuggestion.spec.ts
@@ -101,8 +101,9 @@ suite('toInlineSuggestion', () => {
assert.isDefined(result);
// Range is an empty range at the cursor for a pure insertion
assert.deepStrictEqual(result!.range, new Range(1, 15, 1, 15));
- // Text is prepended with the newline between cursor and original range
- assert.strictEqual(result!.newText, '\n' + replaceText);
+ // Text is prepended with the newline between cursor and original range,
+ // and the trailing newline is dropped so we don't introduce a blank line.
+ assert.strictEqual(result!.newText, '\n' + replaceText.replace(/\r?\n$/, ''));
});
test('should not use ghost text when inserting on next line when none empty', () => {
@@ -149,7 +150,8 @@ suite('toInlineSuggestion', () => {
const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);
assert.isDefined(result);
assert.deepStrictEqual(result!.range, new Range(0, 13, 0, 13));
- assert.strictEqual(result!.newText, '\n' + replaceText);
+ // Trailing '\n' is dropped to avoid a spurious blank line.
+ assert.strictEqual(result!.newText, '\n' + replaceText.replace(/\r?\n$/, ''));
});
test('multi-line insertion without trailing newline rejected when target line has content', () => {
@@ -318,7 +320,8 @@ function createDocumentSymbol(
const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);
assert.isDefined(result);
assert.deepStrictEqual(result!.range, new Range(0, 6, 0, 6));
- assert.strictEqual(result!.newText, '\n\n');
+ // Trailing '\n' is dropped — only the prepended newline remains.
+ assert.strictEqual(result!.newText, '\n');
});
test('next-line: cursor at end of an empty line', () => {
@@ -330,7 +333,8 @@ function createDocumentSymbol(
const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);
assert.isDefined(result);
assert.deepStrictEqual(result!.range, new Range(0, 0, 0, 0));
- assert.strictEqual(result!.newText, '\nnew line\n');
+ // Trailing '\n' is dropped to avoid a spurious blank line.
+ assert.strictEqual(result!.newText, '\nnew line');
});
test('next-line: range on line before cursor is rejected', () => {
@@ -547,4 +551,118 @@ function createDocumentSymbol(
assert.deepStrictEqual(result!.range, new Range(1, 0, 1, 0));
assert.strictEqual(result!.newText, '');
});
+
+ test('insertion on next line in fieldLabels object', () => {
+ const doc = `import React, { useState } from "react";
+
+interface FormData {
+ firstName: string;
+ lastName: string;
+ password: string;
+ email: string;
+ age: string;
+ city: string;
+}
+
+const initialFormData: FormData = {
+ firstName: "",
+ lastName: "",
+ password: "",
+ email: "",
+ age: "",
+ city: "",
+};
+
+const fieldLabels: Record = {
+ firstName: "First Name",
+ lastName: "Last Name",
+ email: "Email Address",
+ age: "Age",
+ city: "City",
+};
+`;
+ const document = createTextDocumentData(Uri.from({ scheme: 'test', path: '/test/file.tsx' }), doc, 'typescriptreact').document;
+ const cursorPosition = new Position(22, 26); // end of ` lastName: "Last Name",`
+ const replaceRange = new Range(23, 0, 23, 0);
+ const replaceText = ' password: "Password",\n';
+
+ const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText, true);
+ assert.isDefined(result);
+ assert.deepStrictEqual(result!.range, new Range(22, 26, 22, 26));
+ // Trailing '\n' is dropped because the original line terminator after
+ // the cursor is preserved.
+ assert.strictEqual(result!.newText, '\n password: "Password",');
+ });
+
+ suite('CRLF', () => {
+
+ function createCRLFDocument(lines: string[], languageId: string = 'typescript') {
+ return createTextDocumentData(
+ Uri.from({ scheme: 'test', path: '/test/file.ts' }),
+ lines.join('\r\n'),
+ languageId,
+ '\r\n',
+ ).document;
+ }
+
+ test('next-line insertion: trailing CRLF is dropped (no dangling \\r)', () => {
+ const document = createCRLFDocument(['function foo(', '', 'other']);
+ const cursorPosition = new Position(0, 13); // end of "function foo("
+ const replaceRange = new Range(1, 0, 1, 0); // empty line
+ const replaceText = ' a: string,\r\n b: number\r\n';
+
+ const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);
+ assert.isDefined(result);
+ assert.deepStrictEqual(result!.range, new Range(0, 13, 0, 13));
+ // The trailing CRLF must be stripped entirely; no dangling '\r'
+ // should leak into the suggestion text.
+ assert.strictEqual(result!.newText, '\r\n a: string,\r\n b: number');
+ });
+
+ test('next-line insertion: trailing CRLF on non-empty target line', () => {
+ const document = createCRLFDocument(['function foo(', ')', 'other']);
+ const cursorPosition = new Position(0, 13);
+ const replaceRange = new Range(1, 0, 1, 0);
+ const replaceText = ' a: string,\r\n b: number\r\n';
+
+ const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);
+ assert.isDefined(result);
+ assert.deepStrictEqual(result!.range, new Range(0, 13, 0, 13));
+ assert.strictEqual(result!.newText, '\r\n a: string,\r\n b: number');
+ });
+
+ test('next-line insertion: CRLF-only newText is fully stripped', () => {
+ const document = createCRLFDocument(['line 0', '', 'line 2']);
+ const cursorPosition = new Position(0, 6);
+ const replaceRange = new Range(1, 0, 1, 0);
+ const replaceText = '\r\n';
+
+ const result = toInlineSuggestion(cursorPosition, document, replaceRange, replaceText);
+ assert.isDefined(result);
+ assert.deepStrictEqual(result!.range, new Range(0, 6, 0, 6));
+ // Only the prepended CRLF between cursor and original range remains.
+ assert.strictEqual(result!.newText, '\r\n');
+ });
+ });
+
+ suite('multi-line range, no common prefix', () => {
+
+ // Regression: when commonLen === 0 and the replaced text starts with '\n',
+ // `lastIndexOf('\n', -1)` would (incorrectly) clamp to 0 and report a
+ // match, causing the leading newline to be stripped — which can collapse
+ // the multi-line range into a same-line "suggestion" that the function
+ // then accepts. With the original substring-based check, no strip occurs
+ // and the result is `undefined`.
+ test('does not strip leading newline when nothing is in common', () => {
+ const document = createMockDocument(['abc', 'x', 'rest']);
+ // replacedText = '\nx', newText[0]='Y' differs from '\n', commonLen=0.
+ const replaceRange = new Range(0, 3, 1, 1);
+ const cursorPosition = new Position(1, 1);
+ const replaceText = 'Yx';
+
+ // The range cannot legitimately be collapsed to a single line, so
+ // the function must not synthesize a ghost-text suggestion.
+ assert.isUndefined(toInlineSuggestion(cursorPosition, document, replaceRange, replaceText));
+ });
+ });
});
diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/features/diagnosticsBasedCompletions/importDiagnosticsCompletionProvider.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/features/diagnosticsBasedCompletions/importDiagnosticsCompletionProvider.ts
index 9c6c3146497..5b4e1d2cc3d 100644
--- a/extensions/copilot/src/extension/inlineEdits/vscode-node/features/diagnosticsBasedCompletions/importDiagnosticsCompletionProvider.ts
+++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/features/diagnosticsBasedCompletions/importDiagnosticsCompletionProvider.ts
@@ -361,7 +361,7 @@ export type ImportDetails = {
labelShort: string;
labelDeduped: string;
importSource: ImportSource;
-}
+};
export interface ILanguageImportHandler {
isImportDiagnostic(diagnostic: Diagnostic): boolean;
diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/isInlineSuggestion.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/isInlineSuggestion.ts
index 3add1ecc00e..b1876d17e03 100644
--- a/extensions/copilot/src/extension/inlineEdits/vscode-node/isInlineSuggestion.ts
+++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/isInlineSuggestion.ts
@@ -4,7 +4,6 @@
*--------------------------------------------------------------------------------------------*/
import { Position, Range, TextDocument } from 'vscode';
-import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange';
export interface InlineSuggestionEdit {
readonly range: Range;
@@ -17,67 +16,112 @@ export interface InlineSuggestionEdit {
* which is required for VS Code to render ghost text.
*/
export function toInlineSuggestion(cursorPos: Position, doc: TextDocument, range: Range, newText: string, advanced: boolean = true): InlineSuggestionEdit | undefined {
- // If multi line insertion starts on the next line
- // All new lines have to be newly created lines
- if (range.isEmpty && cursorPos.line + 1 === range.start.line && range.start.character === 0
- && doc.lineAt(cursorPos.line).text.length === cursorPos.character // cursor is at the end of the line
- && (newText.endsWith('\n') || (newText.includes('\n') && doc.lineAt(range.end.line).text.length === range.end.character)) // no remaining content after insertion
- ) {
- // Use an empty range at the cursor so the suggestion is a pure insertion
- const adjustedRange = new Range(cursorPos, cursorPos);
- const textBetweenCursorAndRange = doc.getText(new Range(cursorPos, range.start));
- return { range: adjustedRange, newText: textBetweenCursorAndRange + newText };
+ // Special case: a multi-line insertion that starts on the line *after* the cursor
+ // can be re-expressed as a pure insertion at the cursor.
+ const nextLineInsertion = tryAdjustNextLineInsertion(cursorPos, doc, range, newText);
+ if (nextLineInsertion) {
+ return nextLineInsertion;
}
- if (advanced) {
- // If the range spans multiple lines, try to reduce it by stripping a common
- // prefix (up to a newline boundary) from the replaced text and newText.
- if (range.start.line !== range.end.line) {
- const fullReplacedText = doc.getText(range);
- let commonLen = 0;
- const maxLen = Math.min(fullReplacedText.length, newText.length);
- while (commonLen < maxLen && fullReplacedText[commonLen] === newText[commonLen]) {
- commonLen++;
- }
- const lastNewline = fullReplacedText.substring(0, commonLen).lastIndexOf('\n');
- if (lastNewline >= 0) {
- const strippedLen = lastNewline + 1;
- newText = newText.substring(strippedLen);
- const newStart = doc.positionAt(doc.offsetAt(range.start) + strippedLen);
- range = new Range(newStart, range.end);
- }
- }
+ // If the range spans multiple lines, try to collapse it to a single line by
+ // trimming a shared prefix up to a newline boundary.
+ if (advanced && range.start.line !== range.end.line) {
+ ({ range, newText } = stripCommonLinePrefix(doc, range, newText));
}
+ // Ghost text requires the edit to be on the cursor's line.
if (range.start.line !== range.end.line || range.start.line !== cursorPos.line) {
return undefined;
}
- const cursorOffset = doc.offsetAt(cursorPos);
- const offsetRange = new OffsetRange(doc.offsetAt(range.start), doc.offsetAt(range.end));
+ return validateSameLineGhostText(cursorPos, doc, range, newText);
+}
- const replacedText = offsetRange.substring(doc.getText());
+/**
+ * If the cursor is at the end of a line and the edit is an empty-range insertion
+ * at column 0 of the next line, rewrite it as a pure insertion at the cursor
+ * position. This is allowed when either:
+ * - `newText` ends with a newline (any existing content on the target line is
+ * pushed onto the following line), or
+ * - `newText` contains a newline and the target line is fully consumed by the
+ * insertion (no leftover content after the insertion).
+ */
+function tryAdjustNextLineInsertion(cursorPos: Position, doc: TextDocument, range: Range, newText: string): InlineSuggestionEdit | undefined {
+ if (!range.isEmpty) {
+ return undefined;
+ }
+ if (cursorPos.line + 1 !== range.start.line || range.start.character !== 0) {
+ return undefined;
+ }
+ if (doc.lineAt(cursorPos.line).text.length !== cursorPos.character) {
+ return undefined; // cursor is not at the end of the line
+ }
- const cursorOffsetInReplacedText = cursorOffset - offsetRange.start;
+ const targetLineFullyConsumed = doc.lineAt(range.end.line).text.length === range.end.character;
+ const noLeftoverAfterInsertion = newText.endsWith('\n') || (newText.includes('\n') && targetLineFullyConsumed);
+ if (!noLeftoverAfterInsertion) {
+ return undefined;
+ }
+
+ // Use an empty range at the cursor so the suggestion is a pure insertion.
+ // The original line terminator between the cursor and `range.start` is preserved
+ // in the document, so:
+ // - prepend that terminator to `newText` (it lives in the doc, not in the edit), and
+ // - drop a single trailing line ending from `newText` to avoid an extra blank line.
+ // CRLF-safe so we don't leak a dangling '\r' into the suggestion.
+ const lineBreak = doc.getText(new Range(cursorPos, range.start));
+ const trimmedNewText = newText.replace(/\r?\n$/, '');
+ return { range: new Range(cursorPos, cursorPos), newText: lineBreak + trimmedNewText };
+}
+
+/**
+ * Strip the longest shared prefix that ends on a newline boundary from both sides
+ * of a multi-line edit. This often shrinks the range so it fits on a single line,
+ * which is required for ghost text rendering.
+ */
+function stripCommonLinePrefix(doc: TextDocument, range: Range, newText: string): { range: Range; newText: string } {
+ const replacedText = doc.getText(range);
+ const maxLen = Math.min(replacedText.length, newText.length);
+ let commonLen = 0;
+ while (commonLen < maxLen && replacedText[commonLen] === newText[commonLen]) {
+ commonLen++;
+ }
+ if (commonLen === 0) {
+ return { range, newText };
+ }
+ const lastNewline = replacedText.lastIndexOf('\n', commonLen - 1);
+ if (lastNewline < 0) {
+ return { range, newText };
+ }
+ const strippedLen = lastNewline + 1;
+ const newStart = doc.positionAt(doc.offsetAt(range.start) + strippedLen);
+ return { range: new Range(newStart, range.end), newText: newText.substring(strippedLen) };
+}
+
+/**
+ * Validate that a single-line edit can be rendered as ghost text at the cursor:
+ * - the cursor is at or after `range.start`
+ * - everything before the cursor in the replaced text matches `newText`
+ * - the replaced text is a subword of `newText` (i.e. only insertions are needed)
+ */
+function validateSameLineGhostText(cursorPos: Position, doc: TextDocument, range: Range, newText: string): InlineSuggestionEdit | undefined {
+ const replacedText = doc.getText(range);
+ const cursorOffsetInReplacedText = cursorPos.character - range.start.character;
if (cursorOffsetInReplacedText < 0) {
return undefined;
}
-
- const textBeforeCursorIsEqual = replacedText.substring(0, cursorOffsetInReplacedText) === newText.substring(0, cursorOffsetInReplacedText);
- if (!textBeforeCursorIsEqual) {
+ if (replacedText.substring(0, cursorOffsetInReplacedText) !== newText.substring(0, cursorOffsetInReplacedText)) {
return undefined;
}
-
if (!isSubword(replacedText, newText)) {
return undefined;
}
-
return { range, newText };
}
+
/**
* a is subword of b if a can be obtained by removing characters from b
*/
-
export function isSubword(a: string, b: string): boolean {
for (let aIdx = 0, bIdx = 0; aIdx < a.length; bIdx++) {
if (bIdx >= b.length) {
diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts
index 8b85a7f39c4..bbf7d45a66f 100644
--- a/extensions/copilot/src/extension/intents/node/agentIntent.ts
+++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts
@@ -17,7 +17,7 @@ import { IAutomodeService } from '../../../platform/endpoint/node/automodeServic
import { IEnvService } from '../../../platform/env/common/envService';
import { ILogService } from '../../../platform/log/common/logService';
import { IEditLogService } from '../../../platform/multiFileEdit/common/editLogService';
-import { CUSTOM_TOOL_SEARCH_NAME, isAnthropicContextEditingEnabled } from '../../../platform/networking/common/anthropic';
+import { isAnthropicContextEditingEnabled } from '../../../platform/networking/common/anthropic';
import { IChatEndpoint } from '../../../platform/networking/common/networking';
import { modelsWithoutResponsesContextManagement } from '../../../platform/networking/common/openai';
import { INotebookService } from '../../../platform/notebook/common/notebookService';
@@ -141,8 +141,6 @@ export const getAgentTools = async (accessor: ServicesAccessor, request: vscode.
allowTools[ToolName.MultiReplaceString] = true;
}
- allowTools[CUSTOM_TOOL_SEARCH_NAME] = !!model.supportsToolSearch;
-
const tools = toolsService.getEnabledTools(request, model, tool => {
if (typeof allowTools[tool.name] === 'boolean') {
return allowTools[tool.name];
diff --git a/extensions/copilot/src/extension/log/node/chatLogExport.ts b/extensions/copilot/src/extension/log/node/chatLogExport.ts
index c61a044b72f..b4f97d9d0e1 100644
--- a/extensions/copilot/src/extension/log/node/chatLogExport.ts
+++ b/extensions/copilot/src/extension/log/node/chatLogExport.ts
@@ -139,7 +139,7 @@ export async function createExportedPrompt(
kind: 'error',
error: error?.toString() || 'Unknown error',
timestamp: new Date().toISOString()
- } as unknown as ExportedLogEntry);
+ });
}
}
diff --git a/extensions/copilot/src/extension/mcp/vscode-node/commands.ts b/extensions/copilot/src/extension/mcp/vscode-node/commands.ts
index 9cfa94dcd6b..eb8bd3b80b9 100644
--- a/extensions/copilot/src/extension/mcp/vscode-node/commands.ts
+++ b/extensions/copilot/src/extension/mcp/vscode-node/commands.ts
@@ -115,10 +115,10 @@ export class McpSetupCommands extends Disposable {
};
constructor(
- @ITelemetryService readonly telemetryService: ITelemetryService,
- @ILogService readonly logService: ILogService,
- @IFetcherService readonly fetcherService: IFetcherService,
- @IInstantiationService readonly instantiationService: IInstantiationService,
+ @ITelemetryService private readonly telemetryService: ITelemetryService,
+ @ILogService private readonly logService: ILogService,
+ @IFetcherService private readonly fetcherService: IFetcherService,
+ @IInstantiationService private readonly instantiationService: IInstantiationService,
) {
super();
this._register(toDisposable(() => this.pendingSetup?.cts.dispose(true)));
diff --git a/extensions/copilot/src/extension/prompts/node/panel/title.tsx b/extensions/copilot/src/extension/prompts/node/panel/title.tsx
index 1af4bad430b..5bd6434b111 100644
--- a/extensions/copilot/src/extension/prompts/node/panel/title.tsx
+++ b/extensions/copilot/src/extension/prompts/node/panel/title.tsx
@@ -16,16 +16,21 @@ export class TitlePrompt extends PromptElement {
return (
<>
- You are an expert in crafting pithy titles for chatbot conversations. You are presented with a chat request, and you reply with a brief title that captures the main topic of that request.
+ You are an expert in crafting ultra-compact titles for chatbot conversations. You are presented with a chat request, and you reply with only a brief title that captures the main topic of that request.
- The title should not be wrapped in quotes. It should be about 8 words or fewer.
+ Write the title in sentence case, not title case. Preserve product names, abbreviations, code symbols, and proper nouns.
+ Aim for 3-6 words. Prefer the shortest accurate title.
+ Drop articles like "a", "an", and "the" unless needed for clarity.
+ Drop filler and generic framing like "help with", "question about", "request for", or "issue with".
+ Prefer short, concrete synonyms and omit unnecessary words.
+ Do not wrap the title in quotes or add trailing punctuation.
Here are some examples of good titles:
- Git rebase question
- - Installing Python packages
- - Location of LinkedList implementation in codebase
- - Adding a tree view to a VS Code extension
- - React useState hook usage
+ - Install Python packages
+ - LinkedList implementation location
+ - Add VS Code tree view
+ - React useState usage
Please write a brief title for the following request:
diff --git a/extensions/copilot/src/extension/search/vscode-node/commands.ts b/extensions/copilot/src/extension/search/vscode-node/commands.ts
index 4a81d810ed8..f7241fe3789 100644
--- a/extensions/copilot/src/extension/search/vscode-node/commands.ts
+++ b/extensions/copilot/src/extension/search/vscode-node/commands.ts
@@ -12,7 +12,7 @@ import { SearchFeedbackKind, SemanticSearchTextSearchProvider } from '../../work
export class SearchPanelCommands extends Disposable {
constructor(
- @ITelemetryService readonly telemetryService: ITelemetryService,
+ @ITelemetryService telemetryService: ITelemetryService,
@IFeedbackReporter private readonly feedbackReporter: IFeedbackReporter,
) {
super();
diff --git a/extensions/copilot/src/extension/tools/common/virtualTools/preComputedToolEmbeddingsCache.ts b/extensions/copilot/src/extension/tools/common/virtualTools/preComputedToolEmbeddingsCache.ts
index 58a306f2347..910d5432514 100644
--- a/extensions/copilot/src/extension/tools/common/virtualTools/preComputedToolEmbeddingsCache.ts
+++ b/extensions/copilot/src/extension/tools/common/virtualTools/preComputedToolEmbeddingsCache.ts
@@ -18,7 +18,7 @@ export class PreComputedToolEmbeddingsCache implements IToolEmbeddingsCache {
private embeddingsMap: Map | undefined;
constructor(
- @ILogService readonly _logService: ILogService,
+ @ILogService private readonly _logService: ILogService,
@IInstantiationService instantiationService: IInstantiationService,
@IEnvService envService: IEnvService
) {
diff --git a/extensions/copilot/src/extension/tools/node/createFileTool.tsx b/extensions/copilot/src/extension/tools/node/createFileTool.tsx
index 80577db3b92..e9559f86892 100644
--- a/extensions/copilot/src/extension/tools/node/createFileTool.tsx
+++ b/extensions/copilot/src/extension/tools/node/createFileTool.tsx
@@ -21,7 +21,7 @@ import { extname } from '../../../util/vs/base/common/resources';
import { count } from '../../../util/vs/base/common/strings';
import { URI } from '../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
-import { Position as ExtPosition, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelToolResult, MarkdownString, TextEdit } from '../../../vscodeTypes';
+import { Position as ExtPosition, Range as ExtRange, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelToolResult, MarkdownString, TextEdit } from '../../../vscodeTypes';
import { CodeBlockProcessor } from '../../codeBlocks/node/codeBlockProcessor';
import { IBuildPromptContext } from '../../prompt/common/intents';
import { renderPromptElementJSON } from '../../prompts/node/base/promptRenderer';
@@ -114,7 +114,15 @@ export class CreateFileTool implements ICopilotTool {
this.sendTelemetry(options.chatRequestId, modelId, fileExtension);
} else {
const content = removeLeadingFilepathComment(options.input.content, languageId, options.input.filePath);
- this._promptContext.stream.textEdit(uri, TextEdit.insert(new ExtPosition(0, 0), content));
+ // When the file has been deleted from disk but VS Code still holds a stale
+ // in-memory doc with content, use a full-document replace so the old buffer
+ // is overwritten rather than prepended to (https://github.com/microsoft/vscode/issues/311043).
+ if (!fileExists && doc && doc.getText().length > 0) {
+ const lastLine = doc.lineCount - 1;
+ this._promptContext.stream.textEdit(uri, TextEdit.replace(new ExtRange(0, 0, lastLine, doc.lineAt(lastLine).text.length), content));
+ } else {
+ this._promptContext.stream.textEdit(uri, TextEdit.insert(new ExtPosition(0, 0), content));
+ }
this._promptContext.stream.textEdit(uri, true);
this.sendTelemetry(options.chatRequestId, modelId, fileExtension);
return new LanguageModelToolResult([
diff --git a/extensions/copilot/src/extension/tools/node/toolSearchTool.ts b/extensions/copilot/src/extension/tools/node/toolSearchTool.ts
index ceca12f5a8b..a0f83013ee2 100644
--- a/extensions/copilot/src/extension/tools/node/toolSearchTool.ts
+++ b/extensions/copilot/src/extension/tools/node/toolSearchTool.ts
@@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import type * as vscode from 'vscode';
+import * as l10n from '@vscode/l10n';
import { ILogService } from '../../../platform/log/common/logService';
import { CUSTOM_TOOL_SEARCH_NAME } from '../../../platform/networking/common/anthropic';
import { LanguageModelTextPart, LanguageModelToolResult } from '../../../vscodeTypes';
@@ -56,10 +57,13 @@ export class ToolSearchTool implements ICopilotModelSpecificTool(args: T | undefined, defaultTimeBudget: number): ResolvedInput | FailedHandlerResponse => {
const requestStartTime = Date.now();
if (args === undefined) {
diff --git a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/nes.spec.ts b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/nes.spec.ts
index 7f1ec232603..a445ccdf7ac 100644
--- a/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/nes.spec.ts
+++ b/extensions/copilot/src/extension/typescriptContext/serverPlugin/src/node/test/nes.spec.ts
@@ -80,12 +80,12 @@ type TrackedRenameInfo = {
oldName: string;
newName: string;
range: Range;
-}
+};
type PostRenameTestCase = {
trackedRename: TrackedRenameInfo;
testCase: NesRenameTestCase;
-}
+};
function computeNesRenameTestCases(filePath: string): NesRenameTestCase[] {
const text = fs.readFileSync(filePath, 'utf8');
diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts
index c735d3a8c78..7f48633ba47 100644
--- a/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts
+++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/languageContextService.ts
@@ -477,7 +477,7 @@ type ContextRequestState = {
type CacheInfo = {
version: number;
state: CacheState;
-}
+};
enum CacheState {
NotPopulated = 'NotPopulated',
@@ -1244,9 +1244,9 @@ export class LanguageContextServiceImpl implements ILanguageContextService, vsco
public readonly onContextComputedOnTimeout: vscode.Event;
constructor(
+ @ITelemetryService telemetryService: ITelemetryService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IExperimentationService private readonly experimentationService: IExperimentationService,
- @ITelemetryService readonly telemetryService: ITelemetryService,
@ILogService private readonly logService: ILogService
) {
this.isDebugging = process.execArgv.some((arg) => /^--(?:inspect|debug)(?:-brk)?(?:=\d+)?$/i.test(arg));
@@ -1791,7 +1791,7 @@ async function* mapAsyncIterable(
const showContextInspectorViewContextKey = `github.copilot.chat.showContextInspectorView`;
export class InlineCompletionContribution implements vscode.Disposable, TokenBudgetProvider {
- private disposables: DisposableStore;
+ private readonly disposables: DisposableStore;
private registrations: DisposableStore | undefined;
private readonly registrationQueue: Queue;
@@ -1799,10 +1799,10 @@ export class InlineCompletionContribution implements vscode.Disposable, TokenBud
private readonly telemetrySender: TelemetrySender;
constructor(
+ @ITelemetryService telemetryService: ITelemetryService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IExperimentationService private readonly experimentationService: IExperimentationService,
- @ILogService readonly logService: ILogService,
- @ITelemetryService readonly telemetryService: ITelemetryService,
+ @ILogService private readonly logService: ILogService,
@ILanguageContextService private readonly languageContextService: ILanguageContextService,
@ILanguageContextProviderService private readonly languageContextProviderService: ILanguageContextProviderService,
) {
diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts
index fa6fa590995..6201e9bfacc 100644
--- a/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts
+++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/nesRenameService.ts
@@ -70,7 +70,7 @@ namespace NesRenameRequestArgs {
type TextChange = {
range: protocol.Range;
newText?: string;
-}
+};
type RenameGroup = {
file: vscode.Uri;
changes: TextChange[];
@@ -138,14 +138,14 @@ class TelemetrySender {
export class NesRenameContribution implements vscode.Disposable {
private _isActivated: Promise | undefined;
- private disposables: DisposableStore;
+ private readonly disposables: DisposableStore;
private readonly telemetrySender: TelemetrySender;
private static readonly ExecConfig: ExecConfig = { executionTarget: ExecutionTarget.Semantic };
constructor(
- @ITelemetryService readonly telemetryService: ITelemetryService,
- @ILogService readonly logService: ILogService,
+ @ITelemetryService telemetryService: ITelemetryService,
+ @ILogService private readonly logService: ILogService,
) {
this.telemetrySender = new TelemetrySender(telemetryService, logService);
this.disposables = new DisposableStore();
@@ -345,4 +345,4 @@ export class NesRenameContribution implements vscode.Disposable {
}
return { document, position, oldName, newName };
}
-}
\ No newline at end of file
+}
diff --git a/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts b/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts
index fc2037916e7..0f4bcdc3904 100644
--- a/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts
+++ b/extensions/copilot/src/extension/typescriptContext/vscode-node/types.ts
@@ -15,7 +15,7 @@ export type ResolvedRunnableResult = {
items: protocol.FullContextItem[];
cache?: protocol.CacheInfo;
debugPath?: protocol.ContextRunnableResultId | undefined;
-}
+};
export namespace ResolvedRunnableResult {
export function from(result: protocol.ContextRunnableResult, items: protocol.FullContextItem[]): ResolvedRunnableResult {
return {
@@ -34,7 +34,7 @@ export type ContextComputedEvent = {
position: vscode.Position;
source?: string;
summary: ContextItemSummary;
-}
+};
export type OnCachePopulatedEvent = ContextComputedEvent & { items: ReadonlyArray };
export type OnContextComputedEvent = ContextComputedEvent & { items: ReadonlyArray };
diff --git a/extensions/copilot/src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts b/extensions/copilot/src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts
index 140751fe2cc..96ee0dcc187 100644
--- a/extensions/copilot/src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts
+++ b/extensions/copilot/src/extension/workspaceRecorder/vscode-node/workspaceListenerService.ts
@@ -10,7 +10,7 @@ import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { IRecordableEditorLogEntry, IRecordableLogEntry, ITextModelEditReasonMetadata, IWorkspaceListenerService } from '../common/workspaceListenerService';
export class WorkspacListenerService extends Disposable implements IWorkspaceListenerService {
- readonly _serviceBrand = undefined;
+ declare _serviceBrand: undefined;
private readonly _onStructuredData = this._register(new Emitter());
readonly onStructuredData = this._onStructuredData.event;
diff --git a/extensions/copilot/src/lib/node/chatLibMain.ts b/extensions/copilot/src/lib/node/chatLibMain.ts
index 2f399d40959..4dd64f67f70 100644
--- a/extensions/copilot/src/lib/node/chatLibMain.ts
+++ b/extensions/copilot/src/lib/node/chatLibMain.ts
@@ -716,7 +716,7 @@ export interface IEditorSession {
readonly uiKind?: string;
}
-export type IActionItem = ActionItem
+export type IActionItem = ActionItem;
export interface INotificationSender {
showWarningMessage(message: string, ...actions: IActionItem[]): Promise;
}
diff --git a/extensions/copilot/src/platform/authentication/common/authentication.ts b/extensions/copilot/src/platform/authentication/common/authentication.ts
index 99a03386736..d0adc1d09a1 100644
--- a/extensions/copilot/src/platform/authentication/common/authentication.ts
+++ b/extensions/copilot/src/platform/authentication/common/authentication.ts
@@ -277,7 +277,7 @@ export abstract class BaseAuthenticationService extends Disposable implements IA
// #endregion
//#region ADO Token
- abstract getAdoAccessTokenBase64(options?: AuthenticationGetSessionOptions): Promise
+ abstract getAdoAccessTokenBase64(options?: AuthenticationGetSessionOptions): Promise;
//#endregion
protected async _handleAuthChangeEvent(): Promise {
diff --git a/extensions/copilot/src/platform/chat/common/commonTypes.ts b/extensions/copilot/src/platform/chat/common/commonTypes.ts
index 73fe349229d..6fb7bae58f9 100644
--- a/extensions/copilot/src/platform/chat/common/commonTypes.ts
+++ b/extensions/copilot/src/platform/chat/common/commonTypes.ts
@@ -187,7 +187,7 @@ export type ChatFetchRetriableError =
/**
* We requested conversation, the response was filtered by RAI, but we want to retry.
*/
- { type: ChatFetchResponseType.FilteredRetry; reason: string; category: FilterReason; value: T; requestId: string; serverRequestId: string | undefined }
+ { type: ChatFetchResponseType.FilteredRetry; reason: string; category: FilterReason; value: T; requestId: string; serverRequestId: string | undefined };
export type FetchSuccess =
{ type: ChatFetchResponseType.Success; value: T; requestId: string; serverRequestId: string | undefined; usage: APIUsage | undefined; resolvedModel: string };
diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts
index e0bf525b7ef..9e585d981b1 100644
--- a/extensions/copilot/src/platform/configuration/common/configurationService.ts
+++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts
@@ -640,6 +640,8 @@ export namespace ConfigKey {
export const UseAlternativeNESNotebookFormat = defineAndMigrateExpSetting('chat.advanced.notebook.alternativeNESFormat.enabled', 'chat.notebook.alternativeNESFormat.enabled', false);
export const InlineChatSelectionRatioThreshold = defineSetting('chat.inlineChat.selectionRatioThreshold', ConfigType.ExperimentBased, 0);
+ export const InlineChatReasoningEffort = defineSetting('chat.inlineChat.reasoningEffort', ConfigType.ExperimentBased, 'low');
+ export const InlineChatEnableThinking = defineSetting('chat.inlineChat.enableThinking', ConfigType.ExperimentBased, false);
export const InstantApplyShortModelName = defineAndMigrateExpSetting('chat.advanced.instantApply.shortContextModelName', 'chat.instantApply.shortContextModelName', CHAT_MODEL.SHORT_INSTANT_APPLY);
export const InstantApplyShortContextLimit = defineAndMigrateExpSetting('chat.advanced.instantApply.shortContextLimit', 'chat.instantApply.shortContextLimit', 8000);
@@ -713,8 +715,6 @@ export namespace ConfigKey {
/** Internal: override reasoning/thinking effort sent to model APIs (e.g. Responses API, Messages API). Used by evals. */
export const ReasoningEffortOverride = defineSetting('chat.reasoningEffortOverride', ConfigType.Simple, null);
-
- export const SessionSearchCloudSync = defineAndMigrateSetting('chat.advanced.sessionSearch.cloudSync.enabled', 'chat.sessionSearch.cloudSync.enabled', false);
}
/**
diff --git a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts
index 64de7269315..32b19748b14 100644
--- a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts
+++ b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts
@@ -70,7 +70,7 @@ type ICompletionModelCapabilities = {
type: 'completion';
family: string;
tokenizer: TokenizerType;
-}
+};
export enum ModelSupportedEndpoint {
ChatCompletions = '/chat/completions',
diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts
index 66fd2c66ab3..072210813e6 100644
--- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts
+++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts
@@ -63,14 +63,18 @@ class AutoModeTokenBank extends Disposable {
this._fetchedValue = this._register(createCapiClientFetchedValue(capiClientService, envService, {
request: async () => {
const authToken = (await authService.getCopilotToken()).token;
- const autoModeHint = expService.getTreatmentVariable(expName) || 'auto';
+ const extValue = expService.getTreatmentVariable(expName);
+ const model_hints = [extValue || 'auto'];
+ if (location === ChatLocation.Editor && model_hints[0] !== 'auto') {
+ model_hints.push('auto');
+ }
return {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
method: 'POST' as const,
- json: { auto_mode: { model_hints: [autoModeHint] } },
+ json: { auto_mode: { model_hints } },
};
},
requestMetadata: { type: RequestType.AutoModels },
diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts
index c18488dcbe2..93729f900a9 100644
--- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts
+++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts
@@ -86,8 +86,9 @@ 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';
+ 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')
diff --git a/extensions/copilot/src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts b/extensions/copilot/src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts
index b7627955849..cae82f015dc 100644
--- a/extensions/copilot/src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts
+++ b/extensions/copilot/src/platform/endpoint/test/node/openaiCompatibleEndpoint.ts
@@ -73,7 +73,7 @@ export type IModelConfig = {
max_completion_tokens?: number | null;
intent?: boolean | null;
};
-}
+};
export class OpenAICompatibleTestEndpoint extends ChatEndpoint {
constructor(
diff --git a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts
index 8d4949173db..ccee88711f1 100644
--- a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts
+++ b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts
@@ -483,7 +483,7 @@ export class GitServiceImpl extends Disposable implements IGitService {
}
private static repoToRepoContext(repo: Repository): RepoContext;
- private static repoToRepoContext(repo: Repository | undefined | null): RepoContext | undefined
+ private static repoToRepoContext(repo: Repository | undefined | null): RepoContext | undefined;
private static repoToRepoContext(repo: Repository | undefined | null): RepoContext | undefined {
if (!repo) {
return undefined;
diff --git a/extensions/copilot/src/platform/inlineCompletions/common/api.ts b/extensions/copilot/src/platform/inlineCompletions/common/api.ts
index 60a820a3bd9..8634278dbf7 100644
--- a/extensions/copilot/src/platform/inlineCompletions/common/api.ts
+++ b/extensions/copilot/src/platform/inlineCompletions/common/api.ts
@@ -11,12 +11,12 @@ export namespace Copilot {
export type Position = {
line: number;
character: number;
- }
+ };
export type Range = {
start: Position;
end: Position;
- }
+ };
/**
* The ContextProvider API allows extensions to provide additional context items that
diff --git a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/languageContext.ts b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/languageContext.ts
index a9b0725f0cf..16e23a2535e 100644
--- a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/languageContext.ts
+++ b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/languageContext.ts
@@ -11,13 +11,13 @@ export type LanguageContextEntry = {
context: ContextItem;
timeStamp: number;
onTimeout: boolean;
-}
+};
export type LanguageContextResponse = {
start: number;
end: number;
items: LanguageContextEntry[];
-}
+};
type SerializedSnippetContext = {
kind: ContextKind.Snippet;
@@ -25,21 +25,21 @@ type SerializedSnippetContext = {
uri: string;
additionalUris?: string[];
value: string;
-}
+};
type SerializedTraitContext = {
kind: ContextKind.Trait;
priority: number;
name: string;
value: string;
-}
+};
type SerializedDiagnosticBagContext = {
kind: ContextKind.DiagnosticBag;
priority: number;
uri: string;
values: Omit[];
-}
+};
type SerializedContextItem = SerializedSnippetContext | SerializedTraitContext | SerializedDiagnosticBagContext;
@@ -50,7 +50,7 @@ export type SerializedContextResponse = {
context: SerializedContextItem;
timeStamp: number;
}[];
-}
+};
export function serializeLanguageContext(response: LanguageContextResponse): SerializedContextResponse {
return {
@@ -113,7 +113,7 @@ export type SerializedDiagnostic = {
source: string;
code: string | number | undefined;
range: string;
-}
+};
function serializeDiagnostic(diagnostic: Diagnostic): Omit;
function serializeDiagnostic(diagnostic: Diagnostic, resource: Uri): SerializedDiagnostic;
diff --git a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts
index 0c9220928a0..0dee4e8cf4d 100644
--- a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts
+++ b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts
@@ -31,7 +31,7 @@ export type RecentlyViewedDocumentsOptions = {
readonly includeViewedFiles: boolean;
readonly includeLineNumbers: IncludeLineNumbersOption;
readonly clippingStrategy: RecentFileClippingStrategy;
-}
+};
export namespace RecentlyViewedDocumentsOptions {
export const VALIDATOR: IValidator> = vObj({
@@ -49,14 +49,14 @@ export type LanguageContextOptions = {
readonly enabled: boolean;
readonly maxTokens: number;
readonly traitPosition: 'before' | 'after';
-}
+};
export type DiffHistoryOptions = {
readonly nEntries: number;
readonly maxTokens: number;
readonly onlyForDocsInPrompt: boolean;
readonly useRelativePaths: boolean;
-}
+};
export type PagedClipping = { pageSize: number };
@@ -66,7 +66,7 @@ export type CurrentFileOptions = {
readonly includeLineNumbers: IncludeLineNumbersOption;
readonly includeCursorTag: boolean;
readonly prioritizeAboveCursor: boolean;
-}
+};
export namespace CurrentFileOptions {
export const VALIDATOR: IValidator> = vObj({
@@ -96,7 +96,7 @@ export type LintOptions = {
maxLineDistance: number;
/** When set to a value > 0, also include linter diagnostics from the N most recently edited/viewed files. */
nRecentFiles: number;
-}
+};
/**
* The raw user-facing aggressiveness setting. Includes `Default` to distinguish
@@ -241,7 +241,7 @@ export type PromptOptions = {
readonly diffHistory: DiffHistoryOptions;
readonly includePostScript: boolean;
readonly lintOptions: LintOptions | undefined;
-}
+};
/**
* Prompt strategies that tweak prompt in a way that's different from current prod prompting strategy.
diff --git a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts
index 6a3c77f6eda..3e27f7701fa 100644
--- a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts
+++ b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts
@@ -26,7 +26,7 @@ import { InlineEditRequestLogContext } from './inlineEditLogContext';
import { stringifyChatMessages } from './utils/stringifyChatMessages';
import { IXtabHistoryEntry } from './workspaceEditTracker/nesXtabHistoryTracker';
-export type EditStreaming = AsyncGenerator
+export type EditStreaming = AsyncGenerator;
export class WithStatelessProviderTelemetry {
constructor(
@@ -36,7 +36,7 @@ export class WithStatelessProviderTelemetry {
}
}
-export type EditStreamingWithTelemetry = AsyncGenerator, WithStatelessProviderTelemetry, void>
+export type EditStreamingWithTelemetry = AsyncGenerator, WithStatelessProviderTelemetry, void>;
export type StreamedEdit = {
readonly targetDocument: DocumentId;
@@ -49,7 +49,7 @@ export type StreamedEdit = {
* in either the original location or the jump target location.
*/
readonly originalWindow?: OffsetRange;
-}
+};
export type PushEdit = (edit: Result) => void;
@@ -432,7 +432,7 @@ export type FetchResultWithStats = {
readonly response: FetchResponse;
readonly fetchTime: number;
readonly fetchResult: ChatFetchResponseType;
-}
+};
export class StatelessNextEditTelemetryBuilder {
diff --git a/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker.ts b/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker.ts
index c755f5bd986..ed7eaa8c671 100644
--- a/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker.ts
+++ b/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker.ts
@@ -38,19 +38,19 @@ export interface IXtabHistoryVisibleRangesEntry extends IXtabHistoryDocumentEntr
export type IXtabHistoryEntry =
| IXtabHistoryEditEntry
- | IXtabHistoryVisibleRangesEntry
+ | IXtabHistoryVisibleRangesEntry;
type DocumentChangedEvent = {
value: StringText;
changes: StringEdit[];
previous: StringText | undefined;
-}
+};
type DocumentSelectionChangedEvent = {
value: readonly OffsetRange[];
changes: unknown[];
previous: readonly OffsetRange[] | undefined;
-}
+};
/**
* Controls how consecutive edits to the same document are merged in history.
diff --git a/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/workspaceDocumentEditTracker.ts b/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/workspaceDocumentEditTracker.ts
index a764232212e..64a029afd53 100644
--- a/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/workspaceDocumentEditTracker.ts
+++ b/extensions/copilot/src/platform/inlineEdits/common/workspaceEditTracker/workspaceDocumentEditTracker.ts
@@ -17,7 +17,7 @@ export type DocumentHistoryDifference = {
before: StringText;
after: StringText;
edits: StringEdit;
-}
+};
export class WorkspaceDocumentEditHistory extends Disposable {
private readonly _documentState = new Map();
diff --git a/extensions/copilot/src/platform/inlineEdits/node/inlineEditsModelService.ts b/extensions/copilot/src/platform/inlineEdits/node/inlineEditsModelService.ts
index 84a0dde583b..0066d0d6e10 100644
--- a/extensions/copilot/src/platform/inlineEdits/node/inlineEditsModelService.ts
+++ b/extensions/copilot/src/platform/inlineEdits/node/inlineEditsModelService.ts
@@ -39,7 +39,7 @@ interface ModelConfigurationWithSource extends ModelConfiguration {
type ModelInfo = {
models: ModelConfigurationWithSource[];
currentModelId: string;
-}
+};
export class InlineEditsModelService extends Disposable implements IInlineEditsModelService {
diff --git a/extensions/copilot/src/platform/networking/common/fetch.ts b/extensions/copilot/src/platform/networking/common/fetch.ts
index 5bda09c2f66..02a3f985c14 100644
--- a/extensions/copilot/src/platform/networking/common/fetch.ts
+++ b/extensions/copilot/src/platform/networking/common/fetch.ts
@@ -304,12 +304,12 @@ export type StreamOptions = {
* All other chunks will also include a usage field, but with a null value. NOTE: If the stream is interrupted, you may not receive the final usage chunk which contains the total token usage for the request.
*/
include_usage?: boolean;
-}
+};
export type Prediction = {
type: 'content';
content: string | { type: string; text: string }[];
-}
+};
/** based on https://platform.openai.com/docs/api-reference/chat/create
*
diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts
index 927855b3548..8c70a781c1e 100644
--- a/extensions/copilot/src/platform/networking/common/networking.ts
+++ b/extensions/copilot/src/platform/networking/common/networking.ts
@@ -231,7 +231,7 @@ export type IChatRequestTelemetryProperties = {
parentRequestId?: string;
/** For a subagent: The tool_call_id from the parent agent's LLM response that triggered this subagent invocation. */
parentToolCallId?: string;
-}
+};
export interface ICreateEndpointBodyOptions extends IMakeChatRequestOptions {
requestId: string;
diff --git a/extensions/copilot/src/platform/notebook/common/alternativeContentEditGenerator.ts b/extensions/copilot/src/platform/notebook/common/alternativeContentEditGenerator.ts
index 02bc8e99af0..2ab7f8dd581 100644
--- a/extensions/copilot/src/platform/notebook/common/alternativeContentEditGenerator.ts
+++ b/extensions/copilot/src/platform/notebook/common/alternativeContentEditGenerator.ts
@@ -23,7 +23,7 @@ export type NotebookEditGenerationTelemtryOptions = {
model: Promise | string | undefined;
requestId: string | undefined;
source: NotebookEditGenrationSource;
-}
+};
export enum NotebookEditGenrationSource {
codeMapperEditNotebook = 'codeMapperEditNotebook',
diff --git a/extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts b/extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts
index 8148929bb4d..8861a96de25 100644
--- a/extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts
+++ b/extensions/copilot/src/platform/review/vscode/reviewServiceImpl.ts
@@ -24,8 +24,8 @@ const numberOfReviewCommentsKey = 'github.copilot.chat.review.numberOfComments';
export class ReviewServiceImpl implements IReviewService {
declare _serviceBrand: undefined;
- private _disposables = new DisposableStore();
- private _repositoryDisposables = new DisposableStore();
+ private readonly _disposables = new DisposableStore();
+ private readonly _repositoryDisposables = new DisposableStore();
private _reviewDiffReposString: string | undefined;
private _diagnosticCollection: vscode.DiagnosticCollection | undefined;
private _commentController = vscode.comments.createCommentController('github-copilot-review', 'Code Review');
diff --git a/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts b/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts
index 2074272384d..0bbc00c2509 100644
--- a/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts
+++ b/extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts
@@ -20,7 +20,7 @@ export class BaseMsftTelemetrySender implements IMSFTTelemetrySender {
protected _internalLargeEventTelemetryReporter: ITelemetryReporter | undefined;
private _externalTelemetryReporter: ITelemetryReporter;
- protected _disposables: DisposableStore = new DisposableStore();
+ protected readonly _disposables: DisposableStore = new DisposableStore();
private _username: string | undefined;
private _vscodeTeamMember: boolean = false;
private _sku: string | undefined;
diff --git a/extensions/copilot/src/platform/tfidf/node/test/tfidf.spec.ts b/extensions/copilot/src/platform/tfidf/node/test/tfidf.spec.ts
deleted file mode 100644
index 09eeae8c500..00000000000
--- a/extensions/copilot/src/platform/tfidf/node/test/tfidf.spec.ts
+++ /dev/null
@@ -1,225 +0,0 @@
-/*---------------------------------------------------------------------------------------------
- * Copyright (c) Microsoft Corporation. All rights reserved.
- * Licensed under the MIT License. See License.txt in the project root for license information.
- *--------------------------------------------------------------------------------------------*/
-
-import assert from 'assert';
-import { suite, test } from 'vitest';
-import { URI } from '../../../../util/vs/base/common/uri';
-import { Range } from '../../../../util/vs/editor/common/core/range';
-import { FileChunk } from '../../../chunking/common/chunk';
-import { PersistentTfIdf, TfIdfDoc } from '../tfidf';
-
-/**
- * Generates all permutations of an array.
- *
- * This is useful for testing to make sure order does not effect the result.
- */
-function permutate(arr: T[]): T[][] {
- if (arr.length === 0) {
- return [[]];
- }
-
- const result: T[][] = [];
-
- for (let i = 0; i < arr.length; i++) {
- const rest = [...arr.slice(0, i), ...arr.slice(i + 1)];
- const permutationsRest = permutate(rest);
- for (let j = 0; j < permutationsRest.length; j++) {
- result.push([arr[i], ...permutationsRest[j]]);
- }
- }
-
- return result;
-}
-
-function assertPathsEqual(result: readonly FileChunk[], expected: readonly string[], docs?: TfIdfDoc[]) {
- assert.deepStrictEqual(result.map(x => x.file.path), expected,
- docs ? `Failed for doc order: ${docs.map(x => x.uri.path)}` : undefined);
-}
-
-suite('TF-IDF', () => {
- test('Search should return nothing when empty', async () => {
- const tfidf = new PersistentTfIdf(':memory:');
- tfidf;
- assertPathsEqual(await tfidf.search('something'), []);
- });
-
- test('Search should return nothing for term not in documents ', async () => {
- const tfidf = new PersistentTfIdf(':memory:');
- await tfidf.addOrUpdate([
- testFile('A', 'cat dog fish'),
- ]);
- assertPathsEqual(await tfidf.search('elephant'), []);
- });
-
- test('Should return document with exact match', async () => {
- for (const docs of permutate([
- testFile('A', 'cat dog cat'),
- testFile('B', 'cat fish'),
- ])) {
- const tfidf = new PersistentTfIdf(':memory:');
- await tfidf.addOrUpdate(docs);
- assertPathsEqual(await tfidf.search('dog'), ['/A'], docs);
- }
- });
-
- test('Should return document with more matches first', async () => {
- for (const docs of permutate([
- testFile('/A', 'cat dog cat'),
- testFile('/B', 'cat fish'),
- testFile('/C', 'frog'),
- ])) {
- const tfidf = new PersistentTfIdf(':memory:');
- await tfidf.addOrUpdate(docs);
- assertPathsEqual(await tfidf.search('cat'), ['/A', '/B'], docs);
- }
- });
-
- test('Should return document with more matches first when term appears in all documents', async () => {
- for (const docs of permutate([
- testFile('/A', 'cat dog cat cat'),
- testFile('/B', 'cat fish'),
- testFile('/C', 'frog cat cat'),
- ])) {
- const tfidf = new PersistentTfIdf(':memory:');
- await tfidf.addOrUpdate(docs);
- assertPathsEqual(await tfidf.search('cat'), ['/A', '/C', '/B'], docs);
- }
- });
-
- test('Should weigh less common term higher', async () => {
- for (const docs of permutate([
- testFile('A', 'cat dog cat'),
- testFile('B', 'fish'),
- testFile('C', 'cat cat cat cat'),
- testFile('D', 'cat fish')
- ])) {
- const tfidf = new PersistentTfIdf(':memory:');
- await tfidf.addOrUpdate(docs);
- assertPathsEqual(await tfidf.search('cat the dog'), ['/A', '/C', '/D'], docs);
- }
- });
-
- test('Should ignore case and punctuation', async () => {
- for (const docs of permutate([
- testFile('/A', 'Cat doG.cat'),
- testFile('/B', 'cAt fiSH'),
- testFile('/C', 'frOg'),
- ])) {
- const tfidf = new PersistentTfIdf(':memory:');
- await tfidf.addOrUpdate(docs);
- assertPathsEqual(await tfidf.search('. ,CaT! '), ['/A', '/B'], docs);
- }
- });
-
- test('Should match on camelCase words', async () => {
- for (const docs of permutate([
- testFile('/A', 'catDog cat'),
- testFile('/B', 'fishCatFish'),
- testFile('/C', 'frogcat'),
- ])) {
- const tfidf = new PersistentTfIdf(':memory:');
- await tfidf.addOrUpdate(docs);
- assertPathsEqual(await tfidf.search('catDOG'), ['/A', '/B'], docs);
- }
- });
-
- test('Should not match document after delete', async () => {
- const docA = testFile('/A', 'cat dog cat');
- const docB = testFile('/B', 'cat fish');
- const docC = testFile('/C', 'frog');
-
- const tfidf = new PersistentTfIdf(':memory:');
- await tfidf.addOrUpdate([docA, docB, docC]);
- assertPathsEqual(await tfidf.search('cat'), ['/A', '/B']);
-
- tfidf.delete([docA.uri]);
- assertPathsEqual(await tfidf.search('cat'), ['/B']);
-
- tfidf.delete([docC.uri]);
- assertPathsEqual(await tfidf.search('cat'), ['/B']);
-
- tfidf.delete([docB.uri]);
- assertPathsEqual(await tfidf.search('cat'), []);
- });
-
- test('Should match for snake_case', async () => {
- const docA = testFile('/A', 'cat_dog cat _dog cat_');
- const docB = testFile('/B', 'fish cat bird_horse');
- const docC = testFile('/C', 'fish');
-
- const tfidf = new PersistentTfIdf(':memory:');
- await tfidf.addOrUpdate([docA, docB, docC]);
- assertPathsEqual(await tfidf.search('cat'), ['/A', '/B']);
- assertPathsEqual(await tfidf.search('cat_dog'), ['/A', '/B']);
- assertPathsEqual(await tfidf.search('_dog'), ['/A']);
- assertPathsEqual(await tfidf.search('cat_'), ['/A', '/B']);
- assertPathsEqual(await tfidf.search('fish_cat'), ['/A', '/B', '/C']);
- assertPathsEqual(await tfidf.search('man_bear_pig'), []);
-
- // Make sure snake case is broken up too for searches
- assertPathsEqual(await tfidf.search('bird_horse'), ['/B']);
- assertPathsEqual(await tfidf.search('bird'), ['/B']);
- assertPathsEqual(await tfidf.search('horse'), ['/B']);
- });
-
- test('Should match with leading/trailing underscores', async () => {
- const docA = testFile('/A', '_cat dog_');
- const docB = testFile('/B', 'fish');
-
- const tfidf = new PersistentTfIdf(':memory:');
- await tfidf.addOrUpdate([docA, docB]);
- assertPathsEqual(await tfidf.search('cat'), ['/A']);
- assertPathsEqual(await tfidf.search('_cat'), ['/A']);
- assertPathsEqual(await tfidf.search('cat_'), ['/A']);
- assertPathsEqual(await tfidf.search('dog'), ['/A']);
- assertPathsEqual(await tfidf.search('_dog'), ['/A']);
- assertPathsEqual(await tfidf.search('dog_'), ['/A']);
- });
-
- test('Should match words with digits', async () => {
- const docA = testFile('/A', 'cat2 dog');
- const docB = testFile('/B', 'fish cat2fish bi2rd');
- const docC = testFile('/C', 'fish3');
-
- const tfidf = new PersistentTfIdf(':memory:');
- await tfidf.addOrUpdate([docA, docB, docC]);
- assertPathsEqual(await tfidf.search('cat2'), ['/A']);
- assertPathsEqual(await tfidf.search('cat2fish'), ['/B']);
- assertPathsEqual(await tfidf.search('fish'), ['/B', '/C']); // Should also match fish3
- });
-
- test('Should match words using $', async () => {
- const docA = testFile('/A', '$cat dog');
- const docB = testFile('/B', 'fish cat');
- const docC = testFile('/C', 'cat$ dog$cat');
-
- const tfidf = new PersistentTfIdf(':memory:');
- await tfidf.addOrUpdate([docA, docB, docC]);
- assertPathsEqual(await tfidf.search('$cat'), ['/A']);
- assertPathsEqual(await tfidf.search('dog$cat'), ['/C']);
- });
-
- test('Should match on function calls', async () => {
- const docA = testFile('/A', 'cat() dog');
- const docB = testFile('/B', 'fish cat');
- const docC = testFile('/C', 'fish');
-
- const tfidf = new PersistentTfIdf(':memory:');
- await tfidf.addOrUpdate([docA, docB, docC]);
- assertPathsEqual(await tfidf.search('cat()'), ['/A', '/B']);
- assertPathsEqual(await tfidf.search('cat'), ['/A', '/B']);
- });
-});
-
-function testFile(path: string, content: string): TfIdfDoc {
- const uri = URI.file(path);
- return {
- uri,
- async getContentVersionId() { return '123'; },
- async getChunks() {
- return [{ file: uri, text: content, rawText: content, range: Range.lift({ startColumn: 0, startLineNumber: 0, endColumn: 0, endLineNumber: 0 }) }];
- },
- };
-}
diff --git a/extensions/copilot/src/platform/tfidf/node/tfidf.ts b/extensions/copilot/src/platform/tfidf/node/tfidf.ts
deleted file mode 100644
index 278ddebd0ac..00000000000
--- a/extensions/copilot/src/platform/tfidf/node/tfidf.ts
+++ /dev/null
@@ -1,576 +0,0 @@
-/*---------------------------------------------------------------------------------------------
- * Copyright (c) Microsoft Corporation. All rights reserved.
- * Licensed under the MIT License. See License.txt in the project root for license information.
- *--------------------------------------------------------------------------------------------*/
-
-import fs from 'fs';
-import sql from 'node:sqlite';
-import path from 'path';
-import { GlobIncludeOptions, shouldInclude } from '../../../util/common/glob';
-import { Limiter } from '../../../util/vs/base/common/async';
-import { Iterable } from '../../../util/vs/base/common/iterator';
-import { ResourceMap, ResourceSet } from '../../../util/vs/base/common/map';
-import { Schemas } from '../../../util/vs/base/common/network';
-import { URI } from '../../../util/vs/base/common/uri';
-import { Range } from '../../../util/vs/editor/common/core/range';
-import { FileChunk } from '../../chunking/common/chunk';
-
-type SparseEmbedding = Map* word */ string, /* weight */number>;
-type TermFrequencies = Record* word */ string, /*occurrences*/ number>;
-
-function countRecordFrom(values: Iterable): Record {
- const map = Object.create(null);
- for (const value of values) {
- map[value] = (map[value] ?? 0) + 1;
- }
- return map;
-}
-
-/**
- * Count how many times each term (word) appears in a string.
- */
-function termFrequencies(input: string): TermFrequencies {
- return countRecordFrom(splitTerms(input));
-}
-
-/**
- * Break a string into terms (words).
- */
-function* splitTerms(input: string): Iterable {
- const normalize = (word: string) => word.toLowerCase();
-
- // Only match on words that are at least 3 characters long and start with a letter
- for (const [word] of input.matchAll(/(?();
- parts.add(normalize(word));
-
- const subParts: string[] = [];
- const camelParts = word.split(/(?<=[a-z$])(?=[A-Z])/g);
- if (camelParts.length > 1) {
- subParts.push(...camelParts);
- }
-
- const snakeParts = word.split('_');
- if (snakeParts.length > 1) {
- subParts.push(...snakeParts);
- }
-
- const nonDigitPrefixMatch = word.match(/^([\D]+)\p{Number}+$/u);
- if (nonDigitPrefixMatch) {
- subParts.push(nonDigitPrefixMatch[1]);
- }
-
- for (const part of subParts) {
- // Require at least 3 letters in the sub parts
- if (part.length > 2 && /[\p{Alphabetic}_$]{3,}/gu.test(part)) {
- parts.add(normalize(part));
- }
- }
-
- yield* parts;
- }
-}
-
-/**
- * A very simple heap implementation that keeps the top `maxSize` elements.
- */
-class SimpleHeap {
-
- private readonly store: Array<{ readonly score: number; readonly value: T }> = [];
-
- constructor(
- private readonly maxSize: number,
- private minScore = -Infinity,
- ) { }
-
- toArray(maxSpread?: number): T[] {
- if (this.store.length && typeof maxSpread === 'number') {
- const minScore = this.store.at(0)!.score * (1.0 - maxSpread);
- return this.store.filter(x => x.score >= minScore).map(x => x.value);
- }
- return this.store.map(x => x.value);
- }
-
- add(score: number, value: T) {
- if (score <= this.minScore) {
- return;
- }
-
- const index = this.store.findIndex(entry => entry.score < score);
- this.store.splice(index >= 0 ? index : this.store.length, 0, { score, value });
- while (this.store.length > this.maxSize) {
- this.store.pop();
- }
-
- if (this.store.length === this.maxSize) {
- this.minScore = this.store.at(-1)?.score ?? this.minScore;
- }
- }
-}
-
-interface DocumentChunkEntry {
- readonly chunk: FileChunk;
- readonly tf: TermFrequencies;
-}
-
-export interface TfIdfDoc {
- readonly uri: URI;
- getContentVersionId(): Promise;
- getChunks(): Promise>;
-}
-
-export interface TfIdfSearchOptions {
- /** Glob pattern for files to include/exclude */
- readonly globPatterns?: GlobIncludeOptions;
-
- /** Maximum number of results to return. If not specified returns as many results as possible */
- readonly maxResults?: number;
-
- /**
- * Maximum range of result scores.
- *
- * This is a multiplier. With a value of `0.7` for instance, all returned results must have a score >= `results[0].score * (1 - 0.7)`
- */
- readonly maxSpread?: number;
-}
-
-interface TfIdfDocData {
- readonly contentVersionId: string;
- readonly chunks: readonly DocumentChunkEntry[];
-}
-
-/**
- * Implementation of tf-idf (term frequency–inverse document frequency) for a set of documents where
- * each document contains one or more chunks of text.
- *
- * This implementation uses SQLite to store the documents and their chunks. This lets us scale up to a large
- * number of documents and chunks.
- */
-export class PersistentTfIdf {
-
- private readonly db!: sql.DatabaseSync;
-
- constructor(dbPath: URI | ':memory:') {
- const syncOptions: sql.DatabaseSyncOptions = {
- open: true,
- enableForeignKeyConstraints: true
- };
-
- if (dbPath !== ':memory:' && dbPath.scheme === Schemas.file) {
- try {
- fs.mkdirSync(path.dirname(dbPath.fsPath), { recursive: true });
- this.db = new sql.DatabaseSync(dbPath.fsPath, syncOptions);
- } catch (e) {
- console.error('Failed to open SQLite database on disk. Trying memory db', e);
- }
- }
-
- // Try falling back to an in-memory database
- if (!this.db) {
- this.db = new sql.DatabaseSync(':memory:', syncOptions);
- }
-
- this.db.exec(`
- PRAGMA journal_mode = OFF;
- PRAGMA synchronous = 0;
- PRAGMA cache_size = 1000000;
- PRAGMA locking_mode = EXCLUSIVE;
- PRAGMA temp_store = MEMORY;
- `);
-
- this.db.exec(`
- CREATE TABLE IF NOT EXISTS Documents (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- uri TEXT,
- contentVersionId TEXT NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS Chunks (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- documentId INTEGER NOT NULL,
- text TEXT NOT NULL,
- startLineNumber INTEGER NOT NULL,
- startColumn INTEGER NOT NULL,
- endLineNumber INTEGER NOT NULL,
- endColumn INTEGER NOT NULL,
- isFullFile INTEGER NOT NULL,
- termFrequencies BLOB NOT NULL, -- JSONB object storing term frequencies
- FOREIGN KEY (documentId) REFERENCES Documents(id) ON DELETE CASCADE
- );
-
- CREATE TABLE IF NOT EXISTS ChunkOccurrences (
- term TEXT PRIMARY KEY,
- chunkCount INTEGER NOT NULL
- );
-
- CREATE INDEX IF NOT EXISTS idx_documents_uri ON Documents(uri);
- CREATE INDEX IF NOT EXISTS idx_chunks_documentId ON Chunks(documentId);
- `);
- }
-
- /**
- * @returns a list of URIs that are out of sync and need to be re-indexed.
- */
- initialize(workspaceDocsIn: Iterable<{ readonly uri: URI; readonly contentId: string }>): { deletedDocs: ResourceSet; newDocs: ResourceSet; outOfSyncDocs: ResourceSet } {
- const inDocsToContentIds = new ResourceMap();
- for (const { uri, contentId } of workspaceDocsIn) {
- inDocsToContentIds.set(uri, contentId);
- }
-
- const allDbDocs = this.db.prepare(
- 'SELECT * FROM Documents'
- ).all();
-
- const dbDocsToContentIds = new ResourceMap();
- for (const docEntry of allDbDocs) {
- try {
- const uri = URI.parse(docEntry.uri as string);
- dbDocsToContentIds.set(uri, docEntry.contentVersionId as string);
- } catch (e) {
- console.error(`Failed to parse URI from database entry: ${docEntry.uri}`, e);
- }
- }
-
- // Build list of documents that are out of sync, new, or deleted
- const deletedDocs = new ResourceSet();
- const outOfSyncDocs = new ResourceSet();
-
- for (const [dbDocUri, dbDocContentId] of dbDocsToContentIds) {
- const inDocContentId = inDocsToContentIds.get(dbDocUri);
- if (!inDocContentId) {
- // Document is not in the workspace anymore
- deletedDocs.add(dbDocUri);
- } else if (inDocContentId !== dbDocContentId) {
- outOfSyncDocs.add(dbDocUri);
- }
- }
-
- // Any new docs in the input that aren't in the db
- const newDocs = new ResourceSet();
- for (const uri of inDocsToContentIds.keys()) {
- if (!dbDocsToContentIds.has(uri)) {
- newDocs.add(uri);
- }
- }
-
- this.delete(Array.from(deletedDocs));
-
- return { outOfSyncDocs, newDocs, deletedDocs };
- }
-
- private async isUpToDate(toCheck: TfIdfDoc): Promise {
- return this.getDocContentVersionId(toCheck.uri) === await toCheck.getContentVersionId();
- }
-
- private getDocContentVersionId(uri: URI): string | undefined {
- const result = this.db.prepare(
- 'SELECT contentVersionId FROM Documents WHERE uri = ?'
- ).get(uri.toString());
- return result?.contentVersionId as string | undefined;
- }
-
- public async addOrUpdate(documents: readonly TfIdfDoc[]): Promise {
- const chunkLimiter = new Limiter>(20);
- try {
- const toUpdate = await Promise.all(documents.map(async doc => {
- try {
- if (await this.isUpToDate(doc)) {
- return;
- }
-
- return {
- uri: doc.uri,
- getDoc: async () => {
- const chunks: Array = [];
- for (const chunk of await chunkLimiter.queue(() => doc.getChunks())) {
- // TODO: See if we can compute the tf lazily
- // The challenge is that we need to also update the `chunkOccurrences`
- // and all of those updates need to get flushed before the real tfidf of
- // anything is computed.
- const tf = termFrequencies(chunk.text);
- chunks.push({ chunk, tf });
- }
- return ({ contentVersionId: await doc.getContentVersionId(), chunks });
- }
- };
- } catch {
- // noop
- }
- }));
-
- await this.addOrUpdateDocs(toUpdate.filter((doc): doc is any => !!doc));
- } finally {
- chunkLimiter.dispose();
- }
- }
-
- public delete(uris: Iterable): void {
- this.db.exec('BEGIN TRANSACTION');
- for (const uri of uris) {
- const doc = this.getDoc(uri);
- if (!doc) {
- continue;
- }
-
- this.db.prepare(`
- DELETE FROM Documents WHERE uri = ?
- `).run(uri.toString());
-
- this._cachedChunkCount = undefined;
-
- const allOccurrences = countRecordFrom(doc.chunks.flatMap(chunk => Object.keys(chunk.tf)));
-
- for (const [term, count] of Object.entries(allOccurrences)) {
- this.db.prepare(`
- UPDATE ChunkOccurrences
- SET chunkCount = chunkCount - ?
- WHERE term = ?;
- `).run(count, term);
- }
- }
- this.db.exec('COMMIT');
-
- this.db.prepare(`
- DELETE FROM ChunkOccurrences
- WHERE chunkCount < 1;
- `).run();
- }
-
- public get fileCount(): number {
- return this.db.prepare(
- `SELECT COUNT(*) as count FROM Documents`
- ).get()!.count as number | undefined ?? 0;
- }
-
- /**
- * Rank the documents by their cosine similarity to a set of search queries.
- */
- public async search(query: string, options?: TfIdfSearchOptions): Promise {
- const heap = new SimpleHeap(options?.maxResults ?? Infinity, -Infinity);
-
- const queryEmbeddings = this.computeEmbeddings(query);
- if (!queryEmbeddings.size) {
- return [];
- }
-
- const idfCache = new Map();
- for (const entry of await this.getAllChunksWithTerms(Array.from(queryEmbeddings.keys()))) {
- if (!shouldInclude(entry.chunk.file, options?.globPatterns)) {
- continue;
- }
-
- const score = this.score(entry, queryEmbeddings, idfCache);
- if (score > 0) {
- heap.add(score, entry.chunk);
- }
- }
-
- return heap.toArray(options?.maxSpread);
- }
-
- private computeEmbeddings(input: string): SparseEmbedding {
- const tf = termFrequencies(input);
- return this.computeTfidf(tf);
- }
-
- private score(chunk: DocumentChunkEntry, queryEmbedding: SparseEmbedding, idfCache: Map): number {
- // Compute the dot product between the chunk's embedding and the query embedding
-
- // Note that the chunk embedding is computed lazily on a per-term basis.
- // This lets us skip a large number of calculations because the majority
- // of chunks do not share any terms with the query.
-
- let sum = 0;
- for (const [term, termTfidf] of queryEmbedding.entries()) {
- const chunkTf = chunk.tf[term];
- if (!chunkTf) {
- // Term does not appear in chunk so it has no contribution
- continue;
- }
-
- let chunkIdf = idfCache.get(term);
- if (typeof chunkIdf !== 'number') {
- chunkIdf = this.idf(term);
- idfCache.set(term, chunkIdf);
- }
-
- const chunkTfidf = chunkTf * chunkIdf;
- sum += chunkTfidf * termTfidf;
- }
- return sum;
- }
-
- private idf(term: string): number {
- const chunkOccurrences = this.getChunkOccurrences(term) ?? 0;
- return chunkOccurrences > 0
- ? Math.log((this.getChunkCount() + 1) / chunkOccurrences)
- : 0;
- }
-
- private computeTfidf(termFrequencies: TermFrequencies): SparseEmbedding {
- const embedding = new Map();
- for (const [word, occurrences] of Object.entries(termFrequencies)) {
- const idf = this.idf(word);
- if (idf > 0) {
- embedding.set(word, occurrences * idf);
- }
- }
- return embedding;
- }
-
- private _cachedChunkCount: number | undefined;
-
- private getChunkCount(): number {
- if (typeof this._cachedChunkCount === 'number') {
- return this._cachedChunkCount;
- }
-
- const result = this.db.prepare(
- 'SELECT COUNT(*) as count FROM Chunks'
- ).get();
- return result?.count as number | undefined ?? 0;
- }
-
- private getChunkOccurrences(term: string): number {
- const result = this.db.prepare(
- 'SELECT chunkCount FROM ChunkOccurrences WHERE term = ?'
- ).get(term);
- return result?.chunkCount as number | undefined ?? 0;
- }
-
- private async addOrUpdateDocs(docs: Iterable<{ uri: URI; getDoc(): Promise }>): Promise {
- this._cachedChunkCount = undefined;
-
- // Track this for the entire set of documents so we can do a single update
- const allChunkOccurrences: Record = Object.create(null);
-
- const processBatch = (docs: ReadonlyArray<{ uri: URI; doc: TfIdfDocData }>) => {
- // Delete existing documents
- // This should also clear the chunks and terms due to the foreign key constraints
- this.delete(docs.map(doc => doc.uri));
-
- this.db.exec('BEGIN TRANSACTION');
- try {
- for (const { uri, doc } of docs) {
- // Add new the document
- const docId = this.db.prepare(
- 'INSERT OR REPLACE INTO Documents (uri, contentVersionId) VALUES (?, ?)'
- )
- .run(uri.toString(), doc.contentVersionId)
- .lastInsertRowid;
-
- // Insert new chunks
- const insertChunkOp = this.db.prepare(
- 'INSERT INTO Chunks (documentId, text, startLineNumber, startColumn, endLineNumber, endColumn, isFullFile, termFrequencies) VALUES (?, ?, ?, ?, ?, ?, ?, jsonb(?))'
- );
-
- for (const chunk of doc.chunks) {
- insertChunkOp.run(
- docId,
- chunk.chunk.text,
- chunk.chunk.range.startLineNumber,
- chunk.chunk.range.startColumn,
- chunk.chunk.range.endLineNumber,
- chunk.chunk.range.endColumn,
- chunk.chunk.isFullFile ? 1 : 0,
- JSON.stringify(chunk.tf),
- );
-
- for (const term of Object.keys(chunk.tf)) {
- allChunkOccurrences[term] = (allChunkOccurrences[term] ?? 0) + 1;
- }
- }
- }
-
- this.db.exec('COMMIT');
- } catch (e) {
- this.db.exec('ROLLBACK');
- throw e;
- }
- };
-
- const batchSize = 200;
- const batch: Array<{ uri: URI; doc: TfIdfDocData }> = [];
- for (const doc of docs) {
- batch.push({ uri: doc.uri, doc: await doc.getDoc() });
- if (batch.length >= batchSize) {
- processBatch(batch);
- batch.length = 0;
- }
- }
-
- // Process any remaining documents
- processBatch(batch);
-
- // Update occurrences list
- const insertOccurrencesOp = this.db.prepare(`
- INSERT INTO ChunkOccurrences (term, chunkCount)
- VALUES (?, ?)
- ON CONFLICT(term) DO UPDATE SET chunkCount = chunkCount + ?;
- `);
-
- this.db.exec('BEGIN TRANSACTION');
- for (const [term, count] of Object.entries(allChunkOccurrences)) {
- insertOccurrencesOp.run(term, count, count);
- }
- this.db.exec('COMMIT');
- }
-
- private getDoc(uri: URI): TfIdfDocData | undefined {
- const doc = this.db.prepare(
- 'SELECT id, contentVersionId FROM Documents WHERE uri = ?'
- ).get(uri.toString());
- if (!doc) {
- return undefined;
- }
-
- const chunks = this.db.prepare(
- 'SELECT text, startLineNumber, startColumn, endLineNumber, endColumn, isFullFile, json(termFrequencies) as termFrequencies FROM Chunks WHERE documentId = ?'
- ).all(doc.id);
- return {
- contentVersionId: doc.contentVersionId as string,
- chunks: chunks.map(row => {
- return this.reviveDocumentChunkEntry({ ...row, uri: uri.toString() });
- })
- };
- }
-
- private async getAllChunksWithTerms(searchTerms: readonly string[]): Promise> {
- if (!searchTerms.length) {
- return [];
- }
-
- const chunkResults = this.db.prepare(`
- SELECT c.id, c.documentId, c.text, c.startLineNumber, c.startColumn, c.endLineNumber, c.endColumn, c.isFullFile,
- json(c.termFrequencies) as termFrequencies, d.uri
- FROM Chunks c
- JOIN Documents d ON c.documentId = d.id
- WHERE EXISTS (
- SELECT 1 FROM json_each(c.termFrequencies)
- WHERE json_each.key IN (${searchTerms.map(_ => `?`).join(',')})
- )
- `).all(...searchTerms);
-
- return Iterable.map(chunkResults, row => this.reviveDocumentChunkEntry(row));
- }
-
- private reviveDocumentChunkEntry(row: any): DocumentChunkEntry {
- return {
- tf: JSON.parse(row.termFrequencies as string),
- get chunk() {
- return {
- file: URI.isUri(row.uri) ? row.uri : URI.parse(row.uri as string),
- text: row.text as string,
- rawText: row.text,
- range: new Range(
- row.startLineNumber as number,
- row.startColumn as number,
- row.endLineNumber as number,
- row.endColumn as number
- ),
- isFullFile: Boolean(row.isFullFile)
- };
- }
- };
- }
-}
diff --git a/extensions/copilot/src/platform/tfidf/node/tfidfMessaging.ts b/extensions/copilot/src/platform/tfidf/node/tfidfMessaging.ts
deleted file mode 100644
index db83f6da6f7..00000000000
--- a/extensions/copilot/src/platform/tfidf/node/tfidfMessaging.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/*---------------------------------------------------------------------------------------------
- * Copyright (c) Microsoft Corporation. All rights reserved.
- * Licensed under the MIT License. See License.txt in the project root for license information.
- *--------------------------------------------------------------------------------------------*/
-
-
-export function rewriteObject(value: any, transform: (obj: object) => object | undefined): any {
- if (!value) {
- return value;
- }
-
- if (Array.isArray(value)) {
- return value.map(x => rewriteObject(x, transform));
- }
-
- if (typeof value === 'object') {
- const t = transform(value);
- if (t) {
- return t;
- }
-
- const newValue: { [key: string]: any } = {};
- for (const key in value) {
- newValue[key] = rewriteObject(value[key], transform);
- }
- return newValue;
- }
-
- return value;
-}
diff --git a/extensions/copilot/src/platform/tfidf/node/tfidfWorker.ts b/extensions/copilot/src/platform/tfidf/node/tfidfWorker.ts
deleted file mode 100644
index 050bb5855ef..00000000000
--- a/extensions/copilot/src/platform/tfidf/node/tfidfWorker.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-/*---------------------------------------------------------------------------------------------
- * Copyright (c) Microsoft Corporation. All rights reserved.
- * Licensed under the MIT License. See License.txt in the project root for license information.
- *--------------------------------------------------------------------------------------------*/
-import { MessagePort, parentPort, workerData } from 'worker_threads';
-import { createRpcProxy, RcpResponseHandler, RpcProxy, RpcRequest, RpcResponse } from '../../../util/node/worker';
-import { CancellationToken } from '../../../util/vs/base/common/cancellation';
-import { Iterable } from '../../../util/vs/base/common/iterator';
-import { Lazy } from '../../../util/vs/base/common/lazy';
-import { ResourceMap } from '../../../util/vs/base/common/map';
-import { StopWatch } from '../../../util/vs/base/common/stopwatch';
-import { URI, UriComponents } from '../../../util/vs/base/common/uri';
-import { IRange, Range } from '../../../util/vs/editor/common/core/range';
-import { FileChunk } from '../../chunking/common/chunk';
-import { NaiveChunker } from '../../chunking/node/naiveChunker';
-import { NullTelemetryService } from '../../telemetry/common/nullTelemetryService';
-import { TokenizationEndpoint, TokenizerProvider } from '../../tokenizer/node/tokenizer';
-import { PersistentTfIdf, TfIdfDoc, TfIdfSearchOptions } from './tfidf';
-import { rewriteObject } from './tfidfMessaging';
-
-export interface TfIdfWorkerData {
- readonly endpoint: TokenizationEndpoint;
- readonly dbPath: ':memory:' | UriComponents;
-}
-
-type Values = T[keyof T];
-
-type Methods = {
- [K in keyof T]: T[K] extends ((...args: any[]) => any) ? T[K] : never;
-};
-
-type Message = Values<{
- [K in keyof Api]: Api[K] extends ((...args: any[]) => any) ? { id: number; fn: K; args: Parameters } : never;
-}>;
-
-function isIRange(obj: any): obj is IRange {
- return obj && typeof obj.startLineNumber === 'number' && typeof obj.startColumn === 'number' && typeof obj.endLineNumber === 'number' && typeof obj.endColumn === 'number';
-}
-
-function serialize(value: any): any {
- return rewriteObject(value, obj => {
- if (URI.isUri(obj)) {
- return { $mid: 'uri', ...obj };
- }
- if (isIRange(obj)) {
- return { $mid: 'range', ...obj } as IRange;
- }
- });
-}
-
-function revive(value: T): T {
- return rewriteObject(value, (obj: any) => {
- if (obj['$mid'] === 'uri') {
- return URI.from(obj as any);
- }
- });
-}
-
-export interface WorkerFileDoc {
- readonly uri: URI;
- readonly hash: string;
- readonly content: string;
-}
-
-type TfIdfOperation = 'update' | 'delete';
-
-class Host {
- private readonly _handler = new RcpResponseHandler();
-
- public readonly proxy: RpcProxy;
-
- constructor(port: MessagePort, impl: TfidfWorker) {
- this.proxy = createRpcProxy((name, args) => {
- const { id, result } = this._handler.createHandler();
- port.postMessage({ id, fn: name, args } satisfies RpcRequest);
- return result;
- });
-
- port.on('message', async (msg: Message | RpcRequest) => {
- if ('fn' in msg) {
- try {
- const res = await ((impl as any)[msg.fn] as any)(...revive(msg.args));
- port.postMessage({ id: msg.id, res: serialize(res) } satisfies RpcResponse);
- } catch (err) {
- port.postMessage({ id: msg.id, err } satisfies RpcResponse);
- }
- } else {
- this._handler.handleResponse(msg);
- }
- });
- }
-}
-
-export interface TfidfSearchResults {
- results: readonly FileChunk[];
- telemetry: {
- readonly fileCount: number;
- readonly updatedFileCount: number;
-
- readonly updateTime: number;
- readonly searchTime: number;
- };
-}
-
-export interface TfIdfInitializeTelemetry {
- readonly outOfSyncFileCount: number;
- readonly newFileCount: number;
- readonly deletedFileCount: number;
-}
-
-class TfidfWorker {
-
- private readonly _tfIdf: PersistentTfIdf;
- private readonly _pendingChanges = new ResourceMap();
-
- private readonly _chunker: NaiveChunker;
-
- private readonly _host: Host;
-
- constructor(port: MessagePort, workerData: TfIdfWorkerData) {
- this._tfIdf = new PersistentTfIdf(workerData.dbPath === ':memory:' ? ':memory:' : URI.from(workerData.dbPath));
- this._chunker = new NaiveChunker(workerData.endpoint, new TokenizerProvider(false, new NullTelemetryService()));
- this._host = new Host(port, this);
- }
-
- initialize(workspaceDocsIn: ReadonlyArray<{ uri: UriComponents; contentId: string }>): TfIdfInitializeTelemetry {
- const { outOfSyncDocs, newDocs, deletedDocs } = this._tfIdf.initialize(workspaceDocsIn.map(entry => ({
- uri: URI.from(entry.uri),
- contentId: entry.contentId,
- })));
-
- // Defer actually updating any out of sync docs until we need to do a search
- for (const uri of Iterable.concat(outOfSyncDocs, newDocs)) {
- this._pendingChanges.set(uri, 'update');
- }
-
- return {
- newFileCount: newDocs.size,
- outOfSyncFileCount: outOfSyncDocs.size,
- deletedFileCount: deletedDocs.size
- };
- }
-
- addOrUpdate(documents: readonly UriComponents[]): void {
- for (const uri of documents) {
- const revivedUri = URI.from(uri);
- this._pendingChanges.set(revivedUri, 'update');
- }
- }
-
- delete(uris: readonly UriComponents[]): void {
- for (const uri of uris) {
- const revivedUri = URI.from(uri);
- this._pendingChanges.set(revivedUri, 'delete');
- }
- }
-
- async search(query: string, options?: TfIdfSearchOptions): Promise {
- const sw = new StopWatch();
-
- const updatedFileCount = this._pendingChanges.size;
- await this._flushPendingChanges();
- const updateTime = sw.elapsed();
-
- sw.reset();
- const results = await this._tfIdf.search(query, options);
- const searchTime = sw.elapsed();
-
- return {
- results: results,
- telemetry: {
- fileCount: this._tfIdf.fileCount,
- updatedFileCount,
- updateTime,
- searchTime,
- }
- };
- }
-
- private async _flushPendingChanges(): Promise {
- if (!this._pendingChanges.size) {
- return;
- }
-
- const toDelete = Array.from(
- Iterable.filter(this._pendingChanges.entries(), ([_uri, op]) => op === 'delete'),
- ([uri]) => uri
- );
- this._tfIdf.delete(toDelete);
-
- const updatedDocs = Array.from(
- Iterable.filter(this._pendingChanges.entries(), ([_uri, op]) => op === 'update'),
- ([uri]): TfIdfDoc => {
- const contentVersionId = new Lazy(() => this._host.proxy.getContentVersionId(uri));
- return {
- uri: uri,
- getContentVersionId: () => contentVersionId.value,
- getChunks: async () => this.getRawNaiveChunks(uri, await this._host.proxy.readFile(uri), CancellationToken.None)
- };
- }
- );
-
- if (updatedDocs.length) {
- await this._tfIdf.addOrUpdate(updatedDocs);
- }
-
- this._pendingChanges.clear();
- }
-
- private async getRawNaiveChunks(uri: URI, text: string, token: CancellationToken): Promise> {
- try {
- const naiveChunks = await this._chunker.chunkFile(uri, text, {}, token);
- return Iterable.map(naiveChunks, (e): FileChunk => {
- return {
- file: uri,
- text: e.text,
- rawText: e.rawText,
- range: Range.lift(e.range),
- isFullFile: e.isFullFile
- };
- });
- } catch (e) {
- console.error(`Could not chunk: ${uri}`, e);
- return [];
- }
- }
-}
-
-export type TfidfWorkerApi = Methods;
-
-export interface TfidfHostApi {
- getContentVersionId(uri: URI): Promise;
- readFile(uri: URI): Promise;
-}
-
-// #region Main
-
-const port = parentPort;
-if (!port) {
- throw new Error(`This module should only be used in a worker thread.`);
-}
-
-if (!workerData) {
- throw new Error(`Expected 'workerData' to be provided to the worker thread.`);
-}
-
-new TfidfWorker(port, workerData as TfIdfWorkerData);
-
-// #endregion
diff --git a/extensions/copilot/src/platform/thinking/common/thinking.ts b/extensions/copilot/src/platform/thinking/common/thinking.ts
index 60d71b85ed7..f61ef2af8dd 100644
--- a/extensions/copilot/src/platform/thinking/common/thinking.ts
+++ b/extensions/copilot/src/platform/thinking/common/thinking.ts
@@ -46,7 +46,7 @@ export type EncryptedThinkingDelta = {
id: string;
text?: string;
encrypted: string;
-}
+};
export function isEncryptedThinkingDelta(delta: ThinkingDelta | EncryptedThinkingDelta): delta is EncryptedThinkingDelta {
return (delta as EncryptedThinkingDelta).encrypted !== undefined;
diff --git a/extensions/copilot/src/util/node/worker.ts b/extensions/copilot/src/util/node/worker.ts
index 425387c2bb5..0fface63bf6 100644
--- a/extensions/copilot/src/util/node/worker.ts
+++ b/extensions/copilot/src/util/node/worker.ts
@@ -60,7 +60,7 @@ export class RcpResponseHandler {
export type RpcProxy = {
[K in keyof ProxyType]: ProxyType[K] extends ((...args: infer Args) => infer R) ? (...args: Args) => Promise> : never;
-}
+};
export function createRpcProxy(remoteCall: (name: string, args: any[]) => Promise): RpcProxy {
const handler = {
diff --git a/extensions/copilot/test/base/simulationOptions.ts b/extensions/copilot/test/base/simulationOptions.ts
index ad45fd20186..84f5d40fcc8 100644
--- a/extensions/copilot/test/base/simulationOptions.ts
+++ b/extensions/copilot/test/base/simulationOptions.ts
@@ -14,7 +14,7 @@ export type NesDatagen = {
readonly output: string | undefined;
readonly rowOffset: number;
readonly workerMode: boolean;
-}
+};
export class SimulationOptions {
public static fromProcessArgs(): SimulationOptions {
diff --git a/extensions/copilot/test/pipeline/alternativeAction/types.ts b/extensions/copilot/test/pipeline/alternativeAction/types.ts
index 40e8733bc6b..51e8bce7472 100644
--- a/extensions/copilot/test/pipeline/alternativeAction/types.ts
+++ b/extensions/copilot/test/pipeline/alternativeAction/types.ts
@@ -18,7 +18,7 @@ export type IData = {
isInlineCompletion: boolean;
};
suggestionStatus: NextEditTelemetryStatus;
-}
+};
export namespace NextUserEdit {
export type t = {
@@ -36,7 +36,7 @@ export namespace Recording {
relativePath: string;
originalOpIdx: number;
};
- }
+ };
}
export namespace SuggestedEdit {
@@ -45,7 +45,7 @@ export namespace SuggestedEdit {
edit: ISerializedEdit;
scoreCategory: 'nextEdit';
score: number;
- }
+ };
}
export namespace Scoring {
diff --git a/extensions/copilot/test/pipeline/pipeline.ts b/extensions/copilot/test/pipeline/pipeline.ts
index 0a17601a79c..2e05f9e33e1 100644
--- a/extensions/copilot/test/pipeline/pipeline.ts
+++ b/extensions/copilot/test/pipeline/pipeline.ts
@@ -40,7 +40,7 @@ export type RunPipelineOptions = {
readonly configFile: string | undefined;
readonly verbose: number | boolean | undefined;
readonly parallelism: number;
-}
+};
export async function runInputPipeline(opts: RunPipelineOptions, log = console.log.bind(console)): Promise {
const nesDatagenOpts = opts.nesDatagen!;
diff --git a/extensions/copilot/test/testVisualizationRunner.ts b/extensions/copilot/test/testVisualizationRunner.ts
index 2b53cb723d2..38fa828ca82 100644
--- a/extensions/copilot/test/testVisualizationRunner.ts
+++ b/extensions/copilot/test/testVisualizationRunner.ts
@@ -25,7 +25,7 @@ function run(args: { fileName: string; path: string[] }) {
runCurrentTest();
}
-const g = globalThis as any as IDebugValueEditorGlobals & IPlaygroundRunnerGlobals;
+const g = globalThis as unknown as IDebugValueEditorGlobals & IPlaygroundRunnerGlobals;
g.$$playgroundRunner_data = { currentPath: [] };
// The timeout seems to fix a deadlock-issue of tsx, when the run function is called from the debugger.
diff --git a/extensions/copilot/test/testVisualizationRunnerSTest.ts b/extensions/copilot/test/testVisualizationRunnerSTest.ts
index bdf1bd81954..8f711a37c5b 100644
--- a/extensions/copilot/test/testVisualizationRunnerSTest.ts
+++ b/extensions/copilot/test/testVisualizationRunnerSTest.ts
@@ -18,7 +18,7 @@ function run(args: { fileName: string; path: string }) {
runCurrentTest();
}
-const g = globalThis as any as IDebugValueEditorGlobals & IPlaygroundRunnerGlobals;
+const g = globalThis as unknown as IDebugValueEditorGlobals & IPlaygroundRunnerGlobals;
g.$$playgroundRunner_data = { currentPath: [] };
diff --git a/extensions/git/package.json b/extensions/git/package.json
index 0af7ea21268..17ddbced72d 100644
--- a/extensions/git/package.json
+++ b/extensions/git/package.json
@@ -2770,7 +2770,7 @@
{
"command": "git.openChange",
"group": "navigation@2",
- "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && scmActiveResourceHasChanges"
+ "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && scmActiveResourceHasChanges && !isSessionsWindow"
},
{
"command": "git.stashApplyEditor",
@@ -2786,37 +2786,37 @@
{
"command": "git.stage",
"group": "2_git@1",
- "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && git.activeResourceHasUnstagedChanges"
+ "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && git.activeResourceHasUnstagedChanges && !isSessionsWindow"
},
{
"command": "git.unstage",
"group": "2_git@2",
- "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && git.activeResourceHasStagedChanges"
+ "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && !isInDiffEditor && !isMergeEditor && resourceScheme == file && git.activeResourceHasStagedChanges && !isSessionsWindow"
},
{
"command": "git.stage",
"group": "2_git@1",
- "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file"
+ "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file && !isSessionsWindow"
},
{
"command": "git.stageSelectedRanges",
"group": "2_git@2",
- "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file"
+ "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file && !isSessionsWindow"
},
{
"command": "git.unstage",
"group": "2_git@3",
- "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == git"
+ "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == git && !isSessionsWindow"
},
{
"command": "git.unstageSelectedRanges",
"group": "2_git@4",
- "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == git"
+ "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == git && !isSessionsWindow"
},
{
"command": "git.revertSelectedRanges",
"group": "2_git@5",
- "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file"
+ "when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0 && isInDiffEditor && isInDiffRightEditor && !isEmbeddedDiffEditor && resourceScheme == file && !isSessionsWindow"
}
],
"editor/context": [
diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json
index a61ac87f4cc..0561134ddad 100644
--- a/extensions/theme-defaults/themes/2026-dark.json
+++ b/extensions/theme-defaults/themes/2026-dark.json
@@ -258,6 +258,9 @@
"gauge.errorBackground": "#F287724D",
"chat.requestBubbleBackground": "#ffffff13",
"chat.requestBubbleHoverBackground": "#ffffff22",
+ "chat.inputWorkingBorderColor1": "#9560D8",
+ "chat.inputWorkingBorderColor2": "#5BC25B",
+ "chat.inputWorkingBorderColor3": "#4A8FF5",
"editorCommentsWidget.rangeBackground": "#488FAE26",
"editorCommentsWidget.rangeActiveBackground": "#488FAE46",
"charts.foreground": "#CCCCCC",
diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json
index a49294ee42a..1b24ca3a52d 100644
--- a/extensions/theme-defaults/themes/2026-light.json
+++ b/extensions/theme-defaults/themes/2026-light.json
@@ -261,6 +261,9 @@
"chat.requestBubbleBackground": "#EEF4FB",
"chat.requestBubbleHoverBackground": "#E6EDFA",
"chat.thinkingShimmer": "#999999",
+ "chat.inputWorkingBorderColor1": "#9B30FF",
+ "chat.inputWorkingBorderColor2": "#00C853",
+ "chat.inputWorkingBorderColor3": "#0044FF",
"editorCommentsWidget.rangeBackground": "#EEF4FB",
"editorCommentsWidget.rangeActiveBackground": "#E6EDFA",
"charts.foreground": "#202020",
diff --git a/package-lock.json b/package-lock.json
index 6686ab60a76..5ddc165b065 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "code-oss-dev",
- "version": "1.117.0",
+ "version": "1.118.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "code-oss-dev",
- "version": "1.117.0",
+ "version": "1.118.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -113,7 +113,7 @@
"cookie": "^0.7.2",
"debounce": "^1.0.0",
"deemon": "^1.13.6",
- "electron": "39.8.7",
+ "electron": "39.8.8",
"eslint": "^9.36.0",
"eslint-formatter-compact": "^8.40.0",
"eslint-plugin-header": "3.1.1",
@@ -7765,9 +7765,9 @@
"dev": true
},
"node_modules/electron": {
- "version": "39.8.7",
- "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.7.tgz",
- "integrity": "sha512-B3TmzbUEeIvrhJ0QcoFp8/tgnVA3vsm0wkdYWzC22hsk9zTVqkzyrrz40cjd0nMTTIrGWxxfDO2tdQTCMe9Bjw==",
+ "version": "39.8.8",
+ "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.8.tgz",
+ "integrity": "sha512-24MMLdwCg8xwlWzDxC1XZU2MqW0Xx2UPxt9EU15vMDoRSMHPqmi/BgRCEINXZaBLfFSeXEKGpg+QlBRdL5uwaw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
diff --git a/package.json b/package.json
index a8c63ee93f1..f9665ffa747 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "code-oss-dev",
- "version": "1.117.0",
- "distro": "cccc9754dda0a01ea6eaa276472ad5e169f80d31",
+ "version": "1.118.0",
+ "distro": "d9cb505fab31e4606739394220a6892cac0e7046",
"author": {
"name": "Microsoft Corporation"
},
@@ -192,7 +192,7 @@
"cookie": "^0.7.2",
"debounce": "^1.0.0",
"deemon": "^1.13.6",
- "electron": "39.8.7",
+ "electron": "39.8.8",
"eslint": "^9.36.0",
"eslint-formatter-compact": "^8.40.0",
"eslint-plugin-header": "3.1.1",
diff --git a/src/bootstrap-meta.ts b/src/bootstrap-meta.ts
index c2dfea36372..b1d8213d893 100644
--- a/src/bootstrap-meta.ts
+++ b/src/bootstrap-meta.ts
@@ -31,7 +31,11 @@ if ((process as INodeProcess).isEmbeddedApp) {
try {
const productSubObj = require('../product.sub.json');
- productObj = Object.assign(productObj, productSubObj);
+ if (productObj.embedded && productSubObj.embedded) {
+ Object.assign(productObj.embedded, productSubObj.embedded);
+ delete productSubObj.embedded;
+ }
+ Object.assign(productObj, productSubObj);
} catch (error) { /* ignore */ }
try {
const pkgSubObj = require('../package.sub.json');
diff --git a/src/vs/base/browser/mouseEvent.ts b/src/vs/base/browser/mouseEvent.ts
index 2f8c99ff327..2873a56e1e4 100644
--- a/src/vs/base/browser/mouseEvent.ts
+++ b/src/vs/base/browser/mouseEvent.ts
@@ -67,14 +67,8 @@ export class StandardMouseEvent implements IMouseEvent {
this.altKey = e.altKey;
this.metaKey = e.metaKey;
- if (typeof e.pageX === 'number') {
- this.posx = e.pageX;
- this.posy = e.pageY;
- } else {
- // Probably hit by MSGestureEvent
- this.posx = e.clientX + this.target.ownerDocument.body.scrollLeft + this.target.ownerDocument.documentElement.scrollLeft;
- this.posy = e.clientY + this.target.ownerDocument.body.scrollTop + this.target.ownerDocument.documentElement.scrollTop;
- }
+ this.posx = e.pageX;
+ this.posy = e.pageY;
// Find the position of the iframe this code is executing in relative to the iframe where the event was captured.
const iframeOffsets = IframeUtils.getPositionOfChildWindowRelativeToAncestorWindow(targetWindow, e.view);
diff --git a/src/vs/base/browser/overlayLayoutElement.ts b/src/vs/base/browser/overlayLayoutElement.ts
index 588d4ce9b33..c6e764dda6d 100644
--- a/src/vs/base/browser/overlayLayoutElement.ts
+++ b/src/vs/base/browser/overlayLayoutElement.ts
@@ -53,6 +53,13 @@ export class OverlayLayoutElement implements IDisposable {
this.content.style.position = 'absolute';
this.content.style.overflow = 'hidden';
+ this._root = document.createElement('div');
+ this._root.appendChild(this.content);
+
+ this.reapplyLayoutStyles();
+ }
+
+ public reapplyLayoutStyles(): void {
if (supportsAnchorPositioning.value) {
this.content.style.position = 'fixed';
this.content.style.top = 'anchor(top)';
@@ -62,10 +69,8 @@ export class OverlayLayoutElement implements IDisposable {
this.content.style.pointerEvents = 'auto';
}
- this._root = document.createElement('div');
this._root.style.position = 'absolute';
this._root.style.pointerEvents = 'none';
- this._root.appendChild(this.content);
}
public dispose(): void {
diff --git a/src/vs/base/browser/ui/actionbar/actionbar.ts b/src/vs/base/browser/ui/actionbar/actionbar.ts
index da20e27e040..f2b116fb7d2 100644
--- a/src/vs/base/browser/ui/actionbar/actionbar.ts
+++ b/src/vs/base/browser/ui/actionbar/actionbar.ts
@@ -79,7 +79,9 @@ export class ActionBar extends Disposable implements IActionRunner {
};
// View Items
- viewItems: IActionViewItem[];
+ private _viewItems: IActionViewItem[];
+ get viewItems(): readonly IActionViewItem[] { return this._viewItems; }
+
private readonly viewItemDisposables = this._register(new DisposableMap());
private previouslyFocusedItem?: number;
protected focusedItem?: number;
@@ -91,7 +93,7 @@ export class ActionBar extends Disposable implements IActionRunner {
private focusable: boolean = true;
// Elements
- domNode: HTMLElement;
+ readonly domNode: HTMLElement;
protected readonly actionsList: HTMLElement;
private readonly _onDidBlur = this._register(new Emitter());
@@ -130,7 +132,7 @@ export class ActionBar extends Disposable implements IActionRunner {
this._actionRunnerDisposables.add(this._actionRunner.onDidRun(e => this._onDidRun.fire(e)));
this._actionRunnerDisposables.add(this._actionRunner.onWillRun(e => this._onWillRun.fire(e)));
- this.viewItems = [];
+ this._viewItems = [];
this.focusedItem = undefined;
this.domNode = document.createElement('div');
@@ -380,10 +382,10 @@ export class ActionBar extends Disposable implements IActionRunner {
if (index === null || index < 0 || index >= this.actionsList.children.length) {
this.actionsList.appendChild(actionViewItemElement);
- this.viewItems.push(item);
+ this._viewItems.push(item);
} else {
this.actionsList.insertBefore(actionViewItemElement, this.actionsList.children[index]);
- this.viewItems.splice(index, 0, item);
+ this._viewItems.splice(index, 0, item);
index++;
}
});
@@ -424,32 +426,18 @@ export class ActionBar extends Disposable implements IActionRunner {
}
getWidth(index: number): number {
- if (index >= 0 && index < this.actionsList.children.length) {
- const item = this.actionsList.children.item(index);
- if (item) {
- return item.clientWidth;
- }
- }
-
- return 0;
+ return this.actionsList.children.item(index)?.clientWidth ?? 0;
}
getHeight(index: number): number {
- if (index >= 0 && index < this.actionsList.children.length) {
- const item = this.actionsList.children.item(index);
- if (item) {
- return item.clientHeight;
- }
- }
-
- return 0;
+ return this.actionsList.children.item(index)?.clientHeight ?? 0;
}
pull(index: number): void {
if (index >= 0 && index < this.viewItems.length) {
this.actionsList.childNodes[index].remove();
this.viewItemDisposables.deleteAndDispose(this.viewItems[index]);
- dispose(this.viewItems.splice(index, 1));
+ dispose(this._viewItems.splice(index, 1));
this.refreshRole();
}
}
@@ -459,7 +447,7 @@ export class ActionBar extends Disposable implements IActionRunner {
return;
}
- this.viewItems = dispose(this.viewItems);
+ this._viewItems = dispose(this._viewItems);
this.viewItemDisposables.clearAndDisposeAll();
DOM.clearNode(this.actionsList);
this.refreshRole();
@@ -623,7 +611,7 @@ export class ActionBar extends Disposable implements IActionRunner {
override dispose(): void {
this._context = undefined;
- this.viewItems = dispose(this.viewItems);
+ this._viewItems = dispose(this._viewItems);
this.getContainer().remove();
super.dispose();
}
diff --git a/src/vs/base/browser/ui/findinput/replaceInput.ts b/src/vs/base/browser/ui/findinput/replaceInput.ts
index 10c5b47b653..a9ae899d424 100644
--- a/src/vs/base/browser/ui/findinput/replaceInput.ts
+++ b/src/vs/base/browser/ui/findinput/replaceInput.ts
@@ -280,8 +280,4 @@ export class ReplaceInput extends Widget {
this.inputBox.paddingRight = this.cachedOptionsWidth;
this.domNode.style.width = newWidth + 'px';
}
-
- public override dispose(): void {
- super.dispose();
- }
}
diff --git a/src/vs/base/test/browser/markdownRenderer.test.ts b/src/vs/base/test/browser/markdownRenderer.test.ts
index 16373be2101..2220d936bb0 100644
--- a/src/vs/base/test/browser/markdownRenderer.test.ts
+++ b/src/vs/base/test/browser/markdownRenderer.test.ts
@@ -33,6 +33,25 @@ suite('MarkdownRenderer', () => {
const result: HTMLElement = store.add(renderMarkdown(markdown)).element;
assert.strictEqual(result.innerHTML, '
');
});
+
+ test('Strips links with disallowed schemes (default config)', () => {
+ const markdown = { value: `Read [](vscode-agent-host://my-host/file/-/path/to/foo.ts)` };
+ const result: HTMLElement = store.add(renderMarkdown(markdown)).element;
+ // No element should remain because the scheme isn't allowed.
+ assert.strictEqual(result.querySelector('a'), null);
+ });
+
+ test('Preserves link when scheme is allowed via allowedLinkSchemes.augment', () => {
+ const markdown = { value: `Read [](vscode-agent-host://my-host/file/-/path/to/foo.ts)` };
+ const result: HTMLElement = store.add(renderMarkdown(markdown, {
+ sanitizerConfig: {
+ allowedLinkSchemes: { augment: ['vscode-agent-host'] },
+ },
+ })).element;
+ const anchor = result.querySelector('a');
+ assert.ok(anchor, 'expected to be preserved when scheme is allowed');
+ assert.strictEqual(anchor!.dataset.href, 'vscode-agent-host://my-host/file/-/path/to/foo.ts');
+ });
});
suite('Images', () => {
diff --git a/src/vs/editor/browser/controller/mouseHandler.ts b/src/vs/editor/browser/controller/mouseHandler.ts
index 4ad4ca9d817..52291332b2a 100644
--- a/src/vs/editor/browser/controller/mouseHandler.ts
+++ b/src/vs/editor/browser/controller/mouseHandler.ts
@@ -403,10 +403,6 @@ class MouseDownOperation extends Disposable {
this._lastMouseEvent = null;
}
- public override dispose(): void {
- super.dispose();
- }
-
public isActive(): boolean {
return this._isActive;
}
diff --git a/src/vs/editor/browser/editorDom.ts b/src/vs/editor/browser/editorDom.ts
index 5e41bb11c15..4077e9cadc5 100644
--- a/src/vs/editor/browser/editorDom.ts
+++ b/src/vs/editor/browser/editorDom.ts
@@ -119,21 +119,30 @@ export class EditorMouseEvent extends StandardMouseEvent {
/**
* Editor's coordinates relative to the whole document.
*/
- public readonly editorPos: EditorPagePosition;
+ public get editorPos(): EditorPagePosition {
+ this._editorPos ??= createEditorPagePosition(this._editorViewDomNode);
+ return this._editorPos;
+ }
+ private _editorPos: EditorPagePosition | undefined;
/**
* Coordinates relative to the (top;left) of the editor.
* *NOTE*: These coordinates are preferred because they take into account transformations applied to the editor.
* *NOTE*: These coordinates could be negative if the mouse position is outside the editor.
- */
- public readonly relativePos: CoordinatesRelativeToEditor;
+ */
+ public get relativePos(): CoordinatesRelativeToEditor {
+ this._relativePos ??= createCoordinatesRelativeToEditor(this._editorViewDomNode, this.editorPos, this.pos);
+ return this._relativePos;
+ }
+ private _relativePos: CoordinatesRelativeToEditor | undefined;
+
+ private readonly _editorViewDomNode: HTMLElement;
constructor(e: MouseEvent, isFromPointerCapture: boolean, editorViewDomNode: HTMLElement) {
super(dom.getWindow(editorViewDomNode), e);
this.isFromPointerCapture = isFromPointerCapture;
this.pos = new PageCoordinates(this.posx, this.posy);
- this.editorPos = createEditorPagePosition(editorViewDomNode);
- this.relativePos = createCoordinatesRelativeToEditor(editorViewDomNode, this.editorPos, this.pos);
+ this._editorViewDomNode = editorViewDomNode;
}
}
diff --git a/src/vs/editor/browser/services/editorWorkerService.ts b/src/vs/editor/browser/services/editorWorkerService.ts
index d5c878af3b3..ec09cf29853 100644
--- a/src/vs/editor/browser/services/editorWorkerService.ts
+++ b/src/vs/editor/browser/services/editorWorkerService.ts
@@ -97,9 +97,6 @@ export class EditorWorkerService extends Disposable implements IEditorWorkerServ
this._register(languageFeaturesService.completionProvider.register('*', new WordBasedCompletionItemProvider(this._workerManager, configurationService, this._modelService, this._languageConfigurationService, this._logService, languageFeaturesService)));
}
- public override dispose(): void {
- super.dispose();
- }
public canComputeUnicodeHighlights(uri: URI): boolean {
return canSyncModel(this._modelService, uri);
diff --git a/src/vs/editor/browser/viewParts/blockDecorations/blockDecorations.ts b/src/vs/editor/browser/viewParts/blockDecorations/blockDecorations.ts
index 70fe366c06c..7cd7ea0be4c 100644
--- a/src/vs/editor/browser/viewParts/blockDecorations/blockDecorations.ts
+++ b/src/vs/editor/browser/viewParts/blockDecorations/blockDecorations.ts
@@ -51,9 +51,6 @@ export class BlockDecorations extends ViewPart {
return didChange;
}
- public override dispose(): void {
- super.dispose();
- }
// --- begin event handlers
diff --git a/src/vs/editor/browser/viewParts/editorScrollbar/editorScrollbar.ts b/src/vs/editor/browser/viewParts/editorScrollbar/editorScrollbar.ts
index aa633a1cadf..816dc313885 100644
--- a/src/vs/editor/browser/viewParts/editorScrollbar/editorScrollbar.ts
+++ b/src/vs/editor/browser/viewParts/editorScrollbar/editorScrollbar.ts
@@ -105,9 +105,6 @@ export class EditorScrollbar extends ViewPart {
this._register(dom.addDisposableListener(this.scrollbarDomNode.domNode, 'scroll', (e: Event) => onBrowserDesperateReveal(this.scrollbarDomNode.domNode, true, false)));
}
- public override dispose(): void {
- super.dispose();
- }
private _setLayout(): void {
const options = this._context.configuration.options;
diff --git a/src/vs/editor/browser/viewParts/margin/margin.ts b/src/vs/editor/browser/viewParts/margin/margin.ts
index cd4660c834d..2edbe484f70 100644
--- a/src/vs/editor/browser/viewParts/margin/margin.ts
+++ b/src/vs/editor/browser/viewParts/margin/margin.ts
@@ -50,9 +50,6 @@ export class Margin extends ViewPart {
this._domNode.appendChild(this._glyphMarginBackgroundDomNode);
}
- public override dispose(): void {
- super.dispose();
- }
public getDomNode(): FastDomNode {
return this._domNode;
diff --git a/src/vs/editor/browser/viewParts/rulers/rulers.ts b/src/vs/editor/browser/viewParts/rulers/rulers.ts
index ec1a5042e91..cb67b702704 100644
--- a/src/vs/editor/browser/viewParts/rulers/rulers.ts
+++ b/src/vs/editor/browser/viewParts/rulers/rulers.ts
@@ -34,9 +34,6 @@ export class Rulers extends ViewPart {
this._typicalHalfwidthCharacterWidth = options.get(EditorOption.fontInfo).typicalHalfwidthCharacterWidth;
}
- public override dispose(): void {
- super.dispose();
- }
// --- begin event handlers
diff --git a/src/vs/editor/browser/viewParts/scrollDecoration/scrollDecoration.ts b/src/vs/editor/browser/viewParts/scrollDecoration/scrollDecoration.ts
index dc5dc300709..a703acd26c2 100644
--- a/src/vs/editor/browser/viewParts/scrollDecoration/scrollDecoration.ts
+++ b/src/vs/editor/browser/viewParts/scrollDecoration/scrollDecoration.ts
@@ -35,9 +35,6 @@ export class ScrollDecorationViewPart extends ViewPart {
this._domNode.setAttribute('aria-hidden', 'true');
}
- public override dispose(): void {
- super.dispose();
- }
private _updateShouldShow(): boolean {
const newShouldShow = (this._useShadows && this._scrollTop > 0);
diff --git a/src/vs/editor/common/viewLayout/viewLayout.ts b/src/vs/editor/common/viewLayout/viewLayout.ts
index 202187a4aad..e64482e192c 100644
--- a/src/vs/editor/common/viewLayout/viewLayout.ts
+++ b/src/vs/editor/common/viewLayout/viewLayout.ts
@@ -191,9 +191,6 @@ export class ViewLayout extends Disposable implements IViewLayout {
this._updateHeight();
}
- public override dispose(): void {
- super.dispose();
- }
public getScrollable(): Scrollable {
return this._scrollable.getScrollable();
diff --git a/src/vs/editor/standalone/browser/standaloneCodeEditor.ts b/src/vs/editor/standalone/browser/standaloneCodeEditor.ts
index b55bf1c0038..db742fc9ba8 100644
--- a/src/vs/editor/standalone/browser/standaloneCodeEditor.ts
+++ b/src/vs/editor/standalone/browser/standaloneCodeEditor.ts
@@ -474,9 +474,6 @@ export class StandaloneEditor extends StandaloneCodeEditor implements IStandalon
}
}
- public override dispose(): void {
- super.dispose();
- }
public override updateOptions(newOptions: Readonly): void {
updateConfigurationService(this._configurationService, newOptions, false);
@@ -544,9 +541,6 @@ export class StandaloneDiffEditor2 extends DiffEditorWidget implements IStandalo
this._register(themeDomRegistration);
}
- public override dispose(): void {
- super.dispose();
- }
public override updateOptions(newOptions: Readonly): void {
updateConfigurationService(this._configurationService, newOptions, true);
diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts
index 05aae838ca0..337851c0526 100644
--- a/src/vs/platform/actionWidget/browser/actionList.ts
+++ b/src/vs/platform/actionWidget/browser/actionList.ts
@@ -1505,8 +1505,13 @@ export class ActionListWidget extends Disposable {
}
} else if (element && element.hover?.content && typeof e.index === 'number') {
// Show hover for disabled items that have hover content (with delay)
- this._hideSubmenu();
- this._scheduleSubmenuShow(element, e.index);
+ if (this._currentSubmenuElement === element) {
+ this._cancelSubmenuHide();
+ this._cancelSubmenuShow();
+ } else {
+ this._hideSubmenu();
+ this._scheduleSubmenuShow(element, e.index);
+ }
}
}
diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css
index ccf83901b21..357a57f67bb 100644
--- a/src/vs/platform/actionWidget/browser/actionWidget.css
+++ b/src/vs/platform/actionWidget/browser/actionWidget.css
@@ -357,6 +357,15 @@
word-wrap: break-word;
}
+.action-list-submenu-hover-header a {
+ color: var(--vscode-textLink-foreground);
+}
+
+.action-list-submenu-hover-header a:hover,
+.action-list-submenu-hover-header a:active {
+ color: var(--vscode-textLink-activeForeground);
+}
+
.action-list-submenu-hover-header.has-submenu {
border-bottom: 1px solid var(--vscode-menu-separatorBackground, var(--vscode-editorWidget-border));
}
diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts
index 82e6ff581ba..87fd04735db 100644
--- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts
+++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts
@@ -3,16 +3,17 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import { IActionWidgetService } from './actionWidget.js';
-import { IAction } from '../../../base/common/actions.js';
-import { BaseDropdown, IActionProvider, IBaseDropdownOptions } from '../../../base/browser/ui/dropdown/dropdown.js';
-import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover, IActionListOptions } from './actionList.js';
-import { ThemeIcon } from '../../../base/common/themables.js';
-import { Codicon } from '../../../base/common/codicons.js';
import { getActiveElement, isHTMLElement } from '../../../base/browser/dom.js';
-import { IKeybindingService } from '../../keybinding/common/keybinding.js';
+import { BaseDropdown, IActionProvider, IBaseDropdownOptions } from '../../../base/browser/ui/dropdown/dropdown.js';
import { IListAccessibilityProvider } from '../../../base/browser/ui/list/listWidget.js';
+import { IAction } from '../../../base/common/actions.js';
+import { Codicon } from '../../../base/common/codicons.js';
+import { ResolvedKeybinding } from '../../../base/common/keybindings.js';
+import { ThemeIcon } from '../../../base/common/themables.js';
+import { IKeybindingService } from '../../keybinding/common/keybinding.js';
import { ITelemetryService } from '../../telemetry/common/telemetry.js';
+import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover, IActionListOptions } from './actionList.js';
+import { IActionWidgetService } from './actionWidget.js';
export interface IActionWidgetDropdownAction extends IAction {
category?: { label: string; order: number; showHeader?: boolean };
@@ -26,6 +27,13 @@ export interface IActionWidgetDropdownAction extends IAction {
* Optional toolbar actions shown when the item is focused or hovered.
*/
toolbarActions?: IAction[];
+ /**
+ * Optional keybinding to display next to the action. When provided, this overrides the
+ * keybinding that would otherwise be looked up via {@link IKeybindingService.lookupKeybinding}.
+ * Useful when the active keybinding depends on a scoped context (e.g. focus state) that the
+ * dropdown cannot evaluate at display time.
+ */
+ keybinding?: ResolvedKeybinding;
}
// TODO @lramos15 - Should we just make IActionProvider templated?
@@ -139,7 +147,7 @@ export class ActionWidgetDropdown extends BaseDropdown {
hideIcon: false,
label: action.label,
keybinding: this._options.showItemKeybindings ?
- this.keybindingService.lookupKeybinding(action.id) :
+ (action.keybinding ?? this.keybindingService.lookupKeybinding(action.id)) :
undefined,
});
}
diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts
index 910c40db359..f4ac668759d 100644
--- a/src/vs/platform/actions/common/actions.ts
+++ b/src/vs/platform/actions/common/actions.ts
@@ -88,6 +88,7 @@ export class MenuId {
static readonly EditorContextPeek = new MenuId('EditorContextPeek');
static readonly EditorContextShare = new MenuId('EditorContextShare');
static readonly EditorTitle = new MenuId('EditorTitle');
+ static readonly EditorTitleLayout = new MenuId('EditorTitleLayout');
static readonly ModalEditorTitle = new MenuId('ModalEditorTitle');
static readonly ModalEditorEditorTitle = new MenuId('ModalEditorEditorTitle');
static readonly CompactWindowEditorTitle = new MenuId('CompactWindowEditorTitle');
diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
index 601c78308de..c83ff0399df 100644
--- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
+++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts
@@ -180,7 +180,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
* Create a new session on the remote agent host.
*/
async createSession(config?: IAgentCreateSessionConfig): Promise {
- const provider = config?.provider ?? 'copilot';
+ const provider = config?.provider;
+ if (!provider) {
+ throw new Error('Cannot create remote agent host session without a provider.');
+ }
const session = AgentSession.uri(provider, generateUuid());
await this._sendRequest('createSession', {
session: session.toString(),
@@ -188,6 +191,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
model: config?.model,
workingDirectory: config?.workingDirectory ? fromAgentHostUri(config.workingDirectory).toString() : undefined,
config: config?.config,
+ activeClient: config?.activeClient,
});
return session;
}
diff --git a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts
index 457ef7ad2c5..556e6bd342c 100644
--- a/src/vs/platform/agentHost/browser/webSocketClientTransport.ts
+++ b/src/vs/platform/agentHost/browser/webSocketClientTransport.ts
@@ -11,6 +11,7 @@ import { Disposable } from '../../../base/common/lifecycle.js';
import { connectionTokenQueryName } from '../../../base/common/network.js';
import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js';
import type { IClientTransport } from '../common/state/sessionTransport.js';
+import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js';
// ---- Client transport -------------------------------------------------------
@@ -31,6 +32,7 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans
readonly onOpen = this._onOpen.event;
private _ws: WebSocket | undefined;
+ private _malformedFrames = 0;
get isOpen(): boolean {
return this._ws?.readyState === WebSocket.OPEN;
@@ -94,13 +96,45 @@ export class WebSocketClientTransport extends Disposable implements IClientTrans
// Wire up long-lived listeners after connection
ws.addEventListener('message', (event: MessageEvent) => {
- try {
- const text = typeof event.data === 'string' ? event.data : '';
- const message = JSON.parse(text) as IProtocolMessage;
- this._onMessage.fire(message);
- } catch {
- // Malformed message - drop.
+ if (typeof event.data !== 'string') {
+ this._malformedFrames++;
+ if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) {
+ const dataType = event.data instanceof ArrayBuffer ? 'ArrayBuffer' : event.data instanceof Blob ? 'Blob' : typeof event.data;
+ const byteLen = event.data instanceof ArrayBuffer ? event.data.byteLength : event.data instanceof Blob ? event.data.size : 0;
+ console.warn(
+ `[WebSocketClientTransport] Non-string frame #${this._malformedFrames} (type=${dataType}, bytes=${byteLen})`
+ );
+ }
+ if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) {
+ console.warn(
+ `[WebSocketClientTransport] Malformed frame threshold exceeded; forcing close of ${this._address}.`
+ );
+ this._ws?.close(4002, 'malformed-frames');
+ }
+ return;
}
+ const text = event.data;
+ let message: IProtocolMessage;
+ try {
+ message = JSON.parse(text) as IProtocolMessage;
+ } catch (err) {
+ this._malformedFrames++;
+ if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) {
+ const preview = text.length > 80 ? text.slice(0, 80) + '…' : text;
+ console.warn(
+ `[WebSocketClientTransport] Malformed frame #${this._malformedFrames} (len=${text.length}): ${preview}`,
+ err instanceof Error ? err.message : String(err)
+ );
+ }
+ if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) {
+ console.warn(
+ `[WebSocketClientTransport] Malformed frame threshold exceeded; forcing close of ${this._address}.`
+ );
+ this._ws?.close(4002, 'malformed-frames');
+ }
+ return;
+ }
+ this._onMessage.fire(message);
});
ws.addEventListener('close', () => {
diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts
index 2b6f2c7a9b3..60414285514 100644
--- a/src/vs/platform/agentHost/common/agentService.ts
+++ b/src/vs/platform/agentHost/common/agentService.ts
@@ -12,7 +12,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js';
import type { ISyncedCustomization } from './agentPluginManager.js';
import type { IAgentSubscription } from './state/agentSubscription.js';
import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from './state/protocol/commands.js';
-import { IProtectedResourceMetadata, type IConfigSchema, type IFileEdit, type IModelSelection, type IToolDefinition } from './state/protocol/state.js';
+import { IProtectedResourceMetadata, type IConfigSchema, type IFileEdit, type IModelSelection, type ISessionActiveClient, type IToolDefinition } from './state/protocol/state.js';
import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from './state/sessionActions.js';
import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js';
import { AttachmentType, ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type ICustomizationRef, type IPendingMessage, type IRootState, type ISessionInputAnswer, type ISessionInputRequest, type IToolCallResult, type IToolResultContent, type PolicyState, type StringOrMarkdown } from './state/sessionState.js';
@@ -125,6 +125,14 @@ export interface IAgentCreateSessionConfig {
readonly session?: URI;
readonly workingDirectory?: URI;
readonly config?: Record;
+ /**
+ * Eagerly claim the active client role for the new session. When provided,
+ * the server initializes the session with this client as the active
+ * client, equivalent to dispatching a `session/activeClientChanged`
+ * action immediately after creation. The `clientId` MUST match the
+ * connection's own `clientId`.
+ */
+ readonly activeClient?: ISessionActiveClient;
/** Fork from an existing session at a specific turn. */
readonly fork?: {
readonly session: URI;
@@ -233,6 +241,18 @@ export interface IAgentToolStartEvent extends IAgentProgressEventBase {
readonly language?: string;
/** Serialized JSON of the tool arguments, if available. */
readonly toolArguments?: string;
+ /**
+ * For `toolKind === 'subagent'`, the internal name of the agent being
+ * spawned (e.g. 'explore'). Adapters are responsible for extracting this
+ * from their SDK-specific tool argument shape.
+ */
+ readonly subagentAgentName?: string;
+ /**
+ * For `toolKind === 'subagent'`, a human-readable description of the
+ * subagent's task. Adapters are responsible for extracting this from
+ * their SDK-specific tool argument shape.
+ */
+ readonly subagentDescription?: string;
readonly mcpServerName?: string;
readonly mcpToolName?: string;
readonly parentToolCallId?: string;
diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts
index 5214fe52cf6..73cf6beb37c 100644
--- a/src/vs/platform/agentHost/common/remoteAgentHostService.ts
+++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts
@@ -22,6 +22,12 @@ export const RemoteAgentHostsSettingId = 'chat.remoteAgentHosts';
/** Configuration key to enable remote agent host connections. */
export const RemoteAgentHostsEnabledSettingId = 'chat.remoteAgentHostsEnabled';
+/**
+ * Configuration key that controls whether online dev tunnels and
+ * configured SSH remote agent hosts are auto-connected at startup.
+ */
+export const RemoteAgentHostAutoConnectSettingId = 'chat.remoteAgentHostsAutoConnect';
+
export const enum RemoteAgentHostEntryType {
WebSocket = 'websocket',
SSH = 'ssh',
diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts
index e398c8a13ee..d78d718861b 100644
--- a/src/vs/platform/agentHost/common/sessionDataService.ts
+++ b/src/vs/platform/agentHost/common/sessionDataService.ts
@@ -173,6 +173,19 @@ export interface ISessionDatabase extends IDisposable {
*/
remapTurnIds(mapping: ReadonlyMap): Promise;
+ /**
+ * Creates a safe, consistent copy of the database at the given path
+ * using SQLite's `VACUUM INTO` command.
+ */
+ vacuumInto(targetPath: string): Promise;
+
+ /**
+ * Resolves once all in-flight write operations on this database have
+ * settled. Used by graceful shutdown to flush fire-and-forget writes
+ * before the process exits.
+ */
+ whenIdle(): Promise;
+
/**
* Close the database connection. After calling this method, the object is
* considered disposed and all other methods will reject with an error.
@@ -235,4 +248,12 @@ export interface ISessionDataService {
* Called at startup; safe to call multiple times.
*/
cleanupOrphanedData(knownSessionIds: Set): Promise;
+
+ /**
+ * Resolves once all in-flight write operations across every currently
+ * open per-session database have settled. Intended for graceful
+ * shutdown — fire-and-forget writes (e.g. metadata persistence) would
+ * otherwise be lost when the process exits.
+ */
+ whenIdle(): Promise;
}
diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version
index 79967dc6f68..f9c684bfe33 100644
--- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version
+++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version
@@ -1 +1 @@
-ab467b2
+0947b17
diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts
index f868bd271ae..a68103156dd 100644
--- a/src/vs/platform/agentHost/common/state/protocol/commands.ts
+++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts
@@ -6,7 +6,7 @@
// allow-any-unicode-comment-file
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts
-import type { URI, ISnapshot, ISessionConfigSchema, ISessionSummary, IModelSelection, ITurn, ITerminalClaim } from './state.js';
+import type { URI, ISnapshot, ISessionConfigSchema, ISessionSummary, IModelSelection, ITurn, ITerminalClaim, ISessionActiveClient } from './state.js';
import type { IActionEnvelope, IStateAction } from './actions.js';
export type { IConfigPropertySchema, IConfigSchema, ISessionConfigPropertySchema, ISessionConfigSchema } from './state.js';
@@ -198,6 +198,15 @@ export interface ICreateSessionParams {
* Keys and values correspond to the schema returned by the server.
*/
config?: Record;
+ /**
+ * Eagerly claim the active client role for the new session.
+ *
+ * When provided, the server initializes the session with this client as the
+ * active client, equivalent to dispatching a `session/activeClientChanged`
+ * action immediately after creation. The `clientId` MUST match the
+ * `clientId` the creating client supplied in `initialize`.
+ */
+ activeClient?: ISessionActiveClient;
}
// ─── disposeSession ──────────────────────────────────────────────────────────
diff --git a/src/vs/platform/agentHost/common/transportConstants.ts b/src/vs/platform/agentHost/common/transportConstants.ts
new file mode 100644
index 00000000000..1da5a8c7bfa
--- /dev/null
+++ b/src/vs/platform/agentHost/common/transportConstants.ts
@@ -0,0 +1,22 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+/**
+ * Shared constants for agent-host protocol transports. Kept in a common
+ * module so browser, electron-browser, and sessions-layer transports all
+ * apply the same malformed-frame policy without duplicating values.
+ */
+
+/**
+ * Force-close the transport once more than this many malformed inbound
+ * frames have been observed. A handful of bad frames can be tolerated
+ * (e.g. a proxy momentarily corrupts a message), but a sustained stream
+ * almost always indicates a protocol mismatch or a broken relay, and is
+ * best surfaced as a hard disconnect so the reconnect loop can take over.
+ */
+export const MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD = 10;
+
+/** Cap warn-level logs per connection for malformed frames to avoid spam. */
+export const MALFORMED_FRAMES_LOG_CAP = 5;
diff --git a/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts
index 6a6341b72ee..bc9b5948a04 100644
--- a/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts
+++ b/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts
@@ -8,6 +8,7 @@ import { Disposable } from '../../../base/common/lifecycle.js';
import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js';
import type { IProtocolTransport } from '../common/state/sessionTransport.js';
import type { ITunnelAgentHostMainService, ITunnelRelayMessage } from '../common/tunnelAgentHost.js';
+import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js';
/**
* A protocol transport that relays messages through the shared process
@@ -24,6 +25,8 @@ export class TunnelRelayTransport extends Disposable implements IProtocolTranspo
private readonly _onClose = this._register(new Emitter());
readonly onClose = this._onClose.event;
+ private _malformedFrames = 0;
+
constructor(
private readonly _connectionId: string,
private readonly _tunnelService: ITunnelAgentHostMainService,
@@ -32,14 +35,28 @@ export class TunnelRelayTransport extends Disposable implements IProtocolTranspo
// Listen for relay messages from the shared process
this._register(this._tunnelService.onDidRelayMessage((msg: ITunnelRelayMessage) => {
- if (msg.connectionId === this._connectionId) {
- try {
- const parsed = JSON.parse(msg.data) as IProtocolMessage;
- this._onMessage.fire(parsed);
- } catch {
- // Malformed message — drop
- }
+ if (msg.connectionId !== this._connectionId) {
+ return;
}
+ let parsed: IProtocolMessage;
+ try {
+ parsed = JSON.parse(msg.data) as IProtocolMessage;
+ } catch (err) {
+ this._malformedFrames++;
+ if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) {
+ const preview = msg.data.length > 80 ? msg.data.slice(0, 80) + '…' : msg.data;
+ console.warn(
+ `[TunnelRelayTransport] Malformed frame #${this._malformedFrames} (len=${msg.data.length}): ${preview}`,
+ err instanceof Error ? err.message : String(err)
+ );
+ }
+ if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) {
+ console.warn('[TunnelRelayTransport] Malformed frame threshold exceeded; closing relay.');
+ this._tunnelService.disconnect(this._connectionId).catch(() => { /* best effort */ });
+ }
+ return;
+ }
+ this._onMessage.fire(parsed);
}));
// Listen for relay close
diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts
index e662c38c8e3..402457ddd22 100644
--- a/src/vs/platform/agentHost/node/agentEventMapper.ts
+++ b/src/vs/platform/agentHost/node/agentEventMapper.ts
@@ -96,18 +96,14 @@ export class AgentEventMapper {
const e = event as IAgentToolStartEvent;
const meta: Record = { toolKind: e.toolKind, language: e.language };
- // For subagent tools, extract agent metadata from tool arguments
- // so the renderer can display the name/description immediately.
- if (e.toolKind === 'subagent' && e.toolArguments) {
- try {
- const args = JSON.parse(e.toolArguments) as Record;
- if (typeof args.description === 'string') {
- meta.subagentDescription = args.description;
- }
- if (typeof args.agentName === 'string') {
- meta.subagentAgentName = args.agentName;
- }
- } catch { /* ignore parse errors */ }
+ // Subagent metadata is normalized by the per-SDK adapter (e.g.
+ // the Copilot adapter maps `agent_type` → `subagentAgentName`),
+ // so the generic mapper just forwards it as-is.
+ if (e.subagentDescription) {
+ meta.subagentDescription = e.subagentDescription;
+ }
+ if (e.subagentAgentName) {
+ meta.subagentAgentName = e.subagentAgentName;
}
const startAction: IToolCallStartAction = {
diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts
index 7f611200723..9b09da0f7ea 100644
--- a/src/vs/platform/agentHost/node/agentHostServerMain.ts
+++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts
@@ -16,6 +16,7 @@ globalThis._VSCODE_FILE_ROOT = fileURLToPath(new URL('../../../..', import.meta.
import * as fs from 'fs';
import * as os from 'os';
import { DisposableStore } from '../../../base/common/lifecycle.js';
+import { raceTimeout } from '../../../base/common/async.js';
import { URI } from '../../../base/common/uri.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { localize } from '../../../nls.js';
@@ -253,12 +254,31 @@ async function main(): Promise {
// Keep alive until stdin closes or signal
process.stdin.resume();
- process.stdin.on('end', shutdown);
- process.on('SIGTERM', shutdown);
- process.on('SIGINT', shutdown);
+ process.stdin.on('end', () => { void shutdown(); });
+ process.on('SIGTERM', () => { void shutdown(); });
+ process.on('SIGINT', () => { void shutdown(); });
- function shutdown(): void {
+ let shuttingDown = false;
+ async function shutdown(): Promise {
+ if (shuttingDown) {
+ return;
+ }
+ shuttingDown = true;
logService.info('[AgentHostServer] Shutting down...');
+ // Close the WebSocket server first so no further actions can be
+ // dispatched while we wait for in-flight writes to flush — otherwise
+ // a late-arriving action could keep queuing DB writes and either
+ // undermine the flush or push us past the timeout.
+ wsServer.dispose();
+ // Wait for in-flight persistence writes to flush to the per-session
+ // SQLite databases. Without this, a SIGTERM arriving while a
+ // `setMetadata` write (configValues, customTitle, isRead, isDone,
+ // diffs) is in flight can drop the latest value — see the
+ // "Session Config persistence across restarts" integration test.
+ // Capped so a stuck write cannot hang shutdown indefinitely.
+ await raceTimeout(sessionDataService.whenIdle(), 3000, () => {
+ logService.warn('[AgentHostServer] Timed out waiting for session database writes to flush; exiting anyway.');
+ });
disposables.dispose();
loggerService?.dispose();
process.exit(0);
diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts
index 3193302c672..f0a77734e40 100644
--- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts
+++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts
@@ -38,7 +38,7 @@ export interface ICommandFinishedEvent {
*/
export interface IAgentHostTerminalManager {
readonly _serviceBrand: undefined;
- createTerminal(params: ICreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean }): Promise;
+ createTerminal(params: ICreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise