Merge branch 'main' into dev/mjbvz/wonderful-piranha

This commit is contained in:
Matt Bierner
2026-04-21 08:11:12 -07:00
314 changed files with 12852 additions and 6183 deletions
@@ -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 `<div>`/`<span>` elements styled as buttons) to eliminate the 300ms tap delay on touch devices. This is not needed for native `<button>` elements or standard VS Code widgets (quick picks, context menus, action bar items) which already handle touch behavior.
@@ -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.
@@ -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');
}
@@ -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
+2 -2
View File
@@ -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"
@@ -71,3 +71,4 @@ extends:
publishExtension: ${{ parameters.publishExtension }}
ghReleasePublishVSIX: true
ghTagPrefix: 'copilot/'
+75 -75
View File
@@ -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
+4 -5
View File
@@ -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') {
+4 -5
View File
@@ -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') {
+17 -69
View File
@@ -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}`);
}
}
+12 -8
View File
@@ -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"
}
]
}
@@ -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",
+3 -3
View File
@@ -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": {
+1 -15
View File
@@ -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',
{
-1
View File
@@ -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' },
-1
View File
@@ -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
+1
View File
@@ -257,3 +257,4 @@ extends:
publishExtension: ${{ parameters.publishExtension }}
ghReleasePublishVSIX: true
ghTagPrefix: 'copilot/'
+1
View File
@@ -243,3 +243,4 @@ extends:
publishExtension: ${{ parameters.publishExtension }}
ghReleasePublishVSIX: true
ghTagPrefix: 'copilot/'
+494 -18
View File
@@ -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",
-4
View File
@@ -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',
+40 -40
View File
@@ -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"
],
+73 -28
View File
@@ -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",
+3 -1
View File
@@ -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.",
@@ -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
@@ -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,
};
}
@@ -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>('IChatSessionMetadataStore');
@@ -124,4 +139,12 @@ export interface IChatSessionMetadataStore {
storeForkedSessionMetadata(sourceSessionId: string, targetSessionId: string, customTitle: string): Promise<void>;
setSessionOrigin(sessionId: string): Promise<void>;
getSessionOrigin(sessionId: string): Promise<'vscode' | 'other'>;
setSessionParentId(sessionId: string, parentSessionId: string): Promise<void>;
getSessionParentId(sessionId: string): Promise<string | undefined>;
/**
* 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<void>;
}
@@ -31,6 +31,10 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore {
this._requestDetails.delete(sessionId);
}
async refresh(): Promise<void> {
// no-op in mock — there is no on-disk state to reload.
}
async storeWorktreeInfo(sessionId: string, properties: ChatSessionWorktreeProperties): Promise<void> {
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<void> {
return Promise.resolve();
}
getSessionParentId(_sessionId: string): Promise<string | undefined> {
return Promise.resolve(undefined);
}
}
@@ -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.
@@ -1601,6 +1601,17 @@ export async function updateTodoListFromSqlItems(
}, token);
}
export async function clearTodoList(toolsService: IToolsService,
toolInvocationToken: ChatParticipantToolToken,
token: CancellationToken): Promise<void> {
await toolsService.invokeTool(ToolName.CoreManageTodoList, {
input: {
operation: 'write',
todoList: []
} satisfies IManageTodoListToolInputParams,
toolInvocationToken,
}, token);
}
interface IManageTodoListToolInputParams {
readonly operation?: 'write' | 'read'; // Optional in write-only mode
@@ -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');
}
@@ -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<typeof import('@github/copilot/sdk')> {
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');
}
@@ -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<string, Promise<string | undefined>>();
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
@@ -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<Promise<internal.LocalSessionManager>>(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) {
@@ -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<void> | 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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<boolean> {
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;
}
@@ -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`.
@@ -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());
@@ -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'
});
@@ -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<string, ChatSessionMetadataFile> = {};
private readonly _cacheDirectory: Uri;
private readonly _cacheFile: Uri;
private readonly _intialize: Lazy<Promise<void>>;
/** 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<void>;
private readonly _updateStorageDebouncer = this._register(new ThrottledDelayer<void>(1_000));
private readonly _requestMappingWriteSequencer = new SequencerByKey<string>();
private readonly _metadataWriteSequencer = new SequencerByKey<string>();
/** Serializes bulk-file flush against {@link reloadBulkFromDisk}. */
private readonly _bulkSequencer = new SequencerByKey<string>();
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<Promise<void>>(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<void> {
// 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<void> {
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<Record<string, Partial<WorkspaceFolderEntry>>>(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<Record<string, string | ChatSessionWorktreeData>>(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<string, ChatSessionMetadataFile>));
// 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<void> {
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<string, ChatSessionMetadataFile>));
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<ChatSessionWorktreeProperties | undefined>;
getWorktreeProperties(folder: Uri): Promise<ChatSessionWorktreeProperties | undefined>;
async getWorktreeProperties(sessionId: string | Uri): Promise<ChatSessionWorktreeProperties | undefined> {
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<string | undefined> {
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<string | undefined> {
const cached = this._worktreeSessions.getSessionIdForFolder(folder);
if (cached) {
return cached;
}
await this._worktreeSessions.reloadIfStale();
return this._worktreeSessions.getSessionIdForFolder(folder);
}
async getSessionWorkspaceFolder(sessionId: string): Promise<vscode.Uri | undefined> {
@@ -284,7 +288,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
}
async getRequestDetails(sessionId: string): Promise<RequestDetails[]> {
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<RequestDetails> & { vscodeRequestId: string })[]): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await this._ready;
await this.updateMetadataFields(sessionId, { parentSessionId, kind: 'sub-session' });
}
public async getSessionParentId(sessionId: string): Promise<string | undefined> {
const metadata = await this.getSessionMetadata(sessionId, false);
return metadata?.parentSessionId;
}
private async getSessionMetadata(sessionId: string, createMetadataFileIfNotFound = true): Promise<ChatSessionMetadataFile | undefined> {
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<ChatSessionMetadataFile | undefined> {
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<void> {
private async updateSessionMetadata(sessionId: string, updates: Partial<ChatSessionMetadataFile>, createDirectoryIfNotFound = true): Promise<void> {
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<string, unknown>)[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<unknown>[] = [];
// 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<string, ChatSessionMetadataFile> = { ...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<string, ChatSessionMetadataFile>): Promise<void> {
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<string, ChatSessionMetadataFile>;
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<void> {
return this._bulkSequencer.queue(BULK_SEQUENCER_KEY, async () => {
let onDisk: Record<string, ChatSessionMetadataFile>;
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<void> {
// Skip if this install already migrated.
if (this.extensionContext.globalState.get<boolean>(LEGACY_BULK_MIGRATED_KEY)) {
return;
}
const legacyCacheFile = Uri.joinPath(this.extensionContext.globalStorageUri, 'copilotcli', LEGACY_BULK_METADATA_FILENAME);
let legacyData: Record<string, ChatSessionMetadataFile>;
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<string, ChatSessionMetadataFile> = {};
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<void> {
// 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<void> {
if (this.extensionContext.globalState.get<boolean>(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)`);
}
}
@@ -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<ChatSessionWorktreeProperties | undefined> {
@@ -202,11 +204,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi
async setWorktreeProperties(sessionId: string, properties: ChatSessionWorktreeProperties): Promise<void> {
this._sessionWorktrees.set(sessionId, properties);
const sessionWorktreesProperties = this.extensionContext.globalState.get<Record<string, string | ChatSessionWorktreeData>>(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<RepoContext | undefined> {
@@ -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<PermissionMode>;
readonly folderUri: ISettableObservable<URI | undefined>;
readonly folderItems: ISettableObservable<readonly vscode.ChatSessionProviderOptionItem[]>;
readonly isSessionStarted: ISettableObservable<boolean>;
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<string, vscode.ChatSessionItem>();
private _showBadge: boolean;
// #region Shared Observable State
/** Whether the "bypass permissions" config is enabled — controls permission mode items. */
private readonly _bypassPermissionsEnabled: IObservable<boolean>;
/** Current workspace folders — controls folder group items and visibility. */
private readonly _workspaceFolders: IObservable<URI[]>;
/** Disposes per-state autoruns when the state object is garbage collected. */
private readonly _stateAutorunRegistry = new FinalizationRegistry<DisposableStore>(
store => store.dispose()
);
/** Maps input state objects to their reactive pipelines for external updates. */
private readonly _statePipelines = new WeakMap<vscode.ChatSessionInputState, InputStateReactivePipeline>();
// #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<void>());
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<vscode.ChatSessionInputState> }[] = [];
/**
* 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<PermissionMode>(this, seed.permissionMode);
const folderUri = observableValue<URI | undefined>(this, seed.folderUri);
const folderItems = observableValue<readonly vscode.ChatSessionProviderOptionItem[]>(this, seed.folderItems);
const isSessionStarted = observableValue<boolean>(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<vscode.ChatSessionProviderOptionGroup | undefined>(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<vscode.ChatSessionProviderOptionGroup[]> {
@@ -374,14 +540,6 @@ export class ClaudeChatSessionItemController extends Disposable {
return this._optionBuilder.buildExistingSessionGroups(permissionMode, folderUri);
}
private async _rebuildInputState(state: vscode.ChatSessionInputState): Promise<void> {
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;
}
@@ -42,18 +42,16 @@ export class ClaudeSessionOptionBuilder {
private readonly _workspaceService: IWorkspaceService,
) { }
async buildNewSessionGroups(previousInputState?: vscode.ChatSessionInputState): Promise<vscode.ChatSessionProviderOptionGroup[]> {
async buildNewSessionGroups(): Promise<vscode.ChatSessionProviderOptionGroup[]> {
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<vscode.ChatSessionProviderOptionGroup | undefined> {
async buildNewFolderGroup(): Promise<vscode.ChatSessionProviderOptionGroup | undefined> {
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
@@ -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,
@@ -61,6 +61,7 @@ const REPOSITORY_OPTION_ID = 'repository';
const _sessionWorktreeIsolationCache = new Map<string, boolean>();
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<void> {
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<string | undefined> }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference<ICopilotCLISession> | 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<string | undefined>; sessionParentId?: string }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference<ICopilotCLISession> | 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.
@@ -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<typeof createDefaultMocks>,
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<void> {
// 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<IGitService>() {
}
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 () => {
@@ -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();
});
@@ -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());
@@ -158,6 +158,7 @@ function createProvider() {
const metadataStore = new class extends mock<IChatSessionMetadataStore>() {
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();
@@ -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<string[]> {
@@ -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<ILogService>() {
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');
});
});
});
@@ -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<string, WorktreeSessionEntry>();
/** Worktree folder URI → session id. Uses URI-aware comparison so path casing is handled correctly. */
private readonly _byFolder = new ResourceMap<string>();
/** 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<void> {
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<void> {
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<void> {
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<void> {
if (!this._byId.has(sessionId)) {
return;
}
this.deleteEntry(sessionId);
await this.writeToDisk();
}
}
@@ -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;
}
@@ -132,6 +132,7 @@ Standup for <date>:
- 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 <date>:
- 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
@@ -9,25 +9,16 @@ import { SessionIndexingPreference } from '../sessionIndexingPreference';
function createMockConfigService(opts: {
localIndexEnabled?: boolean;
cloudSyncEnabled?: boolean;
cloudSyncPublicEnabled?: boolean;
excludeRepositories?: string[];
} = {}) {
const configs: Record<string, unknown> = {};
// 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<string>();
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);
});
});
@@ -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;
}
@@ -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,
) { }
@@ -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();
@@ -17,7 +17,7 @@ export type ContextProviderExpSettings = {
excludeRelatedFiles: boolean;
timeBudget: number;
params?: Record<string, string | boolean | number>;
}
};
export const ICompletionsFeaturesService = createServiceIdentifier<ICompletionsFeaturesService>('ICompletionsFeaturesService');
export interface ICompletionsFeaturesService {
@@ -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[],
@@ -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. */
@@ -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,
@@ -88,7 +88,7 @@ interface IGitHubRepositoryReference {
}
export class RemoteAgentContribution implements IDisposable {
private disposables = new DisposableStore();
private readonly disposables = new DisposableStore();
private refreshRemoteAgentsP: Promise<void> | undefined;
private enabledSkillsPromise: Promise<Set<string>> | undefined;
@@ -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 () => {
@@ -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<IInlineChatEditResult> {
@@ -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<IInlineChatEditResult> {
@@ -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,
@@ -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<Ne
private _pendingStatelessNextEditRequest: StatelessNextEditRequest<CachedOrRebasedEdit> | 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<CachedOrRebasedEdit>;
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<Ne
this._logger = this._logService.createSubLogger(['NES', 'NextEditProvider']);
this._nextEditCache = new NextEditCache(this._workspace, this._logService, this._configService, this._expService);
this._specManager = this._register(new SpeculativeRequestManager(this._logger.createSubLogger('SpeculativeRequestManager')));
mapObservableArrayCached(this, this._workspace.openDocuments, (doc, store) => {
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<Ne
&& this._pendingStatelessNextEditRequest || undefined;
// Check if we can reuse the speculative pending request (from when a suggestion was shown)
const speculativeRequestMatches = this._speculativePendingRequest?.docId === curDocId
&& this._speculativePendingRequest?.postEditContent === documentAtInvocationTime.value
&& !this._speculativePendingRequest.request.cancellationTokenSource.token.isCancellationRequested
&& cursorInRequestEditWindow(this._speculativePendingRequest.request);
const speculativeRequest = speculativeRequestMatches ? this._speculativePendingRequest?.request : undefined;
const specPending = this._specManager.pending;
const speculativeRequestMatches = specPending?.docId === curDocId
&& specPending?.postEditContent === documentAtInvocationTime.value
&& !specPending.request.cancellationTokenSource.token.isCancellationRequested
&& cursorInRequestEditWindow(specPending.request);
const speculativeRequest = speculativeRequestMatches ? specPending?.request : undefined;
// Prefer speculative request if it matches (it was specifically created for this post-edit state)
const requestToReuse = speculativeRequest ?? existingNextEditRequest;
@@ -559,8 +553,8 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
// Nice! No need to make another request, we can reuse the result from a pending request.
if (speculativeRequest) {
logger.trace(`reusing speculative pending request (opportunityId=${speculativeRequest.opportunityId}, headerRequestId=${speculativeRequest.headerRequestId})`);
// Clear the speculative request since we're using it
this._speculativePendingRequest = null;
// Detach the speculative — caller is consuming it now.
this._specManager.consumePending();
} else {
logger.trace(`reusing in-flight pending request (opportunityId=${requestToReuse.opportunityId}, headerRequestId=${requestToReuse.headerRequestId})`);
}
@@ -741,17 +735,12 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
// Clear any scheduled (but not yet triggered) speculative request tied to the
// old stream — it would otherwise fire stale when the old stream's background
// loop calls handleStreamEnd after the stream has already been superseded.
this._scheduledSpeculativeRequest = null;
this._specManager.clearScheduled();
}
// Cancel speculative request if it doesn't match the document/state
// of this new request — it was built for a different document or post-edit state.
if (this._speculativePendingRequest
&& (this._speculativePendingRequest.docId !== curDocId
|| this._speculativePendingRequest.postEditContent !== nextEditRequest.documentBeforeEdits.value)
) {
this._cancelSpeculativeRequest();
}
this._specManager.cancelIfMismatch(curDocId, nextEditRequest.documentBeforeEdits.value, SpeculativeCancelReason.Superseded);
this._pendingStatelessNextEditRequest = nextEditRequest;
@@ -888,9 +877,8 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
// Fire any scheduled speculative request — the last shown edit
// was indeed the last edit from this stream.
if (this._scheduledSpeculativeRequest?.headerRequestId === nextEditRequest.headerRequestId) {
const scheduled = this._scheduledSpeculativeRequest;
this._scheduledSpeculativeRequest = null;
const scheduled = this._specManager.consumeScheduled(nextEditRequest.headerRequestId);
if (scheduled) {
void this._triggerSpeculativeRequest(scheduled.suggestion);
}
@@ -920,9 +908,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
// A new edit arrived from the stream — the previously-shown
// edit was not the last one. Clear the scheduled speculative.
if (this._scheduledSpeculativeRequest?.headerRequestId === nextEditRequest.headerRequestId) {
this._scheduledSpeculativeRequest = null;
}
this._specManager.consumeScheduled(nextEditRequest.headerRequestId);
res = await editStream.next();
}
@@ -1030,7 +1016,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
this._lastShownTime = Date.now();
this._lastShownSuggestionId = suggestion.requestId;
this._lastOutcome = undefined; // clear so that outcome is "pending" until resolved
this._scheduledSpeculativeRequest = null; // clear any previously scheduled speculative
this._specManager.clearScheduled(); // clear any previously scheduled speculative
// Trigger speculative request for the post-edit document state
const speculativeRequestsEnablement = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, this._expService);
@@ -1041,10 +1027,10 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
// request only fires when the stream ends with the shown edit as the last one.
const originatingRequest = this._pendingStatelessNextEditRequest;
if (originatingRequest && originatingRequest.headerRequestId === suggestion.source.headerRequestId) {
this._scheduledSpeculativeRequest = {
this._specManager.schedule({
suggestion,
headerRequestId: originatingRequest.headerRequestId,
};
});
} else {
void this._triggerSpeculativeRequest(suggestion);
}
@@ -1115,7 +1101,8 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
}
// Check if we already have a speculative request for this post-edit state
if (this._speculativePendingRequest?.docId === docId && this._speculativePendingRequest?.postEditContent === postEditContent) {
const existingSpec = this._specManager.pending;
if (existingSpec?.docId === docId && existingSpec?.postEditContent === postEditContent) {
logger.trace('already have speculative request for post-edit state');
return;
}
@@ -1129,8 +1116,11 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
return;
}
// Cancel any previous speculative request
this._cancelSpeculativeRequest();
// Note: any previous speculative request will be cancelled (as `Replaced`)
// by `_specManager.setPending` once the new request is actually installed —
// see the `setPending` call at the end of this method. We deliberately do
// not cancel earlier so the prior speculative stays available for reuse
// while the new one is being constructed.
const historyContext = this._historyContextProvider.getHistoryContext(docId);
if (!historyContext) {
@@ -1159,11 +1149,24 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
);
if (speculativeRequest) {
this._speculativePendingRequest = {
// Capture trajectory data: while the user is typing in `docId`, the
// document is on a "type-through" trajectory iff:
// doc = preEdit[0..editStart] + newText[0..k] + preEdit[editEnd..]
// for some 0 <= k <= newText.length. Storing the prefix/suffix/newText
// (already-CRLF-normalized via `result.edit.newText` whose newlines
// match the original document) lets us check this in O(|cur|) on doc changes.
const preEditValue = result.documentBeforeEdits.value;
const trajectoryPrefix = preEditValue.slice(0, preciseEdit.replaceRange.start);
const trajectorySuffix = preEditValue.slice(preciseEdit.replaceRange.endExclusive);
const trajectoryNewText = preciseEdit.newText;
this._specManager.setPending({
request: speculativeRequest,
docId,
postEditContent,
};
trajectoryPrefix,
trajectorySuffix,
trajectoryNewText,
});
}
} catch (e) {
logger.trace(`speculative request failed: ${ErrorUtils.toString(e)}`);
@@ -1445,7 +1448,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
// The user rejected the suggestion, so the speculative request (which
// predicted the post-accept state) will never be reused. Cancel it to
// avoid wasting a server slot.
this._cancelSpeculativeRequest();
this._specManager.cancelAll(SpeculativeCancelReason.Rejected);
const shownDuration = Date.now() - this._lastShownTime;
if (shownDuration > 1000 && suggestion.result.edit) {
@@ -1470,9 +1473,15 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
if (wasShown && !wasSuperseded) {
// The shown suggestion was dismissed (not superseded by a new one),
// so the speculative request for its post-accept state is useless.
this._cancelSpeculativeRequest();
this._specManager.cancelAll(SpeculativeCancelReason.IgnoredDismissed);
this._statelessNextEditProvider.handleIgnored?.();
}
// Note: the superseded case is intentionally NOT handled here. The trajectory
// check on `_specManager.onActiveDocumentChanged` already cancels the
// speculative iff the user's edit moved off the type-through trajectory; if
// the new (superseding) suggestion is just a continuation of the old one
// (e.g. typed `i` while `ibonacci` was shown → now `bonacci` is shown), the
// speculative's `postEditContent` is still the right bet and we keep it.
}
private async runSnippy(docId: DocumentId, suggestion: NextEditResult) {
@@ -1497,6 +1506,9 @@ export class NextEditProvider extends Disposable implements INextEditProvider<Ne
public clearCache() {
this._nextEditCache.clear();
this._rejectionCollector.clear();
// Any in-flight speculative would land its result into a cache that's
// meant to be empty (and may be based on a now-stale model/auth/prompt).
this._specManager.cancelAll(SpeculativeCancelReason.CacheCleared);
}
}
@@ -0,0 +1,199 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { DocumentId } from '../../../platform/inlineEdits/common/dataTypes/documentId';
import { StatelessNextEditRequest } from '../../../platform/inlineEdits/common/statelessNextEditProvider';
import { ILogger } from '../../../platform/log/common/logService';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { CachedOrRebasedEdit } from './nextEditCache';
import { NextEditResult } from './nextEditResult';
/**
* Reasons why a speculative request was cancelled. Recorded on the request's
* log context so each cancellation has an attributable cause.
*/
export const enum SpeculativeCancelReason {
/** The originating suggestion was rejected by the user. */
Rejected = 'rejected',
/** The originating suggestion was dismissed without being superseded. */
IgnoredDismissed = 'ignoredDismissed',
/** A new fetch is starting whose `(docId, postEditContent)` doesn't match. */
Superseded = 'superseded',
/** A newer speculative is being installed in this slot. */
Replaced = 'replaced',
/** The user's edits moved off the type-through trajectory toward `postEditContent`. */
DivergedFromTrajectoryForm = 'divergedFromTrajectoryForm',
DivergedFromTrajectoryPrefix = 'divergedFromTrajectoryPrefix',
DivergedFromTrajectoryMiddle = 'divergedFromTrajectoryMiddle',
DivergedFromTrajectorySuffix = 'divergedFromTrajectorySuffix',
/** `clearCache()` was invoked. */
CacheCleared = 'cacheCleared',
/** The target document was removed from the workspace. */
DocumentClosed = 'documentClosed',
/** The provider was disposed. */
Disposed = 'disposed',
}
export interface SpeculativePendingRequest {
readonly request: StatelessNextEditRequest<CachedOrRebasedEdit>;
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();
}
}
@@ -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);
});
});
});
@@ -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<keyof FormData, string> = {
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));
});
});
});
@@ -361,7 +361,7 @@ export type ImportDetails = {
labelShort: string;
labelDeduped: string;
importSource: ImportSource;
}
};
export interface ILanguageImportHandler {
isImportDiagnostic(diagnostic: Diagnostic): boolean;
@@ -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) {
@@ -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];
@@ -139,7 +139,7 @@ export async function createExportedPrompt(
kind: 'error',
error: error?.toString() || 'Unknown error',
timestamp: new Date().toISOString()
} as unknown as ExportedLogEntry);
});
}
}
@@ -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)));
@@ -16,16 +16,21 @@ export class TitlePrompt extends PromptElement<TitlePromptProps> {
return (
<>
<SystemMessage priority={1000}>
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.<br />
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.<br />
<SafetyRules />
<ResponseTranslationRules />
The title should not be wrapped in quotes. It should be about 8 words or fewer.<br />
Write the title in sentence case, not title case. Preserve product names, abbreviations, code symbols, and proper nouns.<br />
Aim for 3-6 words. Prefer the shortest accurate title.<br />
Drop articles like "a", "an", and "the" unless needed for clarity.<br />
Drop filler and generic framing like "help with", "question about", "request for", or "issue with".<br />
Prefer short, concrete synonyms and omit unnecessary words.<br />
Do not wrap the title in quotes or add trailing punctuation.<br />
Here are some examples of good titles:<br />
- Git rebase question<br />
- Installing Python packages<br />
- Location of LinkedList implementation in codebase<br />
- Adding a tree view to a VS Code extension<br />
- React useState hook usage
- Install Python packages<br />
- LinkedList implementation location<br />
- Add VS Code tree view<br />
- React useState usage
</SystemMessage>
<UserMessage priority={900}>
Please write a brief title for the following request:<br />
@@ -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();
@@ -18,7 +18,7 @@ export class PreComputedToolEmbeddingsCache implements IToolEmbeddingsCache {
private embeddingsMap: Map<string, Embedding> | undefined;
constructor(
@ILogService readonly _logService: ILogService,
@ILogService private readonly _logService: ILogService,
@IInstantiationService instantiationService: IInstantiationService,
@IEnvService envService: IEnvService
) {
@@ -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<ICreateFileParams> {
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([
@@ -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<IToolSearchPara
ToolRegistry.registerModelSpecificTool(
{
name: CUSTOM_TOOL_SEARCH_NAME,
displayName: 'Search Tools',
displayName: l10n.t('Search Tools'),
toolReferenceName: 'toolSearch',
userDescription: l10n.t('Search for relevant tools by describing what you need'),
description: 'Search for relevant tools by describing what you need. Returns tool references for tools matching your query. Use this when you need to find a tool but aren\'t sure of its exact name. Check the availableDeferredTools list in your instructions for the full set of deferred tools, and include relevant tool names from that list in your query for more accurate results. Use broad queries to find all related tools in a single call rather than making multiple narrow searches.',
tags: [],
source: undefined,
toolSet: 'vscode',
inputSchema: {
type: 'object',
properties: {
@@ -30,30 +30,30 @@ export enum CacheScopeKind {
export type FileCacheScope = {
kind: CacheScopeKind.File;
}
};
export type NeighborFilesCacheScope = {
kind: CacheScopeKind.NeighborFiles;
}
};
export type Position = {
line: number;
character: number;
}
};
export type Range = {
start: Position;
end: Position;
}
};
export type WithinRangeCacheScope = {
kind: CacheScopeKind.WithinRange;
range: Range;
}
};
export type OutsideRangeCacheScope = {
kind: CacheScopeKind.OutsideRange;
ranges: Range[];
}
};
export type CacheScope = FileCacheScope | NeighborFilesCacheScope | WithinRangeCacheScope | OutsideRangeCacheScope;
@@ -66,7 +66,7 @@ export enum EmitMode {
export type CacheInfo = {
emitMode: EmitMode;
scope: CacheScope;
}
};
export namespace CacheInfo {
export type has = { cache: CacheInfo };
export function has(item: unknown): item is has {
@@ -76,7 +76,7 @@ export namespace CacheInfo {
export type CachedContextItem = {
key: ContextItemKey;
sizeInChars?: number;
}
};
export namespace CachedContextItem {
export function create(key: ContextItemKey, sizeInChars?: number): CachedContextItem {
return { key, sizeInChars };
@@ -236,7 +236,7 @@ export namespace ContextItem {
export type PriorityTag = {
priority: number;
}
};
export enum ContextRunnableState {
Created = 'created',
@@ -291,7 +291,7 @@ export type ContextRunnableResult = {
* A human readable path to the signature to ease debugging.
*/
debugPath?: ContextRunnableResultId | undefined;
}
};
export type CachedContextRunnableResult = {
@@ -317,7 +317,7 @@ export type CachedContextRunnableResult = {
* The cache information of the runnable.
*/
cache?: CacheInfo;
}
};
export type ContextRunnableResultReference = {
@@ -328,7 +328,7 @@ export type ContextRunnableResultReference = {
* this state.
*/
id: ContextRunnableResultId;
}
};
export type ContextRunnableResultTypes = ContextRunnableResult | ContextRunnableResultReference;
@@ -345,7 +345,7 @@ export namespace ErrorData {
export type Timings = {
totalTime: number;
computeTime: number;
}
};
export namespace Timings {
export function create(totalTime: number, computeTime: number): Timings {
return { totalTime, computeTime };
@@ -397,7 +397,7 @@ export type ContextRequestResult = {
* New server side context items that were computed.
*/
contextItems?: ContextItem[];
}
};
export interface ComputeContextRequestArgs extends tt.server.protocol.FileLocationRequestArgs {
startTime: number;
@@ -504,17 +504,17 @@ export namespace PrepareNesRenameResult {
canRename: RenameKind.yes;
oldName: string;
onOldState: boolean;
}
};
export type Maybe = {
canRename: RenameKind.maybe;
oldName: string;
onOldState: boolean;
}
};
export type No = {
canRename: RenameKind.no;
timedOut: boolean;
reason?: string;
}
};
}
export type PrepareNesRenameResult = PrepareNesRenameResult.Yes | PrepareNesRenameResult.Maybe | PrepareNesRenameResult.No;
@@ -566,17 +566,17 @@ export interface NesRenameRequestArgs extends tt.server.protocol.FileLocationReq
export type TextChange = {
range: Range;
newText?: string;
}
};
export type RenameGroup = {
file: FilePath;
changes: TextChange[];
}
};
export namespace NesRenameResult {
export type OK = {
groups: RenameGroup[];
}
};
export type Failed = CustomResponse.Failed;
}
@@ -374,7 +374,7 @@ export class RunnableResult {
public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined): void;
public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined, ifRoom: false): void;
public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined, ifRoom: true): boolean;
public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined, ifRoom: boolean): boolean
public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined, ifRoom: boolean): boolean;
public addSnippet(code: SnippetProvider, location: SnippetLocation, key: string | undefined, ifRoom: boolean = false): boolean {
const budget = location === SnippetLocation.Primary ? this.primaryBudget : this.secondaryBudget;
if (code.isEmpty()) {
@@ -684,7 +684,7 @@ class CacheBasedContextRunnable implements ContextRunnable {
export type SymbolData = {
symbol: tt.Symbol;
name?: string;
}
};
enum SymbolEmitDataKind {
symbol = 'symbol',
@@ -695,12 +695,12 @@ type SymbolEmitData = {
kind: SymbolEmitDataKind.symbol;
symbol: tt.Symbol;
name?: string;
}
};
type TypeAliasEmitData = {
kind: SymbolEmitDataKind.typeAlias;
node: tt.TypeAliasDeclaration;
}
};
type EmitData = SymbolEmitData | TypeAliasEmitData;
@@ -1158,4 +1158,4 @@ export class CharacterBudget {
this.spent(chars);
this.throwIfExhausted();
}
}
}
@@ -30,30 +30,30 @@ export enum CacheScopeKind {
export type FileCacheScope = {
kind: CacheScopeKind.File;
}
};
export type NeighborFilesCacheScope = {
kind: CacheScopeKind.NeighborFiles;
}
};
export type Position = {
line: number;
character: number;
}
};
export type Range = {
start: Position;
end: Position;
}
};
export type WithinRangeCacheScope = {
kind: CacheScopeKind.WithinRange;
range: Range;
}
};
export type OutsideRangeCacheScope = {
kind: CacheScopeKind.OutsideRange;
ranges: Range[];
}
};
export type CacheScope = FileCacheScope | NeighborFilesCacheScope | WithinRangeCacheScope | OutsideRangeCacheScope;
@@ -66,7 +66,7 @@ export enum EmitMode {
export type CacheInfo = {
emitMode: EmitMode;
scope: CacheScope;
}
};
export namespace CacheInfo {
export type has = { cache: CacheInfo };
export function has(item: unknown): item is has {
@@ -76,7 +76,7 @@ export namespace CacheInfo {
export type CachedContextItem = {
key: ContextItemKey;
sizeInChars?: number;
}
};
export namespace CachedContextItem {
export function create(key: ContextItemKey, sizeInChars?: number): CachedContextItem {
return { key, sizeInChars };
@@ -236,7 +236,7 @@ export namespace ContextItem {
export type PriorityTag = {
priority: number;
}
};
export enum ContextRunnableState {
Created = 'created',
@@ -291,7 +291,7 @@ export type ContextRunnableResult = {
* A human readable path to the signature to ease debugging.
*/
debugPath?: ContextRunnableResultId | undefined;
}
};
export type CachedContextRunnableResult = {
@@ -317,7 +317,7 @@ export type CachedContextRunnableResult = {
* The cache information of the runnable.
*/
cache?: CacheInfo;
}
};
export type ContextRunnableResultReference = {
@@ -328,7 +328,7 @@ export type ContextRunnableResultReference = {
* this state.
*/
id: ContextRunnableResultId;
}
};
export type ContextRunnableResultTypes = ContextRunnableResult | ContextRunnableResultReference;
@@ -345,7 +345,7 @@ export namespace ErrorData {
export type Timings = {
totalTime: number;
computeTime: number;
}
};
export namespace Timings {
export function create(totalTime: number, computeTime: number): Timings {
return { totalTime, computeTime };
@@ -397,7 +397,7 @@ export type ContextRequestResult = {
* New server side context items that were computed.
*/
contextItems?: ContextItem[];
}
};
export interface ComputeContextRequestArgs extends tt.server.protocol.FileLocationRequestArgs {
startTime: number;
@@ -504,17 +504,17 @@ export namespace PrepareNesRenameResult {
canRename: RenameKind.yes;
oldName: string;
onOldState: boolean;
}
};
export type Maybe = {
canRename: RenameKind.maybe;
oldName: string;
onOldState: boolean;
}
};
export type No = {
canRename: RenameKind.no;
timedOut: boolean;
reason?: string;
}
};
}
export type PrepareNesRenameResult = PrepareNesRenameResult.Yes | PrepareNesRenameResult.Maybe | PrepareNesRenameResult.No;
@@ -566,17 +566,17 @@ export interface NesRenameRequestArgs extends tt.server.protocol.FileLocationReq
export type TextChange = {
range: Range;
newText?: string;
}
};
export type RenameGroup = {
file: FilePath;
changes: TextChange[];
}
};
export namespace NesRenameResult {
export type OK = {
groups: RenameGroup[];
}
};
export type Failed = CustomResponse.Failed;
}
@@ -600,4 +600,4 @@ export namespace NesRenameResponse {
export type NesRenameResponse = (tt.server.protocol.Response & {
body: NesRenameResponse.OK | NesRenameResponse.Failed;
}) | { type: 'cancelled' };
}) | { type: 'cancelled' };
@@ -451,7 +451,7 @@ namespace tss {
}
}
export type DirectSuperSymbolInfo = { extends?: { symbol: tt.Symbol; name: string } | undefined; implements?: { symbol: tt.Symbol; name: string }[] }
export type DirectSuperSymbolInfo = { extends?: { symbol: tt.Symbol; name: string } | undefined; implements?: { symbol: tt.Symbol; name: string }[] };
export class Symbols {
@@ -114,7 +114,7 @@ type ResolvedInput = {
startTime: number;
timeBudget: number;
requestStartTime: number;
}
};
const resolveInput = <T extends tt.server.protocol.FileRequestArgs & tt.server.protocol.Location & { timeBudget?: number; startTime?: number }>(args: T | undefined, defaultTimeBudget: number): ResolvedInput | FailedHandlerResponse => {
const requestStartTime = Date.now();
if (args === undefined) {
@@ -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');
@@ -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<OnContextComputedOnTimeoutEvent>;
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<T, U>(
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<void>;
@@ -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,
) {
@@ -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<boolean> | 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 };
}
}
}
@@ -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<ResolvedRunnableResult> };
export type OnContextComputedEvent = ContextComputedEvent & { items: ReadonlyArray<ContextItem> };
@@ -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<IRecordableLogEntry | IRecordableEditorLogEntry>());
readonly onStructuredData = this._onStructuredData.event;
@@ -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<IActionItem | undefined>;
}
@@ -277,7 +277,7 @@ export abstract class BaseAuthenticationService extends Disposable implements IA
// #endregion
//#region ADO Token
abstract getAdoAccessTokenBase64(options?: AuthenticationGetSessionOptions): Promise<string | undefined>
abstract getAdoAccessTokenBase64(options?: AuthenticationGetSessionOptions): Promise<string | undefined>;
//#endregion
protected async _handleAuthChangeEvent(): Promise<void> {
@@ -187,7 +187,7 @@ export type ChatFetchRetriableError<T> =
/**
* 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<T> =
{ type: ChatFetchResponseType.Success; value: T; requestId: string; serverRequestId: string | undefined; usage: APIUsage | undefined; resolvedModel: string };
@@ -640,6 +640,8 @@ export namespace ConfigKey {
export const UseAlternativeNESNotebookFormat = defineAndMigrateExpSetting<boolean>('chat.advanced.notebook.alternativeNESFormat.enabled', 'chat.notebook.alternativeNESFormat.enabled', false);
export const InlineChatSelectionRatioThreshold = defineSetting<number>('chat.inlineChat.selectionRatioThreshold', ConfigType.ExperimentBased, 0);
export const InlineChatReasoningEffort = defineSetting<string>('chat.inlineChat.reasoningEffort', ConfigType.ExperimentBased, 'low');
export const InlineChatEnableThinking = defineSetting<boolean>('chat.inlineChat.enableThinking', ConfigType.ExperimentBased, false);
export const InstantApplyShortModelName = defineAndMigrateExpSetting<string>('chat.advanced.instantApply.shortContextModelName', 'chat.instantApply.shortContextModelName', CHAT_MODEL.SHORT_INSTANT_APPLY);
export const InstantApplyShortContextLimit = defineAndMigrateExpSetting<number>('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<string | null>('chat.reasoningEffortOverride', ConfigType.Simple, null);
export const SessionSearchCloudSync = defineAndMigrateSetting<boolean>('chat.advanced.sessionSearch.cloudSync.enabled', 'chat.sessionSearch.cloudSync.enabled', false);
}
/**
@@ -70,7 +70,7 @@ type ICompletionModelCapabilities = {
type: 'completion';
family: string;
tokenizer: TokenizerType;
}
};
export enum ModelSupportedEndpoint {
ChatCompletions = '/chat/completions',
@@ -63,14 +63,18 @@ class AutoModeTokenBank extends Disposable {
this._fetchedValue = this._register(createCapiClientFetchedValue<AutoModeAPIResponse>(capiClientService, envService, {
request: async () => {
const authToken = (await authService.getCopilotToken()).token;
const autoModeHint = expService.getTreatmentVariable<string>(expName) || 'auto';
const extValue = expService.getTreatmentVariable<string>(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 },
@@ -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')
@@ -73,7 +73,7 @@ export type IModelConfig = {
max_completion_tokens?: number | null;
intent?: boolean | null;
};
}
};
export class OpenAICompatibleTestEndpoint extends ChatEndpoint {
constructor(
@@ -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;
@@ -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
@@ -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<SerializedDiagnostic, 'uri'>[];
}
};
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<SerializedDiagnostic, 'uri'>;
function serializeDiagnostic(diagnostic: Diagnostic, resource: Uri): SerializedDiagnostic;
@@ -31,7 +31,7 @@ export type RecentlyViewedDocumentsOptions = {
readonly includeViewedFiles: boolean;
readonly includeLineNumbers: IncludeLineNumbersOption;
readonly clippingStrategy: RecentFileClippingStrategy;
}
};
export namespace RecentlyViewedDocumentsOptions {
export const VALIDATOR: IValidator<Partial<RecentlyViewedDocumentsOptions>> = 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<Partial<CurrentFileOptions>> = 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.

Some files were not shown because too many files have changed in this diff Show More