mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-01 03:57:15 +01:00
Merge branch 'main' into dev/mjbvz/wonderful-piranha
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -257,3 +257,4 @@ extends:
|
||||
|
||||
publishExtension: ${{ parameters.publishExtension }}
|
||||
ghReleasePublishVSIX: true
|
||||
ghTagPrefix: 'copilot/'
|
||||
|
||||
@@ -243,3 +243,4 @@ extends:
|
||||
|
||||
publishExtension: ${{ parameters.publishExtension }}
|
||||
ghReleasePublishVSIX: true
|
||||
ghTagPrefix: 'copilot/'
|
||||
|
||||
+494
-18
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Generated
+40
-40
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
-1
@@ -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>;
|
||||
}
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
+13
-3
@@ -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`.
|
||||
|
||||
+9
-9
@@ -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());
|
||||
|
||||
+3
-3
@@ -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'
|
||||
});
|
||||
|
||||
+439
-157
@@ -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)`);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-6
@@ -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> {
|
||||
|
||||
+238
-76
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+34
-26
@@ -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,
|
||||
|
||||
+16
-3
@@ -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.
|
||||
|
||||
+464
-569
File diff suppressed because it is too large
Load Diff
+253
-6
@@ -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 () => {
|
||||
|
||||
+6
-29
@@ -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();
|
||||
});
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
+1
@@ -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();
|
||||
|
||||
+15
-14
@@ -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[]> {
|
||||
|
||||
+190
@@ -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
|
||||
|
||||
-24
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
) { }
|
||||
|
||||
+2
-2
@@ -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();
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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;
|
||||
|
||||
|
||||
+2
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
+103
-21
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+123
-5
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-21
@@ -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' };
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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');
|
||||
|
||||
+5
-5
@@ -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> };
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user