diff --git a/.github/skills/agent-host-logs/SKILL.md b/.github/skills/agent-host-logs/SKILL.md index a9678040a29bc..c728c015323f0 100644 --- a/.github/skills/agent-host-logs/SKILL.md +++ b/.github/skills/agent-host-logs/SKILL.md @@ -25,9 +25,12 @@ Files are collected best-effort, so a valid bundle may contain only some of thes events.jsonl usage.jsonl customizations.json -Agent Host.log -Window.log -Shared.log +agenthost.log +agenthost.1.log +agenthost-server.log +vscode-logs/Window/renderer.log +vscode-logs/Window/renderer.1.log +vscode-logs/Shared/sharedprocess.log ahp/*.jsonl copilot-logs/*.log remote-agenthost.log @@ -47,10 +50,10 @@ Window/client <-> AHP <-> Agent Host process <-> Copilot SDK | `usage.jsonl` | Client-captured token/credit usage, one record per model call (`turnId`, model, input/output/cache tokens, cumulative `totalNanoAiu`). The SDK's `assistant.usage` event is ephemeral and never reaches `events.jsonl`, so this is the only per-call usage record. Present only when agent-host debug logging was on. | | `customizations.json` | Snapshot of the skills/hooks/agents/MCP servers loaded for the session. The SDK's `session.*_loaded` events are ephemeral, so this is the only record of what was actually active. Present only when agent-host debug logging was on. | | `ahp/*.jsonl` | AHP traffic for a client connection. `_ahpLog.dir` is `c2s` or `s2c`; `_ahpLog.ts` is the wire timestamp. Use this to see requests, responses, subscriptions, actions, notifications, and client-visible ordering. | -| `Agent Host.log` | Local Agent Host process behavior: startup, auth, sessions, provider events, tools, Git/worktrees, and host-side errors. | +| `agenthost*.log` | Local or server Agent Host process behavior: startup, auth, sessions, provider events, tools, Git/worktrees, and host-side errors. Numbered files are older rotated segments. | | `copilot-logs/*.log` | Copilot SDK process logs that mention the selected session ID. A process log may contain other sessions too. | -| `Window.log` | Renderer/client behavior: connections, session adapters, UI state, permissions, rendering, and client-side errors. | -| `Shared.log` | Shared-process activity. Usually secondary evidence and often noisy. | +| `vscode-logs/Window/*` | Current and rotated files from the Window log group, including renderer/client behavior, network activity, views, and other window-owned logs. | +| `vscode-logs/Shared/*` | Current and rotated files from the Shared log group. Usually secondary evidence and often noisy. | | `Agent Host ().log` | Forwarded logs from a named remote Agent Host. | | `remote-agenthost.log` | A directly downloaded remote `agenthost.log`, when available. | @@ -62,9 +65,9 @@ Window/client <-> AHP <-> Agent Host process <-> Copilot SDK - Turn or provider behavior: `events.jsonl` - Token/credit usage or cost questions: `usage.jsonl` - Client/server state or ordering: `ahp/*.jsonl` - - Host implementation failure: `Agent Host.log` + - Host implementation failure: `agenthost*.log` - SDK behavior: `copilot-logs/*.log` - - UI behavior: `Window.log` + - UI behavior: `vscode-logs/Window/renderer.log` and its rotated segments 4. Search by the known time or ID, then follow the same operation into the adjacent layer. Useful correlation fields include the raw session ID, session/chat URI, `turnId`, `interactionId`, tool/request IDs, JSON-RPC request `id`, AHP `serverSeq`, and event `id`/`parentId`. diff --git a/.github/skills/ui-scenario-validation/SKILL.md b/.github/skills/ui-scenario-validation/SKILL.md deleted file mode 100644 index f091d33aa9404..0000000000000 --- a/.github/skills/ui-scenario-validation/SKILL.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -name: ui-scenario-validation -description: Use when reproducing a UI bug or verifying a fix by driving a real VS Code window end to end and capturing evidence. Launches VS Code through the automation MCP, performs the scenario as a user would, and produces a video, per-step screenshots, a Playwright trace, and an HTML report to attach to an issue or pull request. ---- - -# UI Scenario Validation - -Drives a real VS Code instance through a scenario and records reproducible evidence. - -Use this to reproduce a reported bug, to show that a fix works, or to attach a recording to a -test-plan item. For deterministic regression coverage that runs on every build, write a smoke test -instead (see the `smoke-tests` skill) — this skill is for one-off, issue-derived validation. - -## Prerequisites - -```bash -npm install # once -npm run electron # download the Electron runtime -npm run transpile-client # or `npm run watch` in another terminal -npm --prefix test/mcp run compile -``` - -The automation MCP server is `test/mcp` (`out/stdio.js`). Add it to your MCP configuration so the -`vscode_automation_*` tools are available. - -| Target | Args | Use for | -|--------|------|---------| -| Dev build from this checkout | *(none)* | Verifying a local change | -| Installed Insiders | `--build ` | Reproducing a report against shipped behavior | -| Web | `--web --headless` | Browser-only behavior | - -`--build` takes the application root — the install directory on Windows and Linux, or the `.app` -bundle on macOS. For example: - -```bash -# Windows ---build "C:/Users//AppData/Local/Programs/Microsoft VS Code Insiders" -# macOS ---build "/Applications/Visual Studio Code - Insiders.app" -``` - -An installed build runs with an isolated profile, so your own extensions and settings do not leak -into the recording. Note that Insiders only reproduces **shipped** behavior — to validate an -unmerged change you must run the dev build from a checkout that contains it. - -## Record a clean capture - -Set `VSCODE_EVIDENCE_CLEAN_CAPTURE=1` in the MCP server environment. - -Evidence capture can draw a step banner into the window it is recording. That banner is part of the -DOM of the product under test, so it can shift layout and affect focus and selectors. With clean -capture enabled the recording shows unmodified UI, and step boundaries are still recorded in -`manifest.json` with timestamps and screenshots. - -Add the step titles back afterwards, once the recording is finished: - -```bash -node test/mcp/out/renderEvidenceChapters.js .build/vscode-playwright-mcp/evidence/ -``` - -This writes `videos/annotated.mp4` with a full-screen card before each step, inserted between -segments so no recorded frame is hidden. It needs `ffmpeg` and `ffprobe` on `PATH`; without them it -prints a warning and leaves the raw recording untouched. - -## Run a scenario - -1. Choose a **disposable** workspace folder. Never point a scenario at real work: the run types, - clicks, and may modify files. Nothing in the recording should contain credentials, tokens, or - private conversations. -2. Call `vscode_automation_evidence_start` **before** any other automation tool, passing the - scenario id, title, the source issue URL, and the workspace path. It launches VS Code with an - isolated profile and starts video plus tracing. -3. For each step: - - call `vscode_automation_evidence_step` with `status: started` and a one-line intent; - - inspect the accessibility snapshot before choosing a selector; - - prefer feature-specific automation tools, then semantic selectors, then coordinates; - - perform the action the way a user would; - - **validate through a separate observable signal** — an action completing is not a result; - - call the step again with `passed`, `failed`, or `skipped` plus concise details. -4. Call `vscode_automation_evidence_finish` with the overall outcome. This stops VS Code and - finalizes the video, trace, screenshots, `manifest.json`, and `report.html`. - -Stop at the first failed required step unless the scenario says otherwise, and mark steps that need -unavailable hardware, accounts, or services as `skipped` rather than passed. - -## What makes evidence trustworthy - -- Assert on DOM state, accessibility, focus, or text — screenshots support a claim, they do not - establish one. -- If the bug is a race, make the timing explicit (for example a forced delay or a repeated loop) so - the recording shows the window in which it occurs rather than relying on luck. -- Record the failing behavior before the fix when you can. A passing run alone does not show that - the scenario would have caught the bug. - -## Report - -Evidence is written to `.build/vscode-playwright-mcp/evidence//`: - -| File | Contents | -|------|----------| -| `report.html` | Step table, outcome, embedded video | -| `manifest.json` | Step timestamps, statuses, artifact paths, environment | -| `videos/` | Screen recording, plus `annotated.mp4` once chapters are rendered | -| `*.png` | Per-step screenshots | -| `logs/` | Playwright trace, window and server logs | - -Summarize the outcome, list failed or skipped steps, link `report.html`, and state the OS, VS Code -commit, and source issue. Attach the video to the issue or pull request by dragging it into the -comment box. - -## Automated validation on a pull request - -`microsoft/vscode-engineering` runs the same harness in CI: labelling a pull request -`~requires-ui-validation` researches the change, runs a checked-in scenario adapter against the -exact merge candidate, and posts the per-step result with chaptered video. Use this skill when a -scenario is not yet covered there, or to iterate locally before proposing one. diff --git a/.github/skills/validate-ui-scenario/SKILL.md b/.github/skills/validate-ui-scenario/SKILL.md new file mode 100644 index 0000000000000..0288ef46749b5 --- /dev/null +++ b/.github/skills/validate-ui-scenario/SKILL.md @@ -0,0 +1,197 @@ +--- +name: validate-ui-scenario +description: Use when reproducing a UI bug or verifying a fix by driving a real VS Code window end to end and capturing evidence. Writes a scenario file, runs it against a dev build or installed Insiders, and produces a captioned video, per-step screenshots, a Playwright trace, and an HTML report to attach to an issue or pull request. +--- + +# Validate UI Scenario + +Drives a real VS Code instance through a scenario and records reproducible evidence. + +Use this to reproduce a reported bug, to show that a fix works, or to attach a recording to a +test-plan item. For deterministic regression coverage that runs on every build, write a smoke test +instead (see the `smoke-tests` skill) — this skill is for one-off, issue-derived validation. + +A scenario is a small JavaScript file run by `test/mcp/out/runScenario.js`. Nothing else has to be +configured: the runner launches VS Code, records video and a trace, captures a screenshot at every +step boundary, writes the report, and captions the recording with each step and its result. + +## Prepare + +```bash +npm install # once +npm --prefix test/mcp run compile # after any change under test/mcp +``` + +Add `ffmpeg` and `ffprobe` to `PATH` to get the caption band on the video. Without them the run still +succeeds and the raw recording is kept. + +| Target | Extra flags | Also required | Use for | +|--------|-------------|---------------|---------| +| Installed Insiders | `--build ` | nothing | Reproducing a report against shipped behavior | +| Dev build from this checkout | *(none)* | `npm run electron`, `npm run transpile-client` | Verifying an unmerged change | +| Web | `--web --headless` | `npm run transpile-client` | Browser-only behavior | + +`--build` takes the application root — the install directory on Windows and Linux, or the `.app` +bundle on macOS: + +```bash +# Windows +--build "C:/Users//AppData/Local/Programs/Microsoft VS Code Insiders" +# macOS +--build "/Applications/Visual Studio Code - Insiders.app" +``` + +An installed build runs with its own profile and extensions directory, so your extensions and +settings never leak into the recording. Insiders only reproduces **shipped** behavior — to validate +an unmerged change, run the dev build from a checkout that contains it. + +## Write the scenario + +Save the file next to the run it produces, for example +`.build/vscode-playwright-mcp/.cjs`. The **`.cjs`** extension matters: this package is an +ES module package, so a CommonJS scenario named `.js` fails to load. An ES module scenario with a +default export works too. + +```js +const os = require('os'); +const path = require('path'); +const fs = require('fs'); + +const workspacePath = path.join(os.tmpdir(), 'issue-250159-workspace'); +fs.mkdirSync(workspacePath, { recursive: true }); + +// The settings tree is virtualized, so only the rows near the viewport exist in +// the DOM. Scroll the whole list, otherwise "the setting is absent" cannot be +// told apart from "the setting is below the fold". +const COLLECT_TITLES = `(async () => { + const editor = document.querySelector('.settings-editor'); + const scrollable = editor.querySelector('.settings-tree-container .monaco-scrollable-element'); + const titles = new Set(); + const collect = () => editor.querySelectorAll('.setting-item-label') + .forEach(node => titles.add(node.textContent.trim())); + collect(); + for (let previous = -1; scrollable && scrollable.scrollTop !== previous;) { + previous = scrollable.scrollTop; + scrollable.scrollTop = previous + scrollable.clientHeight; + await new Promise(resolve => setTimeout(resolve, 180)); + collect(); + } + return [...titles]; +})()`; + +module.exports = { + id: 'vscode-250159-settings-search', + title: 'Settings search matches across title and description', + source: 'https://github.com/microsoft/vscode/issues/250159', + workspacePath, + steps: [ + { + id: 'SS-01', + title: 'Open the Settings editor', + async run(context) { + await context.workbench.quickaccess.runCommand('workbench.action.openSettings2'); + await context.page.waitForSelector('.settings-editor', { state: 'visible', timeout: 20000 }); + return 'The Settings editor is visible.'; + } + }, + { + id: 'SS-02', + title: 'Search across title and description', + async run(context) { + await context.workbench.settingsEditor.searchSettingsUI('chat confirm'); + const titles = await context.page.evaluate(COLLECT_TITLES); + if (!titles.some(title => /max\s*requests/iu.test(title))) { + throw new Error(`Max Requests is absent. Found: ${titles.join(', ')}`); + } + return 'Max Requests is present in the results.'; + } + } + ] +}; +``` + +| Field | Meaning | +|-------|---------| +| `id`, `title` | Identify the run; `id` names the evidence directory | +| `source` | Issue or test-plan item the scenario came from | +| `workspacePath` | Disposable folder to open | +| `userSettings` | Settings seeded into the profile before launch | +| `extraArgs` | Extra VS Code command-line arguments | + +Each step receives a `context` with `app`, `workbench`, `code`, `page`, and `skip(reason)`. +`workbench` exposes the feature helpers (`settingsEditor`, `quickaccess`, `editors`, `terminal`, +`chat`, …); `page` is the Playwright page for anything they do not cover. + +- **Return a string** describing how the step was validated. It appears in the report. +- **Throw** to fail the step. The message is recorded, and the run stops. +- **Call `skip(reason)`** when hardware, an account, or a service is unavailable. The run stops and + is reported as `aborted`, never as passed. + +## Run it + +```bash +node test/mcp/out/runScenario.js --build "" +``` + +Exit code `0` means every step passed, `1` means the run failed or was aborted, `2` a usage error. + +Evidence is written to `.build/vscode-playwright-mcp/evidence//`: + +| File | Contents | +|------|----------| +| `report.html` | Step table, outcome, embedded video | +| `manifest.json` | Step timestamps, statuses, artifact paths, environment | +| `videos/annotated.mp4` | Recording with a caption band showing each step and its validation result | +| `videos/*.webm` | The raw recording | +| `*.png` | Per-step screenshots | +| `logs/` | Playwright trace, window and server logs | + +The caption band is added **above** the recorded frame rather than drawn over it, so no recorded +pixel is hidden and the recording keeps its original length. Each caption carries the step number +and id, its status, the step title, and the validation detail the step reported. Re-render after +editing a manifest with `node test/mcp/out/renderEvidenceChapters.js `. + +## What makes evidence trustworthy + +- Assert on DOM state, accessibility, focus, or text — screenshots support a claim, they do not + establish one. +- Validate through a signal separate from the action. An automation call returning successfully is + not a result. +- Beware virtualized lists. The settings tree and long lists render only the rows near the viewport, + so scroll the whole list before concluding that something is absent. +- If the bug is a race, make the timing explicit — a forced delay or a repeated loop — so the + recording shows the window in which it occurs rather than relying on luck. +- Record the failing behavior before the fix when you can. A passing run alone does not show that + the scenario would have caught the bug. + +## Report back + +Summarize the outcome, list failed or skipped steps, link `report.html`, and state the OS, the +VS Code version and quality (both are in `manifest.json`), and the source issue. Attach the video to +the issue or pull request by dragging it into the comment box. + +## Related + +- **Interactive exploration.** `test/mcp` also serves these tools over MCP (`vscode_automation_*`), + which helps when you need to inspect the UI before knowing what to assert. Configure it as an MCP + server with `cwd` `test/mcp` and command `npm run start-stdio`. +- **Automated validation on a pull request.** `microsoft/vscode-engineering` runs the same harness + in CI: labelling a pull request `~requires-ui-validation` researches the change, runs a scenario + against the exact merge candidate, and posts the per-step result with captioned video. Use this + skill when a scenario is not yet covered there, or to iterate locally before proposing one. + + +User: "/validate-ui-scenario reproduce https://github.com/microsoft/vscode/issues/250159 against my +installed VS Code Insiders, and give me the report and the annotated video." + +1. Read the issue and identify the observable claim: searching `chat confirm` in the Settings editor + should match **Max Requests**, whose description mentions confirmation. +2. Add a baseline step (`max requests` finds the setting) so a failure cannot be explained by the + setting being missing from the build. +3. Write `.build/vscode-playwright-mcp/issue-250159.cjs`, run it with `--build`, and read the + printed report path. +4. Report the outcome per step, link `report.html`, and attach `videos/annotated.mp4`. + +The run fails at the search step, and that is the answer: the issue reproduces. Report it as a +successful reproduction, not as a broken scenario. + diff --git a/build/next/devTunnelsShims/bufferutil.cjs b/build/next/devTunnelsShims/bufferutil.cjs deleted file mode 100644 index 92adc2c81a6ce..0000000000000 --- a/build/next/devTunnelsShims/bufferutil.cjs +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -exports.mask = function mask(source, mask, output, offset, length) { - for (let i = 0; i < length; i++) { - output[offset + i] = source[i] ^ mask[i & 3]; - } -}; - -exports.unmask = function unmask(buffer, mask) { - for (let i = 0; i < buffer.length; i++) { - buffer[i] ^= mask[i & 3]; - } -}; diff --git a/build/next/devTunnelsShims/utf8Validate.cjs b/build/next/devTunnelsShims/utf8Validate.cjs deleted file mode 100644 index 72acbbbaca312..0000000000000 --- a/build/next/devTunnelsShims/utf8Validate.cjs +++ /dev/null @@ -1,15 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -const utf8Decoder = new TextDecoder('utf-8', { fatal: true }); - -module.exports = function isValidUTF8(buffer) { - try { - utf8Decoder.decode(buffer); - return true; - } catch { - return false; - } -}; diff --git a/build/next/devTunnelsWeb.ts b/build/next/devTunnelsWeb.ts index d1f258a6df577..4a0ec0abfabfc 100644 --- a/build/next/devTunnelsWeb.ts +++ b/build/next/devTunnelsWeb.ts @@ -24,7 +24,6 @@ const allowedImporterRoots = [ path.join(NODE_MODULES_ROOT, '@microsoft', 'dev-tunnels-connections'), path.join(NODE_MODULES_ROOT, '@microsoft', 'dev-tunnels-management'), path.join(NODE_MODULES_ROOT, '@microsoft', 'dev-tunnels-contracts'), - path.join(NODE_MODULES_ROOT, 'websocket'), ]; const nodeBuiltinNames = ['net', 'os', 'path', 'crypto', 'child_process', 'fs', 'http', 'https', 'tls', 'dns', 'zlib']; const nodeBuiltinFilter = new RegExp(`^(?:node:)?(?:${nodeBuiltinNames.join('|')}|stream|buffer)$`); @@ -100,25 +99,18 @@ export function devTunnelsBrowserShimPlugin(): esbuild.Plugin { return { path: path.join(SHIMS_ROOT, 'empty.cjs') }; }); - build.onResolve({ filter: /^\.[\\/]node[\\/]/ }, args => { - if (!isSshNodeAlgorithmImport(args)) { - return; - } - return { path: path.join(SHIMS_ROOT, 'empty.cjs') }; - }); - - build.onResolve({ filter: /^bufferutil$/ }, args => { + build.onResolve({ filter: /^websocket$/ }, args => { if (!isAllowedImporter(args.importer)) { return; } - return { path: path.join(SHIMS_ROOT, 'bufferutil.cjs') }; + return { path: path.join(SHIMS_ROOT, 'empty.cjs') }; }); - build.onResolve({ filter: /^utf-8-validate$/ }, args => { - if (!isAllowedImporter(args.importer)) { + build.onResolve({ filter: /^\.[\\/]node[\\/]/ }, args => { + if (!isSshNodeAlgorithmImport(args)) { return; } - return { path: path.join(SHIMS_ROOT, 'utf8Validate.cjs') }; + return { path: path.join(SHIMS_ROOT, 'empty.cjs') }; }); build.onResolve({ filter: /^vscode-jsonrpc$/ }, args => { diff --git a/build/next/devTunnelsWebEntry.js b/build/next/devTunnelsWebEntry.js index 604f2b82f969f..7c9afff65cb36 100644 --- a/build/next/devTunnelsWebEntry.js +++ b/build/next/devTunnelsWebEntry.js @@ -6,14 +6,10 @@ import { TunnelManagementHttpClient, ManagementApiVersions } from '@microsoft/dev-tunnels-management'; import { TunnelRelayTunnelClient } from '@microsoft/dev-tunnels-connections'; import { TunnelAccessScopes } from '@microsoft/dev-tunnels-contracts'; -// The package root resolves to lib/browser.js, a native-WebSocket wrapper. This deep import provides -// RFC 6455 framing for an existing duplex stream and must not be replaced with the package root. -import WebSocketConnection from 'websocket/lib/WebSocketConnection'; export { TunnelManagementHttpClient, ManagementApiVersions, TunnelRelayTunnelClient, TunnelAccessScopes, - WebSocketConnection, }; diff --git a/src/vs/base/browser/ui/list/listWidget.ts b/src/vs/base/browser/ui/list/listWidget.ts index 45458741adbd9..90c01a3e640d1 100644 --- a/src/vs/base/browser/ui/list/listWidget.ts +++ b/src/vs/base/browser/ui/list/listWidget.ts @@ -2038,6 +2038,10 @@ export class List implements ISpliceable, IDisposable { return this.view.elementTop(index); } + getElementHeight(index: number): number { + return this.view.elementHeight(index); + } + style(styles: IListStyles): void { this.styleController.style(styles); } diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 0c7cb43b7fb07..8c9ad8ebb0722 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -1391,6 +1391,12 @@ class StickyScrollController extends Disposable { this._register(view.onDidScroll(() => this.update())); this._register(view.onDidChangeContentHeight(() => this.update())); this._register(tree.onDidChangeCollapseState(() => this.update())); + this._register(this._widget.onDidChangeHeight(heightChanges => { + // Update the list's tracked element heights with the measured values + for (const { index, height } of heightChanges) { + this.view.updateElementHeight(index, height); + } + })); this._register(model.onDidSpliceRenderedNodes((e) => { const state = this._widget.state; if (!state) { @@ -1498,6 +1504,10 @@ class StickyScrollController extends Disposable { return undefined; } + if (this.tree.options.stickyScrollShowOnlyWhenNodeFullyHidden) { + return undefined; + } + if (this.nodeTopAlignsWithStickyNodesBottom(firstVisibleNodeUnderWidget, stickyNodesHeight)) { return undefined; } @@ -1513,8 +1523,22 @@ class StickyScrollController extends Disposable { return this.view.scrollTop === elementTop - stickyPosition; } + private getNodeHeight(node: ITreeNode): number { + const nodeLocation = this.model.getNodeLocation(node); + const index = this.model.getListIndex(nodeLocation); + if (index >= 0) { + return this.view.getElementHeight(index); + } + return this.treeDelegate.getHeight(node); + } + + private clampNodeHeight(height: number): number { + const max = this.tree.options.stickyScrollMaxNodeHeight; + return max !== undefined ? Math.min(height, max) : height; + } + private createStickyScrollNode(node: ITreeNode, currentStickyNodesHeight: number): StickyScrollNode { - const height = this.treeDelegate.getHeight(node); + const height = this.clampNodeHeight(this.getNodeHeight(node)); const { startIndex, endIndex } = this.getNodeRange(node); const position = this.calculateStickyNodePosition(endIndex, currentStickyNodesHeight, height); @@ -1547,7 +1571,7 @@ class StickyScrollController extends Disposable { // If the last descendant is only partially visible at the top of the view, getRelativeTop() returns null // In that case, utilize the next node's relative top to calculate the sticky node's position if (lastChildRelativeTop === null && this.view.firstVisibleIndex === lastDescendantIndex && lastDescendantIndex + 1 < this.view.length) { - const nodeHeight = this.treeDelegate.getHeight(this.view.element(lastDescendantIndex)); + const nodeHeight = this.view.getElementHeight(lastDescendantIndex); const nextNodeRelativeTop = this.view.getRelativeTop(lastDescendantIndex + 1); lastChildRelativeTop = nextNodeRelativeTop ? nextNodeRelativeTop - nodeHeight / this.view.renderHeight : null; } @@ -1556,8 +1580,7 @@ class StickyScrollController extends Disposable { return stickyRowPositionTop; } - const lastChildNode = this.view.element(lastDescendantIndex); - const lastChildHeight = this.treeDelegate.getHeight(lastChildNode); + const lastChildHeight = this.view.getElementHeight(lastDescendantIndex); const topOfLastChild = lastChildRelativeTop * this.view.renderHeight; const bottomOfLastChild = topOfLastChild + lastChildHeight; @@ -1637,7 +1660,7 @@ class StickyScrollController extends Disposable { let widgetHeight = 0; for (let i = 0; i < ancestors.length && i < this.stickyScrollMaxItemCount; i++) { - widgetHeight += this.treeDelegate.getHeight(ancestors[i]); + widgetHeight += this.clampNodeHeight(this.getNodeHeight(ancestors[i])); } return widgetHeight; } @@ -1690,6 +1713,9 @@ class StickyScrollWidget implements IDisposable { readonly onDidChangeHasFocus: Event; readonly onContextMenu: Event>; + private readonly _onDidChangeHeight = new Emitter<{ index: number; height: number }[]>(); + readonly onDidChangeHeight = this._onDidChangeHeight.event; + constructor( container: HTMLElement, private readonly view: List>, @@ -1714,8 +1740,7 @@ class StickyScrollWidget implements IDisposable { if (!this._previousState) { return 0; } - const lastElement = this._previousState.stickyNodes[this._previousState.count - 1]; - return lastElement.position + lastElement.height; + return this.getRootHeight(this._previousState); } get count(): number { @@ -1761,8 +1786,7 @@ class StickyScrollWidget implements IDisposable { this._previousState = state; - // Set the height of the widget to the bottom of the last sticky node - this._rootDomNode.style.height = `${lastStickyNode.position + lastStickyNode.height}px`; + this.updateRootHeight(state); } private renderState(state: StickyScrollState): void { @@ -1782,11 +1806,68 @@ class StickyScrollWidget implements IDisposable { this.stickyScrollFocus.updateElements(elements, state); this._previousElements = elements; + + // Probe dynamic heights after rendering into DOM + this.probeDynamicHeights(state, elements); } rerender(): void { if (this._previousState) { this.renderState(this._previousState); + this.updateRootHeight(this._previousState); + } + } + + private updateRootHeight(state: StickyScrollState): void { + this._rootDomNode.style.height = `${this.getRootHeight(state)}px`; + } + + private getRootHeight(state: StickyScrollState): number { + const lastStickyNode = state.stickyNodes[state.count - 1]; + const lastStickyElement = this._previousElements[state.count - 1]; + const lastStickyElementHeight = lastStickyElement?.offsetHeight ?? lastStickyNode.height; + return lastStickyNode.position + lastStickyElementHeight; + } + + private probeDynamicHeights(state: StickyScrollState, elements: HTMLElement[]): void { + const heightChanges: { index: number; height: number }[] = []; + + for (let i = 0; i < state.count; i++) { + const stickyNode = state.stickyNodes[i]; + if (!this.treeDelegate.hasDynamicHeight || !this.treeDelegate.hasDynamicHeight(stickyNode.node)) { + continue; + } + + const element = elements[i]; + // Temporarily clear the explicit height to allow the element to size naturally + const previousHeight = element.style.height; + element.style.height = ''; + + const measuredHeight = element.offsetHeight; + if (measuredHeight <= 0) { + element.style.height = previousHeight; + continue; + } + const maxNodeHeight = this.tree.options.stickyScrollMaxNodeHeight; + const clampedMeasuredHeight = maxNodeHeight !== undefined ? Math.min(measuredHeight, maxNodeHeight) : measuredHeight; + + // Always update the sticky element's visual height to match the measured content + if (this.tree.options.setRowHeight !== false) { + element.style.height = `${clampedMeasuredHeight}px`; + } + if (this.tree.options.setRowLineHeight !== false) { + element.style.lineHeight = `${clampedMeasuredHeight}px`; + } + + // Only propagate height increases to the real row — never shrink it, + // since sticky elements may have CSS truncation (e.g. line-clamp). + if (clampedMeasuredHeight > stickyNode.height) { + heightChanges.push({ index: stickyNode.startIndex, height: clampedMeasuredHeight }); + } + } + + if (heightChanges.length > 0) { + this._onDidChangeHeight.fire(heightChanges); } } @@ -1798,14 +1879,19 @@ class StickyScrollWidget implements IDisposable { const stickyElement = document.createElement('div'); stickyElement.style.top = `${stickyNode.position}px`; + const maxNodeHeight = this.tree.options.stickyScrollMaxNodeHeight; + const clampedHeight = maxNodeHeight !== undefined ? Math.min(stickyNode.height, maxNodeHeight) : stickyNode.height; + if (this.tree.options.setRowHeight !== false) { - stickyElement.style.height = `${stickyNode.height}px`; + stickyElement.style.height = `${clampedHeight}px`; } if (this.tree.options.setRowLineHeight !== false) { - stickyElement.style.lineHeight = `${stickyNode.height}px`; + stickyElement.style.lineHeight = `${clampedHeight}px`; } + stickyElement.style.overflow = 'hidden'; + stickyElement.classList.add('monaco-tree-sticky-row'); stickyElement.classList.add('monaco-list-row'); @@ -1880,9 +1966,6 @@ class StickyScrollWidget implements IDisposable { container.setAttribute('aria-level', `${ariaLevel}`); } - // Sticky Scroll elements can not be selected - container.setAttribute('aria-selected', String(false)); - return result; } @@ -1907,6 +1990,7 @@ class StickyScrollWidget implements IDisposable { } dispose(): void { + this._onDidChangeHeight.dispose(); this.stickyScrollFocus.dispose(); this._previousStateDisposables.dispose(); this._rootDomNode.remove(); @@ -2226,6 +2310,8 @@ export interface IAbstractTreeOptionsUpdate extends ITreeRendererOptions { readonly expandOnlyOnTwistieClick?: boolean | ((e: T) => boolean); readonly enableStickyScroll?: boolean; readonly stickyScrollMaxItemCount?: number; + readonly stickyScrollMaxNodeHeight?: number; + readonly stickyScrollShowOnlyWhenNodeFullyHidden?: boolean; readonly paddingTop?: number; } diff --git a/src/vs/base/browser/ui/tree/objectTree.ts b/src/vs/base/browser/ui/tree/objectTree.ts index d9100592a4b14..ef196a252848f 100644 --- a/src/vs/base/browser/ui/tree/objectTree.ts +++ b/src/vs/base/browser/ui/tree/objectTree.ts @@ -73,6 +73,10 @@ export class ObjectTree extends AbstractTree maximum32BitPayloadLength) { + throw new Error('WebSocket frame payload limits must be unsigned 32-bit integers.'); + } + } + + /** + * Accepts a network chunk and returns every complete frame it contains. + */ + acceptChunk(data: VSBuffer): readonly IWebSocketFrame[] { + if (data.byteLength === 0) { + return []; + } + + this._incomingData.acceptChunk(data); + const frames: IWebSocketFrame[] = []; + while (this._incomingData.byteLength >= 2) { + const initialHeader = this._incomingData.peek(2); + const firstByte = initialHeader.readUInt8(0); + const secondByte = initialHeader.readUInt8(1); + const payloadLengthMarker = secondByte & payloadLengthMask; + const extendedPayloadLengthSize = payloadLengthMarker === extendedPayloadLength16 ? 2 : payloadLengthMarker === extendedPayloadLength64 ? 8 : 0; + const masked = (secondByte & secondByteMaskedMask) !== 0; + const headerLength = 2 + extendedPayloadLengthSize + (masked ? 4 : 0); + if (this._incomingData.byteLength < headerLength) { + break; + } + + const header = this._incomingData.peek(headerLength); + const payloadLength = getPayloadLength(header, payloadLengthMarker); + if (payloadLength > this._maxPayloadLength) { + throw new WebSocketFrameTooLargeError(payloadLength, this._maxPayloadLength); + } + validateFrame(firstByte, payloadLength); + const opcode = firstByte & 0b00001111; + validateOpcode(opcode); + if (this._incomingData.byteLength < headerLength + payloadLength) { + break; + } + + this._incomingData.read(headerLength); + const payload = this._incomingData.read(payloadLength); + const mask = masked ? header.readUInt32BE(headerLength - 4) : undefined; + let unmaskedPayload = payload; + if (mask !== undefined) { + if (this._unmaskInPlace) { + applyWebSocketMask(unmaskedPayload, mask); + } else { + unmaskedPayload = copyAndApplyWebSocketMask(payload, mask); + } + } + frames.push({ + final: (firstByte & firstByteFinalMask) !== 0, + compressed: (firstByte & firstByteCompressedMask) !== 0, + opcode, + payload: unmaskedPayload, + mask, + }); + } + return frames; + } +} + +/** + * Encodes one RFC 6455 frame without mutating the supplied payload. + */ +export function encodeWebSocketFrame(payload: VSBuffer, options: IWebSocketFrameOptions): VSBuffer { + const final = options.final ?? true; + const compressed = options.compressed ?? false; + validateOpcode(options.opcode); + validateMask(options.mask); + validateFrame((final ? firstByteFinalMask : 0) | (compressed ? firstByteCompressedMask : 0) | options.opcode, payload.byteLength); + + const headerLength = getHeaderLength(payload.byteLength, options.mask !== undefined); + const header = VSBuffer.alloc(headerLength); + header.writeUInt8((final ? firstByteFinalMask : 0) | (compressed ? firstByteCompressedMask : 0) | options.opcode, 0); + + let offset = 2; + if (payload.byteLength < extendedPayloadLength16) { + header.writeUInt8((options.mask === undefined ? 0 : secondByteMaskedMask) | payload.byteLength, 1); + } else if (payload.byteLength < 2 ** 16) { + header.writeUInt8((options.mask === undefined ? 0 : secondByteMaskedMask) | extendedPayloadLength16, 1); + header.writeUInt8(payload.byteLength >>> 8, offset++); + header.writeUInt8(payload.byteLength, offset++); + } else { + header.writeUInt8((options.mask === undefined ? 0 : secondByteMaskedMask) | extendedPayloadLength64, 1); + header.writeUInt32BE(0, offset); + offset += 4; + header.writeUInt32BE(payload.byteLength, offset); + offset += 4; + } + + if (options.mask === undefined) { + return VSBuffer.concat([header, payload]); + } + + header.writeUInt32BE(options.mask, offset); + return VSBuffer.concat([header, copyAndApplyWebSocketMask(payload, options.mask)]); +} + +/** Applies an RFC 6455 four-byte mask to a buffer in place. */ +export function applyWebSocketMask(payload: VSBuffer, mask: number): void { + validateMask(mask); + if (mask === 0) { + return; + } + + const wordCount = payload.byteLength >>> 2; + for (let index = 0; index < wordCount; index++) { + const offset = index * 4; + payload.writeUInt32BE(payload.readUInt32BE(offset) ^ mask, offset); + } + + const offset = wordCount * 4; + const remainingByteCount = payload.byteLength - offset; + if (remainingByteCount >= 1) { + payload.writeUInt8(payload.readUInt8(offset) ^ ((mask >>> 24) & 0xff), offset); + } + if (remainingByteCount >= 2) { + payload.writeUInt8(payload.readUInt8(offset + 1) ^ ((mask >>> 16) & 0xff), offset + 1); + } + if (remainingByteCount >= 3) { + payload.writeUInt8(payload.readUInt8(offset + 2) ^ ((mask >>> 8) & 0xff), offset + 2); + } +} + +function getHeaderLength(payloadLength: number, masked: boolean): number { + if (payloadLength > maximum32BitPayloadLength) { + throw new Error('WebSocket payload lengths greater than 2^32 - 1 are not supported.'); + } + if (payloadLength < extendedPayloadLength16) { + return 2 + (masked ? 4 : 0); + } + if (payloadLength < 2 ** 16) { + return 4 + (masked ? 4 : 0); + } + return 10 + (masked ? 4 : 0); +} + +function getPayloadLength(header: VSBuffer, marker: number): number { + if (marker < extendedPayloadLength16) { + return marker; + } + if (marker === extendedPayloadLength16) { + return header.readUInt8(2) * 2 ** 8 + header.readUInt8(3); + } + + const highBits = header.readUInt32BE(2); + if (highBits !== 0) { + throw new Error('WebSocket payload lengths greater than 2^32 - 1 are not supported.'); + } + return header.readUInt32BE(6); +} + +function validateFrame(firstByte: number, payloadLength: number): void { + if ((firstByte & firstByteReservedMask) !== 0) { + throw new Error('WebSocket frames must not set RSV2 or RSV3.'); + } + + const opcode = firstByte & 0b00001111; + validateOpcode(opcode); + if (isControlOpcode(opcode)) { + if ((firstByte & firstByteFinalMask) === 0) { + throw new Error('WebSocket control frames must be final.'); + } + if (payloadLength > 125) { + throw new Error('WebSocket control frames must not exceed 125 bytes.'); + } + if ((firstByte & firstByteCompressedMask) !== 0) { + throw new Error('WebSocket control frames must not set RSV1.'); + } + } +} + +function validateOpcode(opcode: number): asserts opcode is WebSocketOpcode { + if (opcode !== WebSocketOpcode.Continuation + && opcode !== WebSocketOpcode.Text + && opcode !== WebSocketOpcode.Binary + && opcode !== WebSocketOpcode.Close + && opcode !== WebSocketOpcode.Ping + && opcode !== WebSocketOpcode.Pong) { + throw new Error(`WebSocket frame has reserved opcode ${opcode}.`); + } +} + +function validateMask(mask: number | undefined): void { + if (mask !== undefined && (!Number.isInteger(mask) || mask < 0 || mask > maximum32BitPayloadLength)) { + throw new Error('WebSocket masks must be unsigned 32-bit integers.'); + } +} + +function isControlOpcode(opcode: number): boolean { + return (opcode & 0b00001000) !== 0; +} + +function copyAndApplyWebSocketMask(payload: VSBuffer, mask: number): VSBuffer { + const maskedPayload = VSBuffer.alloc(payload.byteLength); + maskedPayload.set(payload); + applyWebSocketMask(maskedPayload, mask); + return maskedPayload; +} diff --git a/src/vs/base/parts/ipc/node/ipc.net.ts b/src/vs/base/parts/ipc/node/ipc.net.ts index aa5b367355f65..d97410ac0850c 100644 --- a/src/vs/base/parts/ipc/node/ipc.net.ts +++ b/src/vs/base/parts/ipc/node/ipc.net.ts @@ -16,7 +16,8 @@ import { join } from '../../../common/path.js'; import { Platform, platform } from '../../../common/platform.js'; import { generateUuid } from '../../../common/uuid.js'; import { ClientConnectionEvent, IPCServer } from '../common/ipc.js'; -import { ChunkStream, Client, ISocket, Protocol, SocketCloseEvent, SocketCloseEventType, SocketDiagnostics, SocketDiagnosticsEventType } from '../common/ipc.net.js'; +import { Client, ISocket, Protocol, SocketCloseEvent, SocketCloseEventType, SocketDiagnostics, SocketDiagnosticsEventType } from '../common/ipc.net.js'; +import { encodeWebSocketFrame, WebSocketFrameParser, WebSocketOpcode } from '../common/webSocketFraming.js'; export function upgradeToISocket(req: http.IncomingMessage, socket: Socket, { debugLabel, @@ -266,7 +267,6 @@ export class NodeSocket implements ISocket { } const enum Constants { - MinHeaderByteSize = 2, /** * If we need to write a large buffer, we will split it into 256KB chunks and * send each chunk as a websocket message. This is to prevent that the sending @@ -277,20 +277,13 @@ const enum Constants { MaxWebSocketMessageLength = 256 * 1024 // 256 KB } -const enum ReadState { - PeekHeader = 1, - ReadHeader = 2, - ReadBody = 3, - Fin = 4 -} - interface ISocketTracer { traceSocketEvent(type: SocketDiagnosticsEventType, data?: VSBuffer | Uint8Array | ArrayBuffer | ArrayBufferView | unknown): void; } interface FrameOptions { compressed: boolean; - opcode: number; + opcode: WebSocketOpcode; } /** @@ -300,21 +293,12 @@ export class WebSocketNodeSocket extends Disposable implements ISocket, ISocketT public readonly socket: NodeSocket; private readonly _flowManager: WebSocketFlowManager; - private readonly _incomingData: ChunkStream; + private readonly _frameParser = new WebSocketFrameParser({ unmaskInPlace: true }); private readonly _onData = this._register(new Emitter()); private readonly _onClose = this._register(new Emitter()); private readonly _maxSocketMessageLength: number; private _isEnded = false; - - private readonly _state = { - state: ReadState.PeekHeader, - readLen: Constants.MinHeaderByteSize, - fin: 0, - compressed: false, - firstFrameOfMessage: true, - mask: 0, - opcode: 0 - }; + private _compressedMessage = false; public get permessageDeflate(): boolean { return this._flowManager.permessageDeflate; @@ -367,7 +351,6 @@ export class WebSocketNodeSocket extends Disposable implements ISocket, ISocketT error: err }); })); - this._incomingData = new ChunkStream(); this._register(this.socket.onData(data => this._acceptChunk(data))); this._register(this.socket.onClose(async (e) => { // Delay surfacing the close event until the async inflating is done @@ -418,7 +401,7 @@ export class WebSocketNodeSocket extends Disposable implements ISocket, ISocketT let start = 0; while (start < buffer.byteLength) { - this._flowManager.writeMessage(buffer.slice(start, Math.min(start + this._maxSocketMessageLength, buffer.byteLength)), { compressed: true, opcode: 0x02 /* Binary frame */ }); + this._flowManager.writeMessage(buffer.slice(start, Math.min(start + this._maxSocketMessageLength, buffer.byteLength)), { compressed: true, opcode: WebSocketOpcode.Binary }); start += this._maxSocketMessageLength; } } @@ -430,41 +413,7 @@ export class WebSocketNodeSocket extends Disposable implements ISocket, ISocketT } this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketWrite, buffer); - let headerLen = Constants.MinHeaderByteSize; - if (buffer.byteLength < 126) { - headerLen += 0; - } else if (buffer.byteLength < 2 ** 16) { - headerLen += 2; - } else { - headerLen += 8; - } - const header = VSBuffer.alloc(headerLen); - - // The RSV1 bit indicates a compressed frame - const compressedFlag = compressed ? 0b01000000 : 0; - const opcodeFlag = opcode & 0b00001111; - header.writeUInt8(0b10000000 | compressedFlag | opcodeFlag, 0); - if (buffer.byteLength < 126) { - header.writeUInt8(buffer.byteLength, 1); - } else if (buffer.byteLength < 2 ** 16) { - header.writeUInt8(126, 1); - let offset = 1; - header.writeUInt8((buffer.byteLength >>> 8) & 0b11111111, ++offset); - header.writeUInt8((buffer.byteLength >>> 0) & 0b11111111, ++offset); - } else { - header.writeUInt8(127, 1); - let offset = 1; - header.writeUInt8(0, ++offset); - header.writeUInt8(0, ++offset); - header.writeUInt8(0, ++offset); - header.writeUInt8(0, ++offset); - header.writeUInt8((buffer.byteLength >>> 24) & 0b11111111, ++offset); - header.writeUInt8((buffer.byteLength >>> 16) & 0b11111111, ++offset); - header.writeUInt8((buffer.byteLength >>> 8) & 0b11111111, ++offset); - header.writeUInt8((buffer.byteLength >>> 0) & 0b11111111, ++offset); - } - - this.socket.write(VSBuffer.concat([header, buffer])); + this.socket.write(encodeWebSocketFrame(buffer, { compressed, opcode })); } public end(): void { @@ -473,100 +422,25 @@ export class WebSocketNodeSocket extends Disposable implements ISocket, ISocketT } private _acceptChunk(data: VSBuffer): void { - if (data.byteLength === 0) { - return; - } - - this._incomingData.acceptChunk(data); - - while (this._incomingData.byteLength >= this._state.readLen) { - - if (this._state.state === ReadState.PeekHeader) { - // peek to see if we can read the entire header - const peekHeader = this._incomingData.peek(this._state.readLen); - const firstByte = peekHeader.readUInt8(0); - const finBit = (firstByte & 0b10000000) >>> 7; - const rsv1Bit = (firstByte & 0b01000000) >>> 6; - const opcode = (firstByte & 0b00001111); - - const secondByte = peekHeader.readUInt8(1); - const hasMask = (secondByte & 0b10000000) >>> 7; - const len = (secondByte & 0b01111111); - - this._state.state = ReadState.ReadHeader; - this._state.readLen = Constants.MinHeaderByteSize + (hasMask ? 4 : 0) + (len === 126 ? 2 : 0) + (len === 127 ? 8 : 0); - this._state.fin = finBit; - if (this._state.firstFrameOfMessage) { - // if the frame is compressed, the RSV1 bit is set only for the first frame of the message - this._state.compressed = Boolean(rsv1Bit); - } - this._state.firstFrameOfMessage = Boolean(finBit); - this._state.mask = 0; - this._state.opcode = opcode; - - this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketPeekedHeader, { headerSize: this._state.readLen, compressed: this._state.compressed, fin: this._state.fin, opcode: this._state.opcode }); - - } else if (this._state.state === ReadState.ReadHeader) { - // read entire header - const header = this._incomingData.read(this._state.readLen); - const secondByte = header.readUInt8(1); - const hasMask = (secondByte & 0b10000000) >>> 7; - let len = (secondByte & 0b01111111); - - let offset = 1; - if (len === 126) { - len = ( - header.readUInt8(++offset) * 2 ** 8 - + header.readUInt8(++offset) - ); - } else if (len === 127) { - len = ( - header.readUInt8(++offset) * 0 - + header.readUInt8(++offset) * 0 - + header.readUInt8(++offset) * 0 - + header.readUInt8(++offset) * 0 - + header.readUInt8(++offset) * 2 ** 24 - + header.readUInt8(++offset) * 2 ** 16 - + header.readUInt8(++offset) * 2 ** 8 - + header.readUInt8(++offset) - ); - } - - let mask = 0; - if (hasMask) { - mask = ( - header.readUInt8(++offset) * 2 ** 24 - + header.readUInt8(++offset) * 2 ** 16 - + header.readUInt8(++offset) * 2 ** 8 - + header.readUInt8(++offset) - ); - } - - this._state.state = ReadState.ReadBody; - this._state.readLen = len; - this._state.mask = mask; - - this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketPeekedHeader, { bodySize: this._state.readLen, compressed: this._state.compressed, fin: this._state.fin, mask: this._state.mask, opcode: this._state.opcode }); - - } else if (this._state.state === ReadState.ReadBody) { - // read body - - const body = this._incomingData.read(this._state.readLen); - this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketReadData, body); - - unmask(body, this._state.mask); - this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketUnmaskedData, body); + for (const frame of this._frameParser.acceptChunk(data)) { + const compressed = frame.opcode === WebSocketOpcode.Continuation ? this._compressedMessage : frame.compressed; + if (frame.opcode === WebSocketOpcode.Text || frame.opcode === WebSocketOpcode.Binary) { + this._compressedMessage = frame.compressed; + } - this._state.state = ReadState.PeekHeader; - this._state.readLen = Constants.MinHeaderByteSize; - this._state.mask = 0; + this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketPeekedHeader, { bodySize: frame.payload.byteLength, compressed, fin: Number(frame.final), mask: frame.mask, opcode: frame.opcode }); + this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketReadData, frame.payload); + if (frame.mask !== undefined) { + this.traceSocketEvent(SocketDiagnosticsEventType.WebSocketNodeSocketUnmaskedData, frame.payload); + } - if (this._state.opcode <= 0x02 /* Continuation frame or Text frame or binary frame */) { - this._flowManager.acceptFrame(body, this._state.compressed, !!this._state.fin); - } else if (this._state.opcode === 0x09 /* Ping frame */) { - // Ping frames could be send by some browsers e.g. Firefox - this._flowManager.writeMessage(body, { compressed: false, opcode: 0x0A /* Pong frame */ }); + if (frame.opcode === WebSocketOpcode.Continuation || frame.opcode === WebSocketOpcode.Text || frame.opcode === WebSocketOpcode.Binary) { + this._flowManager.acceptFrame(frame.payload, compressed, frame.final); + if (frame.final) { + this._compressedMessage = false; } + } else if (frame.opcode === WebSocketOpcode.Ping) { + this._flowManager.writeMessage(frame.payload, { compressed: false, opcode: WebSocketOpcode.Pong }); } } } @@ -858,31 +732,6 @@ class ZlibDeflateStream extends Disposable { } } -function unmask(buffer: VSBuffer, mask: number): void { - if (mask === 0) { - return; - } - const cnt = buffer.byteLength >>> 2; - for (let i = 0; i < cnt; i++) { - const v = buffer.readUInt32BE(i * 4); - buffer.writeUInt32BE(v ^ mask, i * 4); - } - const offset = cnt * 4; - const bytesLeft = buffer.byteLength - offset; - const m3 = (mask >>> 24) & 0b11111111; - const m2 = (mask >>> 16) & 0b11111111; - const m1 = (mask >>> 8) & 0b11111111; - if (bytesLeft >= 1) { - buffer.writeUInt8(buffer.readUInt8(offset) ^ m3, offset); - } - if (bytesLeft >= 2) { - buffer.writeUInt8(buffer.readUInt8(offset + 1) ^ m2, offset + 1); - } - if (bytesLeft >= 3) { - buffer.writeUInt8(buffer.readUInt8(offset + 2) ^ m1, offset + 2); - } -} - // Read this before there's any chance it is overwritten // Related to https://github.com/microsoft/vscode/issues/30624 export const XDG_RUNTIME_DIR = process.env['XDG_RUNTIME_DIR']; diff --git a/src/vs/base/parts/ipc/test/common/webSocketFraming.test.ts b/src/vs/base/parts/ipc/test/common/webSocketFraming.test.ts new file mode 100644 index 0000000000000..fc337ddcdfc62 --- /dev/null +++ b/src/vs/base/parts/ipc/test/common/webSocketFraming.test.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer } from '../../../../common/buffer.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../test/common/utils.js'; +import { encodeWebSocketFrame, WebSocketFrameParser, WebSocketOpcode } from '../../common/webSocketFraming.js'; + +suite('WebSocket framing', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('encodes and parses a masked client frame without mutating its payload', () => { + const payload = VSBuffer.fromString('Hello'); + const encoded = encodeWebSocketFrame(payload, { opcode: WebSocketOpcode.Text, mask: 0x01020304 }); + const frame = new WebSocketFrameParser().acceptChunk(encoded)[0]; + + assert.deepStrictEqual({ + encoded: Array.from(encoded.buffer), + payload: payload.toString(), + frame: { + final: frame.final, + compressed: frame.compressed, + opcode: frame.opcode, + payload: frame.payload.toString(), + mask: frame.mask, + }, + }, { + encoded: [0x81, 0x85, 0x01, 0x02, 0x03, 0x04, 0x49, 0x67, 0x6f, 0x68, 0x6e], + payload: 'Hello', + frame: { + final: true, + compressed: false, + opcode: WebSocketOpcode.Text, + payload: 'Hello', + mask: 0x01020304, + }, + }); + }); + + test('masks a three-byte payload using every remainder byte', () => { + const payload = VSBuffer.fromByteArray([0xaa, 0xbb, 0xcc]); + const encoded = encodeWebSocketFrame(payload, { opcode: WebSocketOpcode.Binary, mask: 0x12345678 }); + const frame = new WebSocketFrameParser().acceptChunk(encoded)[0]; + + assert.deepStrictEqual({ + encoded: Array.from(encoded.buffer), + payload: Array.from(frame.payload.buffer), + }, { + encoded: [0x82, 0x83, 0x12, 0x34, 0x56, 0x78, 0xb8, 0x8f, 0x9a], + payload: [0xaa, 0xbb, 0xcc], + }); + }); + + test('can unmask owned payload buffers in place', () => { + const encoded = encodeWebSocketFrame(VSBuffer.fromString('owned'), { opcode: WebSocketOpcode.Text, mask: 0x12345678 }); + const frame = new WebSocketFrameParser({ unmaskInPlace: true }).acceptChunk(encoded)[0]; + + assert.deepStrictEqual({ + payload: frame.payload.toString(), + wirePayloadAfterParsing: encoded.slice(6).toString(), + }, { + payload: 'owned', + wirePayloadAfterParsing: 'owned', + }); + }); + + test('preserves a present zero-valued mask', () => { + const encoded = encodeWebSocketFrame(VSBuffer.fromString('zero'), { opcode: WebSocketOpcode.Text, mask: 0 }); + const frame = new WebSocketFrameParser().acceptChunk(encoded)[0]; + + assert.deepStrictEqual({ + maskBit: encoded.readUInt8(1) & 0b10000000, + maskBytes: Array.from(encoded.slice(2, 6).buffer), + payload: frame.payload.toString(), + mask: frame.mask, + }, { + maskBit: 0b10000000, + maskBytes: [0, 0, 0, 0], + payload: 'zero', + mask: 0, + }); + }); + + test('accepts frames across chunk boundaries and in coalesced chunks', () => { + const first = encodeWebSocketFrame(VSBuffer.fromString('first'), { opcode: WebSocketOpcode.Text }); + const second = encodeWebSocketFrame(VSBuffer.fromString('second'), { opcode: WebSocketOpcode.Text }); + const parser = new WebSocketFrameParser(); + + const firstPart = parser.acceptChunk(first.slice(0, 3)); + const remaining = parser.acceptChunk(VSBuffer.concat([first.slice(3), second])); + + assert.deepStrictEqual({ + firstPart: firstPart.length, + remaining: remaining.map(frame => frame.payload.toString()), + }, { + firstPart: 0, + remaining: ['first', 'second'], + }); + }); + + for (const length of [125, 126, 65_535, 65_536]) { + test(`encodes and parses payload length ${length}`, () => { + const payload = VSBuffer.alloc(length); + for (let index = 0; index < payload.byteLength; index++) { + payload.writeUInt8(index, index); + } + + const encoded = encodeWebSocketFrame(payload, { opcode: WebSocketOpcode.Binary }); + const frame = new WebSocketFrameParser().acceptChunk(encoded)[0]; + + assert.deepStrictEqual({ + header: Array.from(encoded.slice(0, encoded.byteLength - payload.byteLength).buffer), + length: frame.payload.byteLength, + first: frame.payload.readUInt8(0), + last: frame.payload.readUInt8(frame.payload.byteLength - 1), + }, { + header: length < 126 + ? [0x82, length] + : length < 2 ** 16 + ? [0x82, 126, (length >>> 8) & 0xff, length & 0xff] + : [0x82, 127, 0, 0, 0, 0, (length >>> 24) & 0xff, (length >>> 16) & 0xff, (length >>> 8) & 0xff, length & 0xff], + length, + first: 0, + last: (length - 1) & 0xff, + }); + }); + } + + test('rejects invalid control frames, reserved opcodes, and unsupported lengths', () => { + assert.throws(() => encodeWebSocketFrame(VSBuffer.alloc(0), { opcode: WebSocketOpcode.Ping, final: false })); + assert.throws(() => new WebSocketFrameParser().acceptChunk(VSBuffer.fromByteArray([0x83, 0x00]))); + assert.throws(() => new WebSocketFrameParser().acceptChunk(VSBuffer.fromByteArray([0x89, 0x7e, 0x00, 0x7e, ...new Array(126).fill(0)]))); + assert.throws(() => new WebSocketFrameParser().acceptChunk(VSBuffer.fromByteArray([0x82, 0x7f, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]))); + }); + + test('rejects frames over the configured payload limit after reading the header', () => { + const encoded = encodeWebSocketFrame(VSBuffer.fromString('too large'), { opcode: WebSocketOpcode.Text }); + assert.throws( + () => new WebSocketFrameParser({ maxPayloadLength: 4 }).acceptChunk(encoded.slice(0, 2)), + /configured limit of 4/, + ); + }); +}); diff --git a/src/vs/base/test/browser/ui/tree/objectTree.test.ts b/src/vs/base/test/browser/ui/tree/objectTree.test.ts index 1dcbf78f71286..619a2469fdc2e 100644 --- a/src/vs/base/test/browser/ui/tree/objectTree.test.ts +++ b/src/vs/base/test/browser/ui/tree/objectTree.test.ts @@ -140,6 +140,45 @@ suite('ObjectTree', function () { assert.strictEqual(navigator.last(), 2); }); + test('reports the flattened visible render count', () => { + tree.setChildren(null, [ + { + element: 0, + collapsible: true, + collapsed: false, + children: [ + { element: 10 }, + { element: 11 }, + ] + }, + { + element: 1, + collapsible: true, + collapsed: true, + children: [ + { element: 20 }, + ] + }, + { element: 2 } + ]); + + const expandedRoot = tree.getListRenderCount(null); + const expandedSubtree = tree.getListRenderCount(0); + tree.collapse(0); + + assert.deepStrictEqual({ + expandedRoot, + expandedSubtree, + collapsedRoot: tree.getListRenderCount(null), + collapsedSubtree: tree.getListRenderCount(0), + }, { + expandedRoot: 5, + expandedSubtree: 3, + collapsedRoot: 3, + collapsedSubtree: 1, + }); + }); + test('should skip filtered elements', () => { filter = el => el % 2 === 0; diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 6be05deeb2fa4..3ac09865ca47b 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -114,6 +114,11 @@ export interface IActionListItem { readonly keybinding?: ResolvedKeybinding; canPreview?: boolean | undefined; readonly hideIcon?: boolean; + /** + * CSS classes rendered in the item's icon slot, for icons that are not + * codicons (e.g. themed file icons). Takes precedence over `group.icon`. + */ + readonly iconClasses?: readonly string[]; readonly tooltip?: string; /** * Optional toolbar actions shown when the item is focused or hovered. @@ -292,7 +297,10 @@ class ActionItemRenderer implements IListRenderer, IAction // Clear previous element disposables data.elementDisposables.clear(); - if (element.group?.icon) { + if (element.iconClasses?.length) { + data.icon.className = ['icon', ...element.iconClasses].join(' '); + data.icon.style.color = ''; + } else if (element.group?.icon) { data.icon.className = ThemeIcon.asClassName(element.group.icon); if (element.group.icon.color) { data.icon.style.color = asCssVariable(element.group.icon.color.id); diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 2e9bcb6a4aabe..acfa3007018d0 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -19,7 +19,7 @@ import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; import { AgentSession, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; -import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; +import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; import { CollectAgentHostDebugLogsExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, type IAgentHostExtensionCommandMap } from '../common/agentHostExtensionProtocol.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js'; import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; @@ -1153,9 +1153,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC if (resource.scheme !== Schemas.file) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Agent Host returned a non-file debug log resource: ${resource.toString()}`); } - const maxUncompressedSize = kind === 'archive' ? AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES : AGENT_HOST_DEBUG_LOGS_MAX_BYTES; if (!Number.isSafeInteger(result.size) || result.size < 0 || result.size > AGENT_HOST_DEBUG_LOGS_MAX_BYTES - || !Number.isSafeInteger(result.uncompressedSize) || result.uncompressedSize < 0 || result.uncompressedSize > maxUncompressedSize) { + || !Number.isSafeInteger(result.uncompressedSize) || result.uncompressedSize < 0 || result.uncompressedSize > AGENT_HOST_DEBUG_LOGS_MAX_BYTES) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Agent Host returned invalid debug log artifact sizes'); } if (!Array.isArray(result.entries) || result.entries.length > AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES) { @@ -1166,7 +1165,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC for (const entry of result.entries) { const segments = entry.path.split('/'); if (!entry.path || entry.path.includes('\\') || segments.some((segment: string) => !segment || segment === '.' || segment === '..') - || !Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES + || !Number.isSafeInteger(entry.size) || entry.size < 0 || entryPaths.has(entry.path)) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Agent Host returned an invalid debug log artifact manifest entry'); } diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index b58c9ce3e05da..e1df75758448e 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1030,6 +1030,8 @@ export interface IAgentChatAdoptionResult { readonly adopted: boolean; /** Whether the chat was a genuine legacy adoption candidate. */ readonly eligible: boolean; + /** Whether the chat already has Agent Host metadata, i.e. it is ours regardless of adoption. */ + readonly native?: boolean; } /** diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index c15e289671f42..10ec2d438d549 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -500,6 +500,9 @@ export const AgentHostActiveAgentTitleGenerationConfigKey = 'activeAgentTitleGen /** Root config key controlling rich-link guidance for Markdown plan documents. */ export const AgentHostMarkdownPlanRichLinksEnabledConfigKey = 'markdownPlanRichLinksEnabled'; +/** Root config key forwarded from the renderer for the artifact tools and their instruction. */ +export const AgentHostArtifactToolsConfigKey = 'artifactTools'; + // Root config key forwarded from the renderer when the `chat.agentSessions.migrateLegacyCopilotCli` // setting changes. When `true`, `listSessions` surfaces un-adopted extension-host Copilot CLI // sessions as adoptable agent-host sessions, and opening one adopts it in place. Experimental; off. @@ -795,8 +798,14 @@ export const platformRootSchema = createSchema({ }), [AgentHostMarkdownPlanRichLinksEnabledConfigKey]: schemaProperty({ type: 'boolean', - title: localize('agentHost.config.markdownPlanRichLinksEnabled.title', "Markdown Plan Rich Links"), - description: localize('agentHost.config.markdownPlanRichLinksEnabled.description', "Whether agents receive guidance for using rich links and running task markers in Markdown plan documents."), + title: localize('agentHost.config.markdownPlanRichLinks.title', "Markdown Plan Rich Links"), + description: localize('agentHost.config.markdownPlanRichLinks.description', "Whether agents receive guidance for using rich links and running task markers in Markdown plan documents."), + default: false, + }), + [AgentHostArtifactToolsConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.artifactTools.title', "Artifact Tools"), + description: localize('agentHost.config.artifactTools.description', "Whether agents can record artifacts — pull requests, issues, commits, websites, files and other resources — with the artifact tools."), default: false, }), [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: schemaProperty({ diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index e70af159e3627..8cf3c9c134d77 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -33,10 +33,12 @@ import { AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, AgentHostSystemProxyEnabledSettingId, + ArtifactToolsSettingId, } from './agentService.js'; import { AgentHostClaudeMultiRootEnabledConfigKey, AgentHostActiveAgentTitleGenerationConfigKey, + AgentHostArtifactToolsConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCodexEnabledConfigKey, @@ -181,6 +183,15 @@ configurationRegistry.registerConfiguration({ experiment: { mode: 'auto' }, agentHost: { key: AgentHostActiveAgentTitleGenerationConfigKey }, }, + [ArtifactToolsSettingId]: { + type: 'boolean', + description: nls.localize('chat.artifactTools.enabled', "When enabled, agents can record artifacts — pull requests, issues, commits, websites, files and other resources — which are surfaced above the chat input."), + default: product.quality !== 'stable', + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + experiment: { mode: 'auto' }, + agentHost: { key: AgentHostArtifactToolsConfigKey }, + }, [AgentHostMarkdownPlanRichLinksEnabledSettingId]: { type: 'boolean', description: nls.localize('chat.agentHost.experimental.markdownPlanRichLinks', "When enabled, agents receive guidance for using rich links to issues, pull requests, commits, sessions, and chats, plus running task markers, when creating or editing Markdown plan documents."), diff --git a/src/vs/platform/agentHost/common/agentHostUri.ts b/src/vs/platform/agentHost/common/agentHostUri.ts index b4683004d63c0..225fb7e257432 100644 --- a/src/vs/platform/agentHost/common/agentHostUri.ts +++ b/src/vs/platform/agentHost/common/agentHostUri.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +import { decodeBase64, encodeBase64, encodeHex, VSBuffer } from '../../../base/common/buffer.js'; import { Schemas } from '../../../base/common/network.js'; import { OperatingSystem } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; @@ -129,32 +129,28 @@ export function normalizeRemoteAgentHostAddress(address: string): string { } const REMOTE_LOCAL_AGENT_HOST_AUTHORITY = 'remote_local'; +const HEX_AGENT_HOST_AUTHORITY_PREFIX = 'hex-'; /** * Encode a remote address into an identifier that is safe for use in - * both URI schemes and URI authorities, and is collision-free. + * both URI schemes and case-insensitive URI authorities without collisions. * - * Four tiers: - * 1. The reserved ambient authority `local` is escaped for remote hosts. - * 2. Purely alphanumeric addresses are returned as-is. - * 3. "Normal" addresses containing only `[a-zA-Z0-9.:-]` get colons - * replaced with `__` (double underscore) for human readability. - * Addresses containing `_` skip this tier to keep the encoding - * collision-free (`__` can only appear from colon replacement). - * 4. Everything else is url-safe base64-encoded with a `b64-` prefix. + * The reserved `local` name becomes `remote_local`; lowercase alphanumeric + * addresses pass through; lowercase host-like addresses replace `:` with `__`; + * all other values use lowercase hex with a reserved `hex-` prefix. */ export function agentHostAuthority(address: string): string { const normalized = normalizeRemoteAgentHostAddress(address); if (normalized === 'local') { return REMOTE_LOCAL_AGENT_HOST_AUTHORITY; } - if (/^[a-zA-Z0-9]+$/.test(normalized)) { + if (/^[a-z0-9]+$/.test(normalized)) { return normalized; } - if (/^[a-zA-Z0-9.:\-]+$/.test(normalized)) { + if (/^[a-z0-9.:\-]+$/.test(normalized) && !/^hex-/i.test(normalized)) { return normalized.replaceAll(':', '__'); } - return `b64-${encodeBase64(VSBuffer.fromString(normalized), false, true)}`; + return `${HEX_AGENT_HOST_AUTHORITY_PREFIX}${encodeHex(VSBuffer.fromString(normalized))}`; } /** diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 12bcd1db4bccb..3e028dea418d0 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -71,7 +71,7 @@ export const enum AgentHostIpcChannels { export const AgentHostAhpJsonlLoggingSettingId = 'chat.agentHost.ahpJsonlLoggingEnabled'; export type AgentHostDebugLogsArtifactKind = 'archive' | 'directory'; -export const AGENT_HOST_DEBUG_LOGS_MAX_BYTES = 16 * 1024 * 1024; +export const AGENT_HOST_DEBUG_LOGS_MAX_BYTES = 256 * 1024 * 1024; /** Maximum number of files in one Agent Host debug-log artifact. */ export const AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES = 1000; /** @@ -80,19 +80,6 @@ export const AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES = 1000; * never has to encode a whole archive into one JSON-RPC message. */ export const AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES = 1024 * 1024; -/** - * Upper bound on the *uncompressed* logs staged for an archive artifact. Log - * text compresses heavily, so this is deliberately far larger than - * {@link AGENT_HOST_DEBUG_LOGS_MAX_BYTES} — which still bounds the archive that - * is actually transferred. It only exists to keep zipping work finite. - */ -export const AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES = 256 * 1024 * 1024; -/** - * Upper bound on any single file inside an artifact. Oversized files are - * reduced to their trailing bytes rather than dropped, so a very large process - * log still contributes the portion that explains a recent failure. - */ -export const AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES = 10 * 1024 * 1024; export interface IAgentHostDebugLogsArtifactEntry { readonly path: string; @@ -129,6 +116,9 @@ export const AgentHostActiveAgentTitleGenerationSettingId = 'chat.agentHost.expe /** Configuration key enabling rich-link guidance for Markdown plan documents. */ export const AgentHostMarkdownPlanRichLinksEnabledSettingId = 'chat.agentHost.experimental.markdownPlanRichLinks'; +/** Configuration key gating the artifact tools and their agent instruction. */ +export const ArtifactToolsSettingId = 'chat.artifactTools.enabled'; + /** * Configuration key gating multiple-working-directory support for the Copilot * agent-host provider. When `true`, the Copilot provider advertises the diff --git a/src/vs/platform/agentHost/common/sandboxConfigSchema.ts b/src/vs/platform/agentHost/common/sandboxConfigSchema.ts index dab1096d4a7e5..ec82b5f903f92 100644 --- a/src/vs/platform/agentHost/common/sandboxConfigSchema.ts +++ b/src/vs/platform/agentHost/common/sandboxConfigSchema.ts @@ -7,7 +7,6 @@ import { localize } from '../../../nls.js'; import { AgentNetworkDomainSettingId } from '../../networkFilter/common/settings.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../sandbox/common/settings.js'; import { createSchema, schemaProperty } from './agentHostSchema.js'; -import type { RootConfigState } from './state/protocol/state.js'; /** * Top-level keys the agent host's root config bag exposes for sandboxing. @@ -19,18 +18,6 @@ export const enum AgentHostSandboxConfigKey { Sandbox = 'sandbox', } -/** - * Transient root-config value published when Copilot's server-managed settings - * explicitly control sandbox enablement. An absent value means the local - * Agent Host sandbox preference remains authoritative. - */ -export const AgentHostCopilotManagedSandboxEnabledConfigKey = 'copilotManagedSandbox.enabled'; - -export function getAgentHostCopilotManagedSandboxEnabled(config: RootConfigState | undefined): boolean | undefined { - const value = config?.values[AgentHostCopilotManagedSandboxEnabledConfigKey]; - return typeof value === 'boolean' ? value : undefined; -} - /** * Well-known sub-keys inside the agent host's `sandbox` object. These are * intentionally a flat, prefix-free namespace owned by the agent host — diff --git a/src/vs/platform/agentHost/common/serverToolNames.ts b/src/vs/platform/agentHost/common/serverToolNames.ts index 18854e6fe2a35..beea8e6ececd7 100644 --- a/src/vs/platform/agentHost/common/serverToolNames.ts +++ b/src/vs/platform/agentHost/common/serverToolNames.ts @@ -26,3 +26,10 @@ export const enum SessionServerToolName { GetSessionContext = 'get_session_context', DeleteSession = 'delete_session', } + +/** Names of the artifact server tools, shared between `common/` and `node/`. */ +export const enum ArtifactServerToolName { + AddArtifact = 'add_artifact', + RemoveArtifact = 'remove_artifact', + ListArtifacts = 'list_artifacts', +} diff --git a/src/vs/platform/agentHost/common/sessionArtifactCollection.ts b/src/vs/platform/agentHost/common/sessionArtifactCollection.ts new file mode 100644 index 0000000000000..7cdef0d402e59 --- /dev/null +++ b/src/vs/platform/agentHost/common/sessionArtifactCollection.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getSessionArtifactValue, isGitHubArtifactLink, SESSION_ARTIFACT_TYPES, SessionArtifactType, type ISessionArtifact } from './sessionArtifacts.js'; + +/** The fields an agent supplies when adding an artifact. */ +export interface ISessionArtifactInput { + readonly type: SessionArtifactType; + readonly label: string; + readonly link?: string; + readonly uri?: string; + readonly commitHash?: string; + readonly createdByThisSession?: boolean; +} + +export interface IAddSessionArtifactResult { + readonly artifacts: readonly ISessionArtifact[]; + readonly artifact: ISessionArtifact; + /** `false` when an artifact with the same value already existed. */ + readonly added: boolean; +} + +export interface IRemoveSessionArtifactResult { + readonly artifacts: readonly ISessionArtifact[]; + readonly removed: ISessionArtifact | undefined; +} + +const linkTypes: ReadonlySet = new Set([SessionArtifactType.PullRequest, SessionArtifactType.Issue, SessionArtifactType.Website, SessionArtifactType.Commit]); +const uriTypes: ReadonlySet = new Set([SessionArtifactType.File, SessionArtifactType.Resource]); +const gitHubTypes: ReadonlySet = new Set([SessionArtifactType.PullRequest, SessionArtifactType.Issue]); + +function requireString(value: unknown, field: string, toolName: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Invalid ${toolName} input: ${field} must be a non-empty string.`); + } + return value.trim(); +} + +/** + * A link is opened externally on click, which hands it to the OS protocol + * handler. Only web links may do that: a `file:` or custom-scheme link would + * otherwise let an agent-labelled pill launch a local target. + */ +function requireWebLink(value: unknown, field: string, toolName: string): string { + const link = requireString(value, field, toolName); + let scheme: string; + try { + scheme = new URL(link).protocol; + } catch { + throw new Error(`Invalid ${toolName} input: ${field} must be an absolute http(s) URL.`); + } + if (scheme !== 'http:' && scheme !== 'https:') { + throw new Error(`Invalid ${toolName} input: ${field} must be an http(s) URL, but was '${scheme}'.`); + } + return link; +} + +/** Validates and normalizes raw `add_artifact` arguments. */ +export function parseSessionArtifactInput(rawArgs: unknown, toolName: string): ISessionArtifactInput { + if (!rawArgs || typeof rawArgs !== 'object' || Array.isArray(rawArgs)) { + throw new Error(`Invalid ${toolName} input: expected an object.`); + } + const args = rawArgs as Record; + const type = args['type']; + if (typeof type !== 'string' || !(SESSION_ARTIFACT_TYPES as readonly string[]).includes(type)) { + throw new Error(`Invalid ${toolName} input: type must be one of ${SESSION_ARTIFACT_TYPES.join(', ')}.`); + } + + const artifactType = type as SessionArtifactType; + const input: { type: SessionArtifactType; label: string; link?: string; uri?: string; commitHash?: string; createdByThisSession?: boolean } = { + type: artifactType, + label: requireString(args['label'], 'label', toolName), + }; + + if (linkTypes.has(artifactType)) { + input.link = requireWebLink(args['link'], 'link', toolName); + } + if (uriTypes.has(artifactType)) { + input.uri = requireString(args['uri'], 'uri', toolName); + } + if (artifactType === SessionArtifactType.Commit) { + input.commitHash = requireString(args['commitHash'], 'commitHash', toolName); + } + if (artifactType === SessionArtifactType.PullRequest) { + if (typeof args['createdByThisSession'] !== 'boolean') { + throw new Error(`Invalid ${toolName} input: createdByThisSession must be a boolean for pull request artifacts.`); + } + input.createdByThisSession = args['createdByThisSession']; + } + return input; +} + +/** + * The artifacts recorded on a session. Immutable: mutations return the next + * list so callers stay in control of persisting and publishing it. + */ +export class SessionArtifactCollection { + + constructor(private readonly _artifacts: readonly ISessionArtifact[] = []) { } + + get artifacts(): readonly ISessionArtifact[] { + return this._artifacts; + } + + /** + * Adds an artifact unless one with the same value already exists, in which + * case the existing artifact is returned unchanged. + */ + add(input: ISessionArtifactInput, createId: () => string): IAddSessionArtifactResult { + const artifact = this._create(input, createId); + const value = getSessionArtifactValue(artifact); + const existing = this._artifacts.find(candidate => getSessionArtifactValue(candidate) === value); + if (existing) { + return { artifacts: this._artifacts, artifact: existing, added: false }; + } + return { artifacts: [...this._artifacts, artifact], artifact, added: true }; + } + + remove(id: string): IRemoveSessionArtifactResult { + const removed = this._artifacts.find(artifact => artifact.id === id); + return { + artifacts: removed ? this._artifacts.filter(artifact => artifact !== removed) : this._artifacts, + removed, + }; + } + + private _create(input: ISessionArtifactInput, createId: () => string): ISessionArtifact { + const artifact: { + id: string; + type: SessionArtifactType; + label: string; + link?: string; + uri?: string; + commitHash?: string; + isGitHub?: boolean; + createdByThisSession?: boolean; + } = { id: createId(), type: input.type, label: input.label }; + + if (input.link !== undefined) { artifact.link = input.link; } + if (input.uri !== undefined) { artifact.uri = input.uri; } + if (input.commitHash !== undefined) { artifact.commitHash = input.commitHash; } + if (input.link !== undefined && gitHubTypes.has(input.type)) { artifact.isGitHub = isGitHubArtifactLink(input.link); } + if (input.createdByThisSession !== undefined) { artifact.createdByThisSession = input.createdByThisSession; } + return artifact; + } +} diff --git a/src/vs/platform/agentHost/common/sessionArtifacts.ts b/src/vs/platform/agentHost/common/sessionArtifacts.ts new file mode 100644 index 0000000000000..41e0d7ca59902 --- /dev/null +++ b/src/vs/platform/agentHost/common/sessionArtifacts.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { SessionSummaryMeta } from './state/sessionState.js'; + +/** + * Artifact kinds an agent can record on its session. Each kind carries the one + * field the client needs to open it, plus a label. + */ +export const enum SessionArtifactType { + PullRequest = 'pullRequest', + Issue = 'issue', + Commit = 'commit', + Website = 'website', + File = 'file', + Resource = 'resource', +} + +export const SESSION_ARTIFACT_TYPES: readonly SessionArtifactType[] = [ + SessionArtifactType.PullRequest, + SessionArtifactType.Issue, + SessionArtifactType.Commit, + SessionArtifactType.Website, + SessionArtifactType.File, + SessionArtifactType.Resource, +]; + +/** A session artifact as stored by the host and published to clients. */ +export interface ISessionArtifact { + readonly id: string; + readonly type: SessionArtifactType; + readonly label: string; + /** Link for pull request, issue, commit and website artifacts. */ + readonly link?: string; + /** Resource URI for file and resource artifacts. */ + readonly uri?: string; + /** Commit hash for commit artifacts. */ + readonly commitHash?: string; + /** Whether a pull request or issue link points at GitHub. Host-computed. */ + readonly isGitHub?: boolean; + /** Whether this session created the pull request, rather than only referencing it. */ + readonly createdByThisSession?: boolean; +} + +/** + * Reserved key under {@link SessionSummaryMeta} holding the session's agent-set + * artifacts. VS Code convention layered on the protocol's generic `_meta` bag. + */ +export const SESSION_META_ARTIFACTS_KEY = 'agentHost/sessionArtifacts'; + +function isSessionArtifactType(value: unknown): value is SessionArtifactType { + return typeof value === 'string' && (SESSION_ARTIFACT_TYPES as readonly string[]).includes(value); +} + +function parseSessionArtifact(value: unknown): ISessionArtifact | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const raw = value as Record; + if (typeof raw['id'] !== 'string' || typeof raw['label'] !== 'string' || !isSessionArtifactType(raw['type'])) { + return undefined; + } + const artifact: { + id: string; + type: SessionArtifactType; + label: string; + link?: string; + uri?: string; + commitHash?: string; + isGitHub?: boolean; + createdByThisSession?: boolean; + } = { id: raw['id'], type: raw['type'], label: raw['label'] }; + + if (typeof raw['link'] === 'string') { artifact.link = raw['link']; } + if (typeof raw['uri'] === 'string') { artifact.uri = raw['uri']; } + if (typeof raw['commitHash'] === 'string') { artifact.commitHash = raw['commitHash']; } + if (typeof raw['isGitHub'] === 'boolean') { artifact.isGitHub = raw['isGitHub']; } + if (typeof raw['createdByThisSession'] === 'boolean') { artifact.createdByThisSession = raw['createdByThisSession']; } + return artifact; +} + +/** Reads the artifacts recorded on a session's `_meta` bag. */ +export function readSessionArtifacts(meta: SessionSummaryMeta | undefined): readonly ISessionArtifact[] { + const value = meta?.[SESSION_META_ARTIFACTS_KEY]; + if (!Array.isArray(value)) { + return []; + } + const artifacts: ISessionArtifact[] = []; + for (const entry of value) { + const artifact = parseSessionArtifact(entry); + if (artifact) { + artifacts.push(artifact); + } + } + return artifacts; +} + +/** Returns `meta` with the artifact slot replaced, dropping it when empty. */ +export function withSessionArtifacts(meta: SessionSummaryMeta | undefined, artifacts: readonly ISessionArtifact[]): SessionSummaryMeta | undefined { + const next: { [key: string]: unknown } = { ...meta }; + if (artifacts.length > 0) { + next[SESSION_META_ARTIFACTS_KEY] = artifacts; + } else { + delete next[SESSION_META_ARTIFACTS_KEY]; + } + return Object.keys(next).length > 0 ? next : undefined; +} + +/** Serializes artifacts for the session database. */ +export function stringifySessionArtifacts(artifacts: readonly ISessionArtifact[]): string { + return JSON.stringify(artifacts); +} + +/** Parses artifacts previously written by {@link stringifySessionArtifacts}. */ +export function parseSessionArtifacts(value: string | undefined): readonly ISessionArtifact[] { + if (!value) { + return []; + } + try { + return readSessionArtifacts({ [SESSION_META_ARTIFACTS_KEY]: JSON.parse(value) }); + } catch { + return []; + } +} + +/** + * The value that identifies an artifact for de-duplication: its link, resource + * URI or commit hash, normalized for comparison. + */ +export function getSessionArtifactValue(artifact: ISessionArtifact): string { + const value = artifact.link ?? artifact.uri ?? artifact.commitHash ?? ''; + return value.trim().toLowerCase(); +} + +/** Whether a pull request or issue link points at github.com or a GitHub Enterprise host. */ +export function isGitHubArtifactLink(link: string): boolean { + try { + const { hostname } = new URL(link); + return hostname === 'github.com' || hostname === 'www.github.com' || hostname.endsWith('.github.com') || hostname.startsWith('github.'); + } catch { + return false; + } +} diff --git a/src/vs/platform/agentHost/common/tunnelMessageSocket.ts b/src/vs/platform/agentHost/common/tunnelMessageSocket.ts index c733b75e3ad1c..239970961516d 100644 --- a/src/vs/platform/agentHost/common/tunnelMessageSocket.ts +++ b/src/vs/platform/agentHost/common/tunnelMessageSocket.ts @@ -27,61 +27,15 @@ export interface ITunnelSocketCloseEvent { /** The subset of a tunnel relay duplex stream used to perform an HTTP upgrade. */ export interface ITunnelDuplexStream { - readonly remoteAddress?: string; on(event: 'data', listener: (chunk: Uint8Array) => void): void; on(event: 'error', listener: (err: Error) => void): void; on(event: 'close', listener: (hadError?: boolean) => void): void; - on(event: 'end' | 'drain' | 'pause' | 'resume', listener: () => void): void; + on(event: 'end', listener: () => void): void; removeListener(event: 'data', listener: (chunk: Uint8Array) => void): void; removeListener(event: 'error', listener: (err: Error) => void): void; removeListener(event: 'close', listener: (hadError?: boolean) => void): void; - removeListener(event: 'end' | 'drain' | 'pause' | 'resume', listener: () => void): void; - removeAllListeners(event: 'error'): void; + removeListener(event: 'end', listener: () => void): void; write(chunk: Uint8Array | string): boolean; end(): void; destroy(): void; - pause(): void; - resume(): void; -} - -/** A socket-shaped view that supplies TCP methods expected by the framing implementation. */ -export interface IWebSocketDuplexStream extends ITunnelDuplexStream { - write(chunk: Uint8Array | string, callback?: (error?: Error) => void): boolean; - setNoDelay(enable: boolean): void; - setTimeout(timeout: number): void; - setKeepAlive(enable: boolean, initialDelay?: number): void; -} - -/** Configuration consumed by the bundled `WebSocketConnection` framing implementation. */ -export interface IWebSocketConnectionConfig { - readonly maxReceivedFrameSize: number; - readonly maxReceivedMessageSize: number; - readonly fragmentOutgoingMessages: boolean; - readonly fragmentationThreshold: number; - readonly webSocketVersion: 13; - readonly assembleFragments: boolean; - readonly disableNagleAlgorithm: boolean; - readonly closeTimeout: number; -} - -/** A message emitted by the bundled `WebSocketConnection` framing implementation. */ -export type WebSocketConnectionMessage = { readonly type: 'utf8'; readonly utf8Data: string } | { readonly type: 'binary'; readonly binaryData: Uint8Array }; - -/** The event-emitter surface used by the WebSocket-over-duplex adapter. */ -export interface IWebSocketConnection { - _addSocketEventListeners(): void; - handleSocketData(data: Uint8Array): void; - on(event: 'message', listener: (message: WebSocketConnectionMessage) => void): void; - on(event: 'close', listener: (code: number, reason: string) => void): void; - on(event: 'error', listener: (error: Error) => void): void; - removeListener(event: 'message', listener: (message: WebSocketConnectionMessage) => void): void; - removeListener(event: 'close', listener: (code: number, reason: string) => void): void; - removeListener(event: 'error', listener: (error: Error) => void): void; - send(data: string): void; - close(): void; -} - -/** Constructs the bundled `WebSocketConnection` framing implementation. */ -export interface WebSocketConnectionCtor { - new(stream: IWebSocketDuplexStream, extensions: [], protocol: string | null, maskOutgoingPackets: boolean, config: IWebSocketConnectionConfig): IWebSocketConnection; } diff --git a/src/vs/platform/agentHost/common/webSocketOverDuplex.ts b/src/vs/platform/agentHost/common/webSocketOverDuplex.ts index 2c662f4378582..ad9325c24c507 100644 --- a/src/vs/platform/agentHost/common/webSocketOverDuplex.ts +++ b/src/vs/platform/agentHost/common/webSocketOverDuplex.ts @@ -4,31 +4,29 @@ *--------------------------------------------------------------------------------------------*/ import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +import { TimeoutTimer } from '../../../base/common/async.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; -import type { ITunnelDuplexStream, ITunnelMessageSocket, ITunnelSocketCloseEvent, IWebSocketConnection, IWebSocketConnectionConfig, IWebSocketDuplexStream, WebSocketConnectionCtor, WebSocketConnectionMessage } from './tunnelMessageSocket.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { encodeWebSocketFrame, type IWebSocketFrame, WebSocketFrameParser, WebSocketFrameTooLargeError, WebSocketOpcode } from '../../../base/parts/ipc/common/webSocketFraming.js'; +import type { ITunnelDuplexStream, ITunnelMessageSocket, ITunnelSocketCloseEvent } from './tunnelMessageSocket.js'; const websocketAcceptGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; const headerTerminator = VSBuffer.fromString('\r\n\r\n').buffer; -const websocketConnectionConfig: IWebSocketConnectionConfig = { - maxReceivedFrameSize: 0x100000, - maxReceivedMessageSize: 0x800000, - fragmentOutgoingMessages: true, - fragmentationThreshold: 0x4000, - webSocketVersion: 13, - assembleFragments: true, - disableNagleAlgorithm: true, - closeTimeout: 5000, -}; - +const defaultMaxFramePayloadLength = 0x100000; +const defaultMaxMessagePayloadLength = 0x800000; +const defaultCloseTimeoutMs = 5000; /** Options used to establish a WebSocket connection over an existing tunnel stream. */ export interface IWebSocketOverDuplexOptions { /** Request path, e.g. '/agent-host/select' or '/?tkn=abc'. */ readonly path: string; /** Host header value; the tunnel stream is already pointed at the right port. */ readonly host?: string; - /** Injected WebSocketConnection constructor from the lazily-loaded browser bundle. */ - readonly webSocketConnectionCtor: WebSocketConnectionCtor; + /** Maximum accepted frame payload length. */ + readonly maxFramePayloadLength?: number; + /** Maximum accepted assembled message payload length. */ + readonly maxMessagePayloadLength?: number; + /** Time to wait for the peer to complete a close handshake. */ + readonly closeTimeoutMs?: number; } /** Opens a framed WebSocket connection over an already-connected tunnel stream. */ @@ -62,11 +60,14 @@ export async function connectWebSocketOverDuplex( } responseReader.detach(); - const connection = new options.webSocketConnectionCtor(new WebSocketDuplexStreamAdapter(stream), [], null, true, websocketConnectionConfig); - const socket = new TunnelMessageSocket(stream, connection); - connection._addSocketEventListeners(); + const socket = new TunnelMessageSocket( + stream, + options.maxFramePayloadLength ?? defaultMaxFramePayloadLength, + options.maxMessagePayloadLength ?? defaultMaxMessagePayloadLength, + options.closeTimeoutMs ?? defaultCloseTimeoutMs, + ); for (const chunk of responseReader.remainingChunks(headerEnd)) { - connection.handleSocketData(chunk); + socket.acceptChunk(chunk); } return socket; } catch (error) { @@ -101,100 +102,6 @@ export async function createWebSocketAccept(key: string): Promise { return encodeBase64(VSBuffer.wrap(new Uint8Array(digest))); } -/** Adapts a tunnel duplex stream to the TCP-like socket surface required by `WebSocketConnection`. */ -class WebSocketDuplexStreamAdapter implements IWebSocketDuplexStream { - private _ended = false; - private _destroyed = false; - - constructor(private readonly _stream: ITunnelDuplexStream) { - } - - get remoteAddress(): string | undefined { - return this._stream.remoteAddress; - } - - on(event: 'data', listener: (chunk: Uint8Array) => void): void; - on(event: 'error', listener: (err: Error) => void): void; - on(event: 'close', listener: (hadError?: boolean) => void): void; - on(event: 'end' | 'drain' | 'pause' | 'resume', listener: () => void): void; - on(event: 'data' | 'error' | 'end' | 'close' | 'drain' | 'pause' | 'resume', listener: ((chunk: Uint8Array) => void) | ((err: Error) => void) | ((hadError?: boolean) => void) | (() => void)): void { - switch (event) { - case 'data': - this._stream.on(event, listener as (chunk: Uint8Array) => void); - break; - case 'error': - this._stream.on(event, listener as (err: Error) => void); - break; - case 'close': - this._stream.on(event, listener as (hadError?: boolean) => void); - break; - default: - this._stream.on(event, listener as () => void); - } - } - - removeListener(event: 'data', listener: (chunk: Uint8Array) => void): void; - removeListener(event: 'error', listener: (err: Error) => void): void; - removeListener(event: 'close', listener: (hadError?: boolean) => void): void; - removeListener(event: 'end' | 'drain' | 'pause' | 'resume', listener: () => void): void; - removeListener(event: 'data' | 'error' | 'end' | 'close' | 'drain' | 'pause' | 'resume', listener: ((chunk: Uint8Array) => void) | ((err: Error) => void) | ((hadError?: boolean) => void) | (() => void)): void { - switch (event) { - case 'data': - this._stream.removeListener(event, listener as (chunk: Uint8Array) => void); - break; - case 'error': - this._stream.removeListener(event, listener as (err: Error) => void); - break; - case 'close': - this._stream.removeListener(event, listener as (hadError?: boolean) => void); - break; - default: - this._stream.removeListener(event, listener as () => void); - } - } - - removeAllListeners(event: 'error'): void { - this._stream.removeAllListeners(event); - } - - write(chunk: Uint8Array | string, callback?: (error?: Error) => void): boolean { - const written = this._stream.write(chunk); - callback?.(); - return written; - } - - end(): void { - if (!this._ended) { - this._ended = true; - this._stream.end(); - } - } - - destroy(): void { - if (!this._destroyed) { - this._destroyed = true; - this._stream.destroy(); - } - } - - pause(): void { - this._stream.pause(); - } - - resume(): void { - this._stream.resume(); - } - - setNoDelay(_enable: boolean): void { - } - - setTimeout(_timeout: number): void { - } - - setKeepAlive(_enable: boolean, _initialDelay?: number): void { - } -} - /** A parsed HTTP WebSocket upgrade response. */ interface IUpgradeResponse { readonly status: number; @@ -323,7 +230,7 @@ function findSequence(bytes: Uint8Array, sequence: Uint8Array): number { return -1; } -/** Adapts the bundled WebSocket framing implementation to the tunnel socket contract. */ +/** Adapts shared RFC 6455 framing to the tunnel socket contract. */ class TunnelMessageSocket extends Disposable implements ITunnelMessageSocket { private readonly _onDidReceiveMessage = this._register(new Emitter({ onDidAddFirstListener: () => this.flushPendingMessages(), @@ -332,40 +239,147 @@ class TunnelMessageSocket extends Disposable implements ITunnelMessageSocket { private readonly _onDidClose = this._register(new Emitter()); readonly onDidClose: Event = this._onDidClose.event; private readonly _pendingMessages: string[] = []; + private readonly _frameParser: WebSocketFrameParser; + private _fragmentedMessage: VSBuffer[] | undefined; + private _fragmentedMessageLength = 0; private _closed = false; + private _closeSent = false; + private _streamEnded = false; + private _streamDestroyed = false; + private readonly _closeTimer = this._register(new TimeoutTimer()); constructor( private readonly _stream: ITunnelDuplexStream, - private readonly _connection: IWebSocketConnection, + maxFramePayloadLength: number, + private readonly _maxMessagePayloadLength: number, + private readonly _closeTimeoutMs: number, ) { super(); - const onMessage = (message: WebSocketConnectionMessage) => this.acceptMessage(message); - const onClose = (code: number, reason: string) => this.finishClose({ code, reason }); - const onError = (error: Error) => this.finishClose({ error }); - this._connection.on('message', onMessage); - this._connection.on('close', onClose); - this._connection.on('error', onError); - this._register(toDisposable(() => this._connection.removeListener('message', onMessage))); - this._register(toDisposable(() => this._connection.removeListener('close', onClose))); - this._register(toDisposable(() => this._connection.removeListener('error', onError))); + this._frameParser = new WebSocketFrameParser({ maxPayloadLength: maxFramePayloadLength }); + const onData = (data: Uint8Array) => this.acceptChunk(data); + const onError = (error: Error) => this.fail(error, 1002); + const onEnd = () => this.finishClose({}); + const onClose = () => this.finishClose({}); + this._stream.on('data', onData); + this._stream.on('error', onError); + this._stream.on('end', onEnd); + this._stream.on('close', onClose); + this._register({ + dispose: () => { + this._stream.removeListener('data', onData); + this._stream.removeListener('error', onError); + this._stream.removeListener('end', onEnd); + this._stream.removeListener('close', onClose); + } + }); } send(data: string): void { - this._connection.send(data); + if (!this._closed) { + this.writeFrame(VSBuffer.fromString(data), WebSocketOpcode.Text); + } } close(): void { - this._connection.close(); + if (!this._closed) { + this.sendClose(1000, ''); + this._closeTimer.setIfNotSet(() => { + const error = new Error(`WebSocket close handshake timed out after ${this._closeTimeoutMs}ms.`); + this.finishClose({ error }); + this.endStream(); + this.destroyStream(); + }, this._closeTimeoutMs); + } } override dispose(): void { - this._connection.close(); - this._stream.destroy(); + this.close(); + this.destroyStream(); super.dispose(); } - private acceptMessage(message: WebSocketConnectionMessage): void { - const data = message.type === 'utf8' ? message.utf8Data : new TextDecoder().decode(message.binaryData); + acceptChunk(data: Uint8Array): void { + try { + for (const frame of this._frameParser.acceptChunk(VSBuffer.wrap(data))) { + this.acceptFrame(frame); + } + } catch (error) { + if (error instanceof WebSocketFrameTooLargeError) { + this.fail(error, 1009); + } else { + this.fail(new Error('Received an invalid WebSocket frame.'), 1002); + } + } + } + + private acceptFrame(frame: IWebSocketFrame): void { + if (this._closed) { + return; + } + if (frame.mask !== undefined) { + this.fail(new Error('Received a masked WebSocket frame from the server.'), 1002); + return; + } + if (frame.compressed) { + this.fail(new Error('Received an unsupported compressed WebSocket frame.'), 1002); + return; + } + + switch (frame.opcode) { + case WebSocketOpcode.Text: + if (this._fragmentedMessage) { + this.fail(new Error('Received a WebSocket text frame before a fragmented message was complete.'), 1002); + } else if (frame.final) { + this.acceptText(frame.payload); + } else { + this._fragmentedMessage = [frame.payload]; + this._fragmentedMessageLength = frame.payload.byteLength; + this.ensureMessageWithinLimit(); + } + break; + case WebSocketOpcode.Continuation: + if (!this._fragmentedMessage) { + this.fail(new Error('Received a WebSocket continuation frame without a preceding text frame.'), 1002); + } else { + this._fragmentedMessage.push(frame.payload); + this._fragmentedMessageLength += frame.payload.byteLength; + if (!this.ensureMessageWithinLimit()) { + return; + } + if (frame.final) { + const payload = VSBuffer.concat(this._fragmentedMessage); + this._fragmentedMessage = undefined; + this._fragmentedMessageLength = 0; + this.acceptText(payload); + } + } + break; + case WebSocketOpcode.Binary: + this.fail(new Error('Received an unsupported binary WebSocket message.'), 1003); + break; + case WebSocketOpcode.Ping: + this.writeFrame(frame.payload, WebSocketOpcode.Pong); + break; + case WebSocketOpcode.Close: + this.acceptClose(frame.payload); + break; + case WebSocketOpcode.Pong: + break; + } + } + + private acceptText(payload: VSBuffer): void { + if (payload.byteLength > this._maxMessagePayloadLength) { + this.fail(new Error(`WebSocket message payload length ${payload.byteLength} exceeds the configured limit of ${this._maxMessagePayloadLength}.`), 1009); + return; + } + let data: string; + try { + data = new TextDecoder('utf-8', { fatal: true }).decode(payload.buffer); + } catch { + this.fail(new Error('Received invalid UTF-8 WebSocket text.'), 1007); + return; + } if (this._onDidReceiveMessage.hasListeners()) { this._onDidReceiveMessage.fire(data); } else { @@ -373,6 +387,38 @@ class TunnelMessageSocket extends Disposable implements ITunnelMessageSocket { } } + private acceptClose(payload: VSBuffer): void { + if (payload.byteLength === 1) { + this.fail(new Error('Received a WebSocket close frame with an invalid payload.'), 1002); + return; + } + + let event: ITunnelSocketCloseEvent = {}; + if (payload.byteLength >= 2) { + const code = payload.readUInt8(0) * 2 ** 8 + payload.readUInt8(1); + if (!isValidCloseCode(code)) { + this.fail(new Error(`Received an invalid WebSocket close code ${code}.`), 1002); + return; + } + try { + event = { + code, + reason: new TextDecoder('utf-8', { fatal: true }).decode(payload.slice(2).buffer), + }; + } catch { + this.fail(new Error('Received invalid UTF-8 WebSocket close reason.'), 1007); + return; + } + } + + if (!this._closeSent) { + this.writeFrame(payload, WebSocketOpcode.Close); + this._closeSent = true; + } + this.finishClose(event); + this.endStream(); + } + private flushPendingMessages(): void { while (this._pendingMessages.length > 0) { this._onDidReceiveMessage.fire(this._pendingMessages.shift()!); @@ -380,9 +426,67 @@ class TunnelMessageSocket extends Disposable implements ITunnelMessageSocket { } private finishClose(event: ITunnelSocketCloseEvent): void { + this._closeTimer.cancel(); if (!this._closed) { this._closed = true; this._onDidClose.fire(event); } } + + private fail(error: Error, closeCode: number): void { + if (this._closed) { + return; + } + this.sendClose(closeCode, ''); + this.finishClose({ error }); + this.endStream(); + } + + private sendClose(code: number, reason: string): void { + if (this._closeSent) { + return; + } + const reasonPayload = VSBuffer.fromString(reason); + const payload = VSBuffer.alloc(2 + reasonPayload.byteLength); + payload.writeUInt8(code >>> 8, 0); + payload.writeUInt8(code, 1); + payload.set(reasonPayload, 2); + this.writeFrame(payload, WebSocketOpcode.Close); + this._closeSent = true; + } + + private ensureMessageWithinLimit(): boolean { + if (this._fragmentedMessageLength > this._maxMessagePayloadLength) { + this.fail(new Error(`WebSocket message payload length ${this._fragmentedMessageLength} exceeds the configured limit of ${this._maxMessagePayloadLength}.`), 1009); + return false; + } + return true; + } + + private writeFrame(payload: VSBuffer, opcode: WebSocketOpcode): void { + if (this._closed) { + return; + } + const maskBytes = crypto.getRandomValues(new Uint8Array(4)); + const mask = maskBytes[0] * 2 ** 24 + maskBytes[1] * 2 ** 16 + maskBytes[2] * 2 ** 8 + maskBytes[3]; + this._stream.write(encodeWebSocketFrame(payload, { opcode, mask }).buffer); + } + + private endStream(): void { + if (!this._streamEnded) { + this._streamEnded = true; + this._stream.end(); + } + } + + private destroyStream(): void { + if (!this._streamDestroyed) { + this._streamDestroyed = true; + this._stream.destroy(); + } + } +} + +function isValidCloseCode(code: number): boolean { + return code === 1000 || (code >= 1001 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006) || (code >= 3000 && code <= 4999); } diff --git a/src/vs/platform/agentHost/node/agentHostDebugLogs.ts b/src/vs/platform/agentHost/node/agentHostDebugLogs.ts index 0b19b14c1cac8..c65140993fc95 100644 --- a/src/vs/platform/agentHost/node/agentHostDebugLogs.ts +++ b/src/vs/platform/agentHost/node/agentHostDebugLogs.ts @@ -14,7 +14,7 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import type { ILogService } from '../../log/common/log.js'; import type { IAgent } from '../common/agent.js'; -import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; +import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; type DebugLogsProvider = Pick; type LocalZipFile = IFile & { readonly localPath: string }; @@ -57,30 +57,18 @@ export class AgentHostDebugLogsCollector extends Disposable { providerLogsIncluded = await provider.collectDebugLogs(session, URI.file(staging)) || providerLogsIncluded; } - await this._copyOptional( - join(this._environment.logsHome.fsPath, 'agenthost.log'), - join(staging, 'agenthost.log'), - ); + await this._copyAgentHostLogs(staging); const files = await collectFiles(staging); - // Process logs can reach hundreds of megabytes. Keep the tail of any - // oversized file: it is the part that explains a recent failure, and - // it keeps the artifact within the size the client will accept — - // whether the file came from a provider's SDK bundle or was copied - // in directly. let uncompressedSize = 0; const artifactEntries: { path: string; size: number }[] = []; for (const file of files) { - const size = await truncateToTail(file.localPath, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES); + const { size } = await stat(file.localPath); uncompressedSize += size; artifactEntries.push({ path: file.path, size }); } - // A directory artifact is copied file-by-file, so its uncompressed - // size is what crosses the wire. An archive only has to keep the - // staged input bounded; the archive itself is checked after zipping. - const stagedLimit = kind === 'directory' ? AGENT_HOST_DEBUG_LOGS_MAX_BYTES : AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES; - if (uncompressedSize > stagedLimit) { - throw new Error(`Agent Host debug logs are too large (${uncompressedSize} bytes; limit ${stagedLimit} bytes)`); + if (uncompressedSize > AGENT_HOST_DEBUG_LOGS_MAX_BYTES) { + throw new Error(`Agent Host debug logs are too large (${uncompressedSize} bytes; limit ${AGENT_HOST_DEBUG_LOGS_MAX_BYTES} bytes)`); } if (kind === 'directory') { @@ -151,11 +139,26 @@ export class AgentHostDebugLogsCollector extends Disposable { })); } - private async _copyOptional(source: string, target: string): Promise { + private async _copyAgentHostLogs(staging: string): Promise { + let names: string[]; try { - await copyFile(source, target); + names = (await readdir(this._environment.logsHome.fsPath, { withFileTypes: true })) + .filter(entry => entry.isFile() && ( + isRotatedLogFile(entry.name, 'agenthost.log') + || isRotatedLogFile(entry.name, 'agenthost-server.log') + )) + .map(entry => entry.name); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + this._logService.warn(`[AgentHostDebugLogs] Failed to enumerate Agent Host process logs`, error); + } + return; + } + for (const name of names) { + const source = join(this._environment.logsHome.fsPath, name); + try { + await copyFile(source, join(staging, name)); + } catch (error) { this._logService.warn(`[AgentHostDebugLogs] Failed to include ${source}`, error); } } @@ -187,27 +190,6 @@ function artifactKey(path: string): string { return URI.file(path).fsPath; } -/** - * Rewrites `path` in place to its last `maxBytes` bytes when it exceeds them. - * Returns the resulting size. - */ -async function truncateToTail(path: string, maxBytes: number): Promise { - const { size } = await stat(path); - if (size <= maxBytes) { - return size; - } - const handle = await open(path, 'r+'); - try { - const buffer = Buffer.allocUnsafe(maxBytes); - const { bytesRead } = await handle.read(buffer, 0, maxBytes, size - maxBytes); - await handle.write(buffer, 0, bytesRead, 0); - await handle.truncate(bytesRead); - return bytesRead; - } finally { - await handle.close(); - } -} - async function collectFiles(root: string, relative = '', files: LocalZipFile[] = []): Promise { const directory = join(root, relative); const entries = await readdir(directory, { withFileTypes: true }); @@ -224,3 +206,16 @@ async function collectFiles(root: string, relative = '', files: LocalZipFile[] = } return files; } + +function isRotatedLogFile(candidate: string, current: string): boolean { + if (candidate === current) { + return true; + } + const stem = current.endsWith('.log') ? current.slice(0, -'.log'.length) : current; + const prefix = `${stem}.`; + if (!candidate.startsWith(prefix) || !candidate.endsWith('.log')) { + return false; + } + const rotation = candidate.slice(prefix.length, -'.log'.length); + return /^[1-9]\d*$/.test(rotation); +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 136ea0635a64b..954cc39b78ccd 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -63,7 +63,9 @@ import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { buildServerToolGroups } from './shared/serverToolGroups.js'; import { type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, type ISessionServerToolAccessor, validateRenameTitle } from './shared/sessionServerTools.js'; -import { AGENT_HOST_TITLE_SOURCE_AGENT, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadataValues, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; +import { AGENT_HOST_TITLE_SOURCE_AGENT, customChatTitleMetadataKey, customChatTitleSourceMetadataKey, persistSessionMetadata, persistSessionMetadataValues, SESSION_ARTIFACTS_KEY, SESSION_CUSTOM_TITLE_KEY, SESSION_CUSTOM_TITLE_SOURCE_KEY } from './shared/persistSessionMetadata.js'; +import { type IArtifactServerToolAccessor } from './shared/artifactServerTools.js'; +import { parseSessionArtifacts, stringifySessionArtifacts, withSessionArtifacts } from '../common/sessionArtifacts.js'; import { buildWorktreeFailureNotification, WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT, worktreeProjectFromRepositoryRoot } from './shared/worktreeIsolation.js'; import { AgentHostChangesetService } from './agentHostChangesetService.js'; @@ -95,7 +97,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostAuthenticationService, type IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; -import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; import { AgentHostStorageService, IAgentHostStorageService } from './agentHostStorageService.js'; import { SessionCoordinationService } from './sessionCoordination.js'; @@ -838,7 +840,7 @@ export class AgentService extends Disposable implements IAgentService { () => this._agentMergeController.isEnabled(), session => this._agentMergeController.getTurnContext(session), ); - this._serverToolHost = new AgentServerToolHost(this._stateManager, buildServerToolGroups(this._createSessionServerToolAccessor(), agentMergeTools)); + this._serverToolHost = new AgentServerToolHost(this._stateManager, buildServerToolGroups(this._createSessionServerToolAccessor(), agentMergeTools, this._createArtifactServerToolAccessor())); } /** @@ -1151,6 +1153,18 @@ export class AgentService extends Disposable implements IAgentService { return this._configurationService.getRootValue(platformRootSchema, AgentHostActiveAgentTitleGenerationConfigKey) === true; } + /** Dependency surface for the artifact server-tool group. */ + private _createArtifactServerToolAccessor(): IArtifactServerToolAccessor { + return { + isEnabled: () => this._isArtifactToolsEnabled(), + persist: (session, artifacts) => persistSessionMetadata(this._sessionDataService, this._logService, session, SESSION_ARTIFACTS_KEY, stringifySessionArtifacts(artifacts)), + }; + } + + private _isArtifactToolsEnabled(): boolean { + return this._configurationService.getRootValue(platformRootSchema, AgentHostArtifactToolsConfigKey) === true; + } + private _getServerToolCreationDefaults(source: URI): ISessionCreationDefaults | undefined { const session = this._stateManager.getSessionState(source.toString()); if (!session) { @@ -1465,19 +1479,27 @@ export class AgentService extends Disposable implements IAgentService { * discovery is independent and surfaces unknown chats additively. */ private async _awaitInitialProviderMigration(): Promise { - const providers = [...this._providers.values()]; - const migrations = providers.map(provider => this._initialProviderMigrations.get(provider.id) ?? Promise.resolve()); - const results = await Promise.allSettled(migrations); - const retries: Promise[] = []; - for (let index = 0; index < results.length; index++) { - const result = results[index]; - if (result.status === 'rejected') { - const provider = providers[index]; - this._logService.warn(`[AgentService] initial provider catalog for ${provider.id} was unavailable; retrying before listing sessions`, result.reason); - retries.push(this._replaceFailedInitialProviderMigration(provider, migrations[index])); - } + await Promise.all([...this._providers.values()].map(provider => this._awaitInitialProviderMigrationForProvider(provider))); + } + + /** + * Awaits the registration-time legacy migration for a single provider, + * retrying once if that initial catalog pass was unavailable. Rejects only if + * the retry also fails. Restore uses this to wait for its own provider's + * catalog before reading per-session metadata, mirroring what + * {@link _awaitInitialProviderMigration} does for `listSessions`. + */ + private async _awaitInitialProviderMigrationForProvider(provider: IAgent): Promise { + const migration = this._initialProviderMigrations.get(provider.id); + if (!migration) { + return; + } + try { + await migration; + } catch (err) { + this._logService.warn(`[AgentService] initial provider catalog for ${provider.id} was unavailable; retrying before accessing sessions`, err); + await this._replaceFailedInitialProviderMigration(provider, migration); } - await Promise.all(retries); } private _replaceFailedInitialProviderMigration(provider: IAgent, failed: Promise): Promise { @@ -1864,8 +1886,8 @@ export class AgentService extends Disposable implements IAgentService { const sessionStr = s.session.toString(); const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr); const metadataKeys: Record = changesetKeys - ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } - : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; + ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } + : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [SESSION_ARTIFACTS_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; const m = await ref.object.getMetadataObject(metadataKeys); // This session is an internal peer-chat backing (e.g. a // Claude peer chat's SDK session, enumerated by the agent's @@ -1923,6 +1945,10 @@ export class AgentService extends Disposable implements IAgentService { if (multiRoot) { updated = { ...updated, _meta: withSessionMultiRootMetadata(updated._meta, multiRoot) }; } + const artifacts = parseSessionArtifacts(m[SESSION_ARTIFACTS_KEY]); + if (artifacts.length > 0) { + updated = { ...updated, _meta: withSessionArtifacts(updated._meta, artifacts) }; + } const folderPickerDecision = parseSessionFolderPickerDecision(m[SESSION_META_FOLDER_PICKER_KEY]); if (folderPickerDecision) { updated = { ...updated, _meta: withSessionFolderPickerDecision(updated._meta, folderPickerDecision) }; @@ -2137,6 +2163,8 @@ export class AgentService extends Disposable implements IAgentService { /** Tracks the migrate-legacy setting so the config listener acts only on transitions. */ private _lastMigrateLegacyEnabled = false; + /** Adoptable keys retracted in this window; re-enabling also recovers earlier ones from the catalog. */ + private readonly _retractedAdoptableKeys = new Set(); private _isMigrateLegacyEnabled(): boolean { return this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true; @@ -2154,7 +2182,12 @@ export class AgentService extends Disposable implements IAgentService { } this._lastMigrateLegacyEnabled = enabled; if (enabled) { - return; // turning on re-surfaces through the normal discovery / list path + // Discovery skips chats already in the registry, so it cannot re-announce + // what disabling retracted — restore them from the registry instead. + this._sessionListReconciliation = this._sessionListReconciliation + .then(() => this._resurfaceAdoptableSessions()) + .catch(error => this._logService.warn('[AgentService] Re-surfacing adoptable legacy sessions failed', error)); + return; } for (const key of [...this._announcedSurfacedKeys]) { if (this._stateManager.getSessionState(key)) { @@ -2165,10 +2198,39 @@ export class AgentService extends Disposable implements IAgentService { } this._announcedSurfacedKeys.delete(key); this._broadcastExternalSessions.delete(key); + this._retractedAdoptableKeys.add(key); this._stateManager.retractSurfacedSession(key); } } + /** + * Re-announces adoptable-legacy sessions that are not currently surfaced — + * those this window retracted, plus any the catalog still reports as adoptable, + * so rows retracted before a restart are recovered too. + */ + private async _resurfaceAdoptableSessions(): Promise { + if (!this._isMigrateLegacyEnabled()) { + return; + } + for (const metadata of await this.listSessions()) { + const key = metadata.session.toString(); + if (this._announcedSurfacedKeys.has(key) || this._stateManager.getSessionState(key)) { + this._retractedAdoptableKeys.delete(key); + continue; + } + if (!this._retractedAdoptableKeys.has(key) && !readSessionEhcliAdoptable(metadata._meta)) { + continue; + } + const provider = AgentSession.provider(metadata.session); + if (provider) { + await this._announceSurfacedSession(metadata, provider); + } + if (this._announcedSurfacedKeys.has(key) || this._stateManager.getSessionState(key)) { + this._retractedAdoptableKeys.delete(key); + } + } + } + private _queueSessionListReconciliation(previousMode?: AgentHostExternalSessionsMode): void { this._sessionListReconciliation = this._sessionListReconciliation .then(() => this._reconcileExternalSessions(previousMode)) @@ -4610,6 +4672,22 @@ export class AgentService extends Disposable implements IAgentService { if (await this._sessionRegistry.isTombstoned(session)) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`); } + // Wait for the provider's one-time catalog migration before reading + // metadata, mirroring `listSessions`, so restore does not misread an + // unwarmed catalog as a missing session (#331648). A catalog that stays + // unavailable is non-fatal: fall through, but remember it so a resulting + // miss is classified as unavailable rather than absent. + let catalogReadable = true; + try { + await this._awaitInitialProviderMigrationForProvider(agent); + } catch (err) { + catalogReadable = false; + this._logService.warn(`[AgentService] restore: initial catalog migration for provider ${agent.id} failed; a metadata miss will be reported as unavailable, not missing`, err); + } + // Re-check after the (possibly lengthy) wait so a delete that landed meanwhile is not resurrected. + if (await this._sessionRegistry.isTombstoned(session)) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session was explicitly deleted: ${sessionStr}`); + } const registeredSession = (await this._listRegisteredSessions()).find(entry => entry.session.toString() === sessionStr); const external = registeredSession?.external ?? false; @@ -4629,11 +4707,21 @@ export class AgentService extends Disposable implements IAgentService { } const adopted = adoption.adopted; + // A session the registry does not know is only restorable when it is ours: + // either an adoptable legacy chat, or one that already has Agent Host + // metadata whose registry entry was lost. `external` defaults to false for + // unknown sessions, so without this an external chat (e.g. one the GitHub app + // created, hidden while `showExternalSessions` is `none`) would be + // materialized here and thereby claimed away from the extension host's list. + if (!registeredSession && migrateLegacyEnabled && agent.ensureChatAdopted && !adoption.eligible && !adoption.native) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session is not an adoptable legacy chat: ${sessionStr}`); + } + // From here the whole restore is wrapped so `migrated` is reported only // after every required step succeeds, and any failure after a successful // adoption is surfaced as a migration failure. try { - const facts = await this._restoreSessionState(agent, session, sessionStr, adopted, external, registeredSession?.source ?? 'restore'); + const facts = await this._restoreSessionState(agent, session, sessionStr, adopted, external, registeredSession?.source ?? 'restore', catalogReadable, !!registeredSession); await this._restoreAnnotations(session); if (adopted) { this._reportLegacyMigration(agent.id, 'migrated', migrationStartTime, facts); @@ -4730,10 +4818,16 @@ export class AgentService extends Disposable implements IAgentService { * Returns the facts used for migration telemetry; throws if any required step * fails so the caller can report the outcome accurately. */ - private async _restoreSessionState(agent: IAgent, session: URI, sessionStr: string, adopted: boolean, external: boolean, registrationSource: IRegisteredSession['source']): Promise<{ turnCount: number; hasProject: boolean; hasWorktree: boolean; workingDirectoryCount: number }> { + private async _restoreSessionState(agent: IAgent, session: URI, sessionStr: string, adopted: boolean, external: boolean, registrationSource: IRegisteredSession['source'], catalogReadable: boolean, sessionKnownToRegistry: boolean): Promise<{ turnCount: number; hasProject: boolean; hasWorktree: boolean; workingDirectoryCount: number }> { let meta = await this._getSessionMetadataForRestore(agent, session, external); if (!meta) { - throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`); + // Authoritative absence only when the catalog was readable this run and + // the registry has no record of the session; a miss for a known + // (registered) session, or while the catalog was unavailable, is + // transient — e.g. a provider whose SDK is not downloaded yet (#331648). + throw catalogReadable && !sessionKnownToRegistry + ? new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`) + : new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Provider ${agent.id} could not describe ${sessionStr} yet`); } // A freshly-adopted legacy session whose working directory is a @@ -4831,6 +4925,7 @@ export class AgentService extends Disposable implements IAgentService { [AH_META_WORKSPACELESS_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, + [SESSION_ARTIFACTS_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, ...GIT_DB_METADATA_KEYS, ...CHANGESET_DB_METADATA_KEYS, @@ -4894,6 +4989,7 @@ export class AgentService extends Disposable implements IAgentService { sessionMetadata = withSessionOrchestration(sessionMetadata, orchestration); } sessionMetadata = withSessionMultiRootMetadata(sessionMetadata, parseSessionMultiRootMetadata(m[SESSION_META_MULTI_ROOT_KEY])); + sessionMetadata = withSessionArtifacts(sessionMetadata, parseSessionArtifacts(m[SESSION_ARTIFACTS_KEY])); sessionMetadata = withSessionFolderPickerDecision(sessionMetadata, parseSessionFolderPickerDecision(m[SESSION_META_FOLDER_PICKER_KEY])); if (m.configValues) { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 9c04ed0dc49a3..5b6fa6bb4d09b 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -17,7 +17,8 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { ILogService } from '../../log/common/log.js'; import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; -import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, platformRootSchema, type SessionMode } from '../common/agentHostSchema.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostArtifactToolsConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, platformRootSchema, type SessionMode } from '../common/agentHostSchema.js'; +import { ARTIFACT_TOOLS_INSTRUCTION } from './shared/artifactServerTools.js'; import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { readAgentModelByokIdentifier } from '../common/agentModelByokMeta.js'; @@ -2202,6 +2203,9 @@ export class AgentSideEffects extends Disposable { ...(this._agentConfigService.getRootValue(platformRootSchema, AgentHostMarkdownPlanRichLinksEnabledConfigKey) ? [createMarkdownPlanRichLinksInstruction(chat)] : []), + ...(this._agentConfigService.getRootValue(platformRootSchema, AgentHostArtifactToolsConfigKey) + ? [ARTIFACT_TOOLS_INSTRUCTION] + : []), ...(terminalSurface ? [createTerminalChatInstruction(terminalSurface)] : []), ...(renameInstruction ? [renameInstruction] : []), ]; diff --git a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts index d48314b93ab3a..cad3dd863a2aa 100644 --- a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts +++ b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts @@ -14,10 +14,9 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { IProductService } from '../../../product/common/productService.js'; import { ISandboxHelperService, type ISandboxDependencyStatus, type IWindowsMxcPolicyContainment, type IWindowsMxcSandboxPolicy } from '../../../sandbox/common/sandboxHelperService.js'; import { ITerminalSandboxEngineHost, ITerminalSandboxRuntimeInfo, TerminalSandboxEngine } from '../../../sandbox/common/terminalSandboxEngine.js'; -import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { getAppNodeModulesDirName } from '../appNodeModules.js'; -import { AgentHostSandboxConfigKey, AgentHostSandboxKey, sandboxConfigSchema, sandboxSettingIdToAgentHostKey } from '../../common/sandboxConfigSchema.js'; +import { AgentHostSandboxConfigKey, sandboxConfigSchema, sandboxSettingIdToAgentHostKey } from '../../common/sandboxConfigSchema.js'; /** Subdirectory under the user home + product data folder where the engine creates its temp dir. */ const SANDBOX_TEMP_DIR_NAME = 'tmp'; @@ -38,7 +37,6 @@ class AgentHostTerminalSandboxHost implements ITerminalSandboxEngineHost { private readonly _environmentService: INativeEnvironmentService, private readonly _productService: IProductService, private readonly _agentConfigurationService: IAgentConfigurationService, - private readonly _getManagedSandboxEnabled: () => boolean | undefined, sandboxHelper: ISandboxHelperService, ) { this._sandboxHelper = sandboxHelper; @@ -116,12 +114,6 @@ class AgentHostTerminalSandboxHost implements ITerminalSandboxEngineHost { if (innerKey === undefined) { return undefined; } - if (innerKey === AgentHostSandboxKey.Enabled || innerKey === AgentHostSandboxKey.WindowsEnabled) { - const managedEnabled = this._getManagedSandboxEnabled(); - if (typeof managedEnabled === 'boolean') { - return (managedEnabled ? AgentSandboxEnabledValue.On : AgentSandboxEnabledValue.Off) as T; - } - } const sandbox = this._agentConfigurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox); return sandbox?.[innerKey] as T | undefined; } @@ -141,8 +133,7 @@ export function createAgentHostSandboxEngine( sandboxHelper: ISandboxHelperService, sessionId: string, workingDirectory: URI | undefined, - getManagedSandboxEnabled: () => boolean | undefined, ): TerminalSandboxEngine { - const host = new AgentHostTerminalSandboxHost(sessionId, workingDirectory, environmentService as INativeEnvironmentService, productService, agentConfigurationService, getManagedSandboxEnabled, sandboxHelper); + const host = new AgentHostTerminalSandboxHost(sessionId, workingDirectory, environmentService as INativeEnvironmentService, productService, agentConfigurationService, sandboxHelper); return instantiationService.createInstance(TerminalSandboxEngine, host); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index f29aca3300cd7..2c709c0e36906 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -7,7 +7,7 @@ import { CopilotClient, RuntimeConnection, type CopilotClientOptions, type GitHu import * as fs from 'fs/promises'; import * as os from 'os'; import { pathToFileURL } from 'url'; -import { CancelablePromise, createCancelablePromise, DeferredPromise, Delayer, disposableTimeout, Limiter, raceTimeout, retry, Sequencer, SequencerByKey } from '../../../../base/common/async.js'; +import { CancelablePromise, createCancelablePromise, DeferredPromise, Delayer, disposableTimeout, Limiter, raceTimeout, Sequencer, SequencerByKey, timeout } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { structuralEquals } from '../../../../base/common/equals.js'; import { CancellationError, getErrorMessage } from '../../../../base/common/errors.js'; @@ -48,7 +48,6 @@ import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultR import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { AgentHostCopilotManagedSandboxEnabledConfigKey } from '../../common/sandboxConfigSchema.js'; import { ICopilotConfigSlashCommandState } from '../../common/copilotConfigSlashCommands.js'; import { getCopilotHomePath } from '../../common/copilotHome.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; @@ -83,7 +82,6 @@ import { CopilotGitHubTelemetryForwarder } from './copilotGitHubTelemetryForward import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, isCopilotReasoningEffort, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { CopilotAgentStartupConfig } from './copilotAgentStartupConfig.js'; import { ShellManager } from './copilotShellTools.js'; -import { getServerManagedSandboxEnabled } from './sandboxConfigForSdk.js'; import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; import { ICopilotApiService, type IRestrictedTelemetryContext } from '../shared/copilotApiService.js'; import { AgentHostGitHubTelemetryRouter } from '../agentHostGitHubTelemetryRouter.js'; @@ -552,10 +550,23 @@ export function resolveCopilotOtlpMetricsEndpoint(endpoint: string, protocol: 'h } } -/** `origin` value written by the VS Code extension-host Copilot CLI feature. */ -const EXTENSION_HOST_CLI_MARKER_ORIGIN = 'vscode'; const COPILOT_EXTERNAL_SESSION_CLIENT_NAMES = new Set(['github/cli', 'github/autopilot']); const COPILOT_EXTERNAL_SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +/** How many SDK sessions are classified before the batch is published to clients. */ +const COPILOT_DISCOVERY_BATCH_SIZE = 250; + +/** + * Backoff between initial chat-discovery attempts. The common failure is the CLI + * client still starting, which clears in well under a second, so the first retry + * is short; later ones back off for genuinely slow starts. + */ +const CHAT_DISCOVERY_RETRY_DELAYS_MS = [250, 1_000, 5_000]; + +/** `origin` value written by the VS Code extension-host Copilot CLI feature. */ +const EXTENSION_HOST_CLI_MARKER_ORIGIN = 'vscode'; + +/** File name of the marker written beside a Copilot CLI session's SDK event log. */ +const EXTENSION_HOST_CLI_MARKER_FILE = 'vscode.metadata.json'; /** * Shape of the `vscode.metadata.json` marker written next to a Copilot CLI @@ -565,9 +576,55 @@ const COPILOT_EXTERNAL_SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; interface IExtensionHostCliMarker { readonly origin?: string; readonly customTitle?: string; - readonly repositoryProperties?: unknown; - readonly worktreeProperties?: unknown; - readonly workspaceFolder?: unknown; + /** Folder-mode repository root recorded by the extension host. */ + readonly repositoryProperties?: { readonly repositoryPath?: string }; + /** Worktree-mode checkout; `worktreePath` is the directory the session ran in. */ + readonly worktreeProperties?: { readonly worktreePath?: string; readonly repositoryPath?: string }; + readonly workspaceFolder?: { readonly folderPath?: string }; +} + +function parseExtensionHostCliMarker(raw: string): IExtensionHostCliMarker | undefined { + try { + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as IExtensionHostCliMarker : undefined; + } catch { + return undefined; + } +} + +/** + * Whether a marker identifies a chat created by the VS Code extension host — + * the only chats migration ever adopts. + * + * Mirrors the extension host's `getSessionOrigin`: honor an explicit `origin` + * (the GitHub Copilot app writes `other`), else guess `vscode` only when older + * origin-less markers carry VS Code-specific properties. + */ +function isExtensionHostCliMarker(marker: IExtensionHostCliMarker | undefined): boolean { + if (!marker || Object.keys(marker).length === 0) { + return false; + } + if (marker.origin !== undefined) { + return marker.origin === EXTENSION_HOST_CLI_MARKER_ORIGIN; + } + return marker.repositoryProperties !== undefined + || marker.worktreeProperties !== undefined + || marker.workspaceFolder !== undefined; +} + +/** + * Working directory candidates the extension host recorded for a chat, in + * precedence order, used when the SDK reports none. A worktree session ran in + * its checkout, not the repository root — but that checkout may since have been + * deleted, so callers fall through to the next candidate that still exists. + */ +function extensionHostCliWorkingDirectoryPaths(marker: IExtensionHostCliMarker | undefined): string[] { + return [ + marker?.worktreeProperties?.worktreePath, + marker?.workspaceFolder?.folderPath, + marker?.repositoryProperties?.repositoryPath, + marker?.worktreeProperties?.repositoryPath, + ].filter((path): path is string => typeof path === 'string' && path.length > 0); } /** @@ -776,7 +833,6 @@ export class CopilotAgent extends Disposable implements IAgent { */ private readonly _hostCustomizations = new ResourceMap(); private readonly _slashCommandProvider: CopilotSlashCommandProvider; - private _managedSandboxEnabled: boolean | undefined; constructor( @ILogService private readonly _logService: ILogService, @@ -1252,7 +1308,6 @@ export class CopilotAgent extends Disposable implements IAgent { throw new Error(`Copilot runtime diagnostics exceeded 4.5 seconds while ${stage}.`); } this._logService.debug('[Copilot] Runtime managed-settings diagnostics collected'); - this._updateManagedSandbox(result.resolved); return { ...result.resolved, ...(result.account ? { account: result.account } : {}), @@ -1427,7 +1482,6 @@ export class CopilotAgent extends Disposable implements IAgent { this._updateRestrictedTelemetry(token); this._refreshProxy(); if (!token) { - this._updateManagedSandbox(undefined); await this._requestClientRestart('GitHub authentication cleared'); void this._scheduleModelRefresh(); return; @@ -1458,32 +1512,9 @@ export class CopilotAgent extends Disposable implements IAgent { await this._requestClientRestart('GitHub credential update failed'); } await this._resolveCopilotSku(token); - void this._refreshManagedSandbox(); void this._scheduleModelRefresh(); } - private async _refreshManagedSandbox(): Promise { - try { - await this.getManagedSettingsDiagnostics(); - } catch (error) { - this._logService.warn(`[Copilot] Failed to refresh managed sandbox settings: ${getErrorMessage(error)}`); - } - } - - private _updateManagedSandbox(data: ManagedSettingsResolvedData | undefined): void { - const enabled = data ? getServerManagedSandboxEnabled(data) : undefined; - if (this._managedSandboxEnabled === enabled) { - return; - } - this._managedSandboxEnabled = enabled; - for (const session of this._allLiveSessions()) { - session.setManagedSandboxEnabled(enabled); - } - this._configurationService.publishRootTransientValues?.({ - [AgentHostCopilotManagedSandboxEnabledConfigKey]: enabled ?? null, - }); - } - private _handleCopilotSessionAuthRequired(): void { this._authenticationRequired.set({ resource: this._gitHubEndpointService.getCopilotResource(), @@ -2260,19 +2291,22 @@ export class CopilotAgent extends Disposable implements IAgent { } private _runCopilotChatDiscovery(): Promise { - return this._copilotChatDiscoverySequencer.queue(() => - retry(async () => { + return this._copilotChatDiscoverySequencer.queue(async () => { + for (let attempt = 0; ; attempt++) { if (this._shutdownPromise || this._store.isDisposed) { - // Teardown began between attempts. Return rather than throw so - // the retry stops instead of sleeping on a dead client. + // Teardown began between attempts; stop rather than sleep on a dead client. return; } - if (!(await this._emitCopilotChats())) { - throw new Error('Copilot chat catalog is not available'); + if (await this._emitCopilotChats()) { + return; } - }, 5000, 3) - .catch(err => this._logService.warn('[Copilot] Chat discovery failed', err)) - ); + if (attempt >= CHAT_DISCOVERY_RETRY_DELAYS_MS.length) { + this._logService.warn('[Copilot] Chat discovery failed: catalog never became available'); + return; + } + await timeout(CHAT_DISCOVERY_RETRY_DELAYS_MS[attempt]); + } + }); } /** @@ -2287,34 +2321,40 @@ export class CopilotAgent extends Disposable implements IAgent { private async _emitCopilotChats(): Promise { const migrateLegacyAtStart = this._isMigrateLegacyCopilotCliEnabled(); try { - const chats = await this._discoverCopilotChats(); - if (!chats) { + const enumerated = await this._discoverCopilotChats(chats => this._publishDiscoveredChats(chats, migrateLegacyAtStart)); + return enumerated; + } catch (err) { + this._logService.warn('[Copilot] Failed to emit discovered chats', err); + return false; + } + } + + /** + * Publishes one classified batch, filtering out chats that must not surface + * and ones whose signature is unchanged since the last pass. Batches are + * additive, so a large catalogue converges progressively instead of + * withholding every row until the whole scan completes. + */ + private _publishDiscoveredChats(chats: readonly IAgentDiscoveredChat[], migrateLegacyAtStart: boolean): void { + if (this._shutdownPromise || this._store.isDisposed) { + return; + } + const migrateLegacy = migrateLegacyAtStart && this._isMigrateLegacyCopilotCliEnabled(); + const emitted = chats.filter(chat => { + if (!chat.external && !migrateLegacy) { return false; } - if (this._shutdownPromise || this._store.isDisposed) { - return true; - } - const migrateLegacy = migrateLegacyAtStart && this._isMigrateLegacyCopilotCliEnabled(); - const emitted = chats.filter(chat => { - if (!chat.external && !migrateLegacy) { - return false; - } - const key = chat.chat.toString(); - const signature = JSON.stringify(chat); - if (this._discoveredChats.get(key)?.signature === signature) { - return false; - } - this._discoveredChats.set(key, { signature, external: chat.external }); - return true; - }); - this._logService.info(`[Copilot] Chat discovery: emitting ${emitted.length} of ${chats.length} discovered chat(s) (adopt legacy extension-host chats: ${migrateLegacy})`); - if (emitted.length > 0) { - this._onDidDiscoverChats.fire(emitted); + const key = chat.chat.toString(); + const signature = JSON.stringify(chat); + if (this._discoveredChats.get(key)?.signature === signature) { + return false; } + this._discoveredChats.set(key, { signature, external: chat.external }); return true; - } catch (err) { - this._logService.warn('[Copilot] Failed to emit discovered chats', err); - return false; + }); + this._logService.info(`[Copilot] Chat discovery: emitting ${emitted.length} of ${chats.length} discovered chat(s) (adopt legacy extension-host chats: ${migrateLegacy})`); + if (emitted.length > 0) { + this._onDidDiscoverChats.fire(emitted); } } @@ -2344,10 +2384,10 @@ export class CopilotAgent extends Disposable implements IAgent { * `undefined` means the catalog could not be enumerated yet — not an * authoritative empty result. */ - private async _discoverCopilotChats(): Promise { + private async _discoverCopilotChats(publish: (chats: readonly IAgentDiscoveredChat[]) => void): Promise { const sessions = await this._listSdkSessions('discoverable chats', async client => (await client.rpc.sessions.list({})).sessions); if (!sessions) { - return undefined; + return false; } // Filter registered candidates with one registry query. const knownSessions = this._knownSessionsFilter @@ -2366,22 +2406,30 @@ export class CopilotAgent extends Disposable implements IAgent { let withoutRepository = 0; let suppressedAdoptable = 0; let failed = 0; - const mapped = await Promise.all(sessions.map(s => metadataLimiter.queue(async () => { + let discovered = 0; + let external = 0; + const classify = (s: typeof sessions[number]) => metadataLimiter.queue(async () => { const session = AgentSession.uri(this.id, s.sessionId); try { if (knownSessions ? knownSessions.has(session.toString()) : !!(await this._readStoredSessionMetadata(session))) { known++; return undefined; } - if (typeof s.context?.cwd !== 'string') { - withoutWorkingDirectory++; - return undefined; - } const adoptable = await this._isExtensionHostCliSession(s.sessionId); if (adoptable && !emitAdoptable) { suppressedAdoptable++; return undefined; } + // A legacy chat the SDK reports without a cwd is still reachable: the + // extension host records its own directory in the marker, and that is + // the only source once the extension is retired. + const workingDirectory = typeof s.context?.cwd === 'string' + ? URI.file(s.context.cwd) + : adoptable ? await this._extensionHostCliWorkingDirectory(s.sessionId) : undefined; + if (!workingDirectory) { + withoutWorkingDirectory++; + return undefined; + } const modifiedTime = new Date(s.modifiedTime).getTime(); if (!adoptable) { const clientName = s.isRemote ? undefined : s.clientName; @@ -2393,7 +2441,7 @@ export class CopilotAgent extends Disposable implements IAgent { outsideImportWindow++; return undefined; } - if (typeof s.context.repository !== 'string' || s.context.repository.trim().length === 0) { + if (typeof s.context?.repository !== 'string' || s.context.repository.trim().length === 0) { withoutRepository++; return undefined; } @@ -2402,9 +2450,11 @@ export class CopilotAgent extends Disposable implements IAgent { chat: URI.parse(buildDefaultChatUri(session)), startTime: new Date(s.startTime).getTime(), modifiedTime, - project: await this._resolveSessionProject(s.context, projectLimiter, projectByContext), + // Always key the project off the resolved working directory: a worktree + // session's context repository/gitRoot would resolve to the repo root. + project: await this._resolveSessionProject({ ...s.context, cwd: workingDirectory.fsPath }, projectLimiter, projectByContext), summary: s.summary, - workingDirectories: [URI.file(s.context.cwd)], + workingDirectories: [workingDirectory], _meta: adoptable ? withSessionEhcliAdoptable(undefined) : undefined, external: !adoptable, } satisfies IAgentDiscoveredChat; @@ -2413,11 +2463,21 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.warn(`[Copilot] Failed to classify discovered chat ${session.toString()}; skipping it`, err); return undefined; } - }))); - const chats = mapped.filter((chat): chat is IAgentDiscoveredChat => chat !== undefined); - const external = chats.filter(chat => chat.external).length; - this._logService.info(`[Copilot] Chat discovery: ${sessions.length} SDK session(s) -> ${external} external, ${chats.length - external} adoptable legacy extension-host, ${suppressedAdoptable} suppressed adoptable legacy extension-host, ${known} already known to Agent Host, ${withoutWorkingDirectory} without a working directory, ${unsupportedClientName} with unsupported or missing client name, ${outsideImportWindow} outside the import window, ${withoutRepository} without repository metadata, ${failed} failed to classify (adopt legacy extension-host chats: ${emitAdoptable})`); - return chats; + }); + for (let i = 0; i < sessions.length; i += COPILOT_DISCOVERY_BATCH_SIZE) { + if (this._shutdownPromise || this._store.isDisposed) { + return true; + } + const mapped = await Promise.all(sessions.slice(i, i + COPILOT_DISCOVERY_BATCH_SIZE).map(classify)); + const chats = mapped.filter((chat): chat is IAgentDiscoveredChat => chat !== undefined); + if (chats.length > 0) { + discovered += chats.length; + external += chats.filter(chat => chat.external).length; + publish(chats); + } + } + this._logService.info(`[Copilot] Chat discovery: ${sessions.length} SDK session(s) -> ${external} external, ${discovered - external} adoptable legacy extension-host, ${suppressedAdoptable} suppressed adoptable legacy extension-host, ${known} already known to Agent Host, ${withoutWorkingDirectory} without a working directory, ${unsupportedClientName} with unsupported or missing client name, ${outsideImportWindow} outside the import window, ${withoutRepository} without repository metadata, ${failed} failed to classify (adopt legacy extension-host chats: ${emitAdoptable})`); + return true; } private async _listSdkSessions(reason: string, listSessions: (client: CopilotClient) => Promise): Promise { @@ -2980,31 +3040,25 @@ export class CopilotAgent extends Disposable implements IAgent { private _readExtensionHostCliMarker(sessionId: string): Promise { let cached = this._extensionHostCliMarkerCache.get(sessionId); if (!cached) { - cached = fs.readFile(this._extensionHostCliSidecarPath(sessionId, 'vscode.metadata.json'), 'utf8') - .then(raw => { - const parsed = JSON.parse(raw) as unknown; - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as IExtensionHostCliMarker : undefined; - }) + cached = fs.readFile(this._extensionHostCliSidecarPath(sessionId, EXTENSION_HOST_CLI_MARKER_FILE), 'utf8') + .then(raw => parseExtensionHostCliMarker(raw)) .catch(() => undefined); this._extensionHostCliMarkerCache.set(sessionId, cached); + // Only a successful read is durable. The extension host may write the + // marker after this probe (a session created while the host is running), + // so memoizing the miss would classify it as non-adoptable until restart. + const pending = cached; + void pending.then(marker => { + if (marker === undefined && this._extensionHostCliMarkerCache.get(sessionId) === pending) { + this._extensionHostCliMarkerCache.delete(sessionId); + } + }); } return cached; } private async _isExtensionHostCliSession(sessionId: string): Promise { - const marker = await this._readExtensionHostCliMarker(sessionId); - if (!marker || Object.keys(marker).length === 0) { - return false; - } - // Mirror the extension host's `getSessionOrigin`: honor an explicit - // `origin` (the GitHub Copilot app writes `other`), else guess `vscode` - // only when older origin-less markers carry VS Code-specific properties. - if (marker.origin !== undefined) { - return marker.origin === EXTENSION_HOST_CLI_MARKER_ORIGIN; - } - return marker.repositoryProperties !== undefined - || marker.worktreeProperties !== undefined - || marker.workspaceFolder !== undefined; + return isExtensionHostCliMarker(await this._readExtensionHostCliMarker(sessionId)); } /** Reads a legacy extension-host Copilot CLI custom title, if present. */ @@ -3013,6 +3067,27 @@ export class CopilotAgent extends Disposable implements IAgent { return typeof title === 'string' && title.trim() ? title : undefined; } + /** + * Working directory recorded in the extension host's own marker, used when the + * SDK reports no `workingDirectory` for a legacy chat. The extension host + * resolves such chats from this same file, so without it they would be dropped + * here and become unreachable once the extension is retired. + */ + private async _extensionHostCliWorkingDirectory(sessionId: string): Promise { + // Adoption is durable and one-way, so never persist a recorded path that no + // longer exists (a deleted worktree is the common case). + for (const candidate of extensionHostCliWorkingDirectoryPaths(await this._readExtensionHostCliMarker(sessionId))) { + try { + if ((await fs.stat(candidate)).isDirectory()) { + return URI.file(candidate); + } + } catch { + // Missing or unreadable; fall through to the next candidate. + } + } + return undefined; + } + /** Adopts a legacy extension-host Copilot CLI session in place when it is eligible on disk. */ async ensureChatAdopted(chat: URI, context: URI | IAgentChatContext): Promise { const session = resolveAgentChatContext(context, chat).configurationResource; @@ -3025,7 +3100,7 @@ export class CopilotAgent extends Disposable implements IAgent { // existence — to avoid falsely treating an empty DB as migrated. const existing = await this._readStoredSessionMetadata(session); if (existing?.workingDirectory) { - return { adopted: false, eligible: false }; // already native / adopted + return { adopted: false, eligible: false, native: true }; // already native / adopted } // Only migrate legacy EH Copilot CLI sessions — never other Copilot SDK // sessions (standalone CLI, Local agent, …) that share `~/.copilot`. @@ -3034,7 +3109,8 @@ export class CopilotAgent extends Disposable implements IAgent { } const client = await this._ensureClient(); const sdkMetadata = await client.getSessionMetadata(sessionId).catch(() => undefined); - const workingDirectory = typeof sdkMetadata?.context?.workingDirectory === 'string' ? URI.file(sdkMetadata.context.workingDirectory) : undefined; + const workingDirectory = (typeof sdkMetadata?.context?.workingDirectory === 'string' ? URI.file(sdkMetadata.context.workingDirectory) : undefined) + ?? await this._extensionHostCliWorkingDirectory(sessionId); if (!workingDirectory) { // An eligible legacy session whose on-disk working directory could not // be resolved: a genuine migration candidate that did not migrate. @@ -3168,7 +3244,6 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - managedSandboxEnabled: this._managedSandboxEnabled, model: provisional.model, longContextWindow: this._longContextWindowFor(provisional.model?.id), freeLongContext: this._isFreeLongContext(provisional.model?.id), @@ -3676,7 +3751,6 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - managedSandboxEnabled: this._managedSandboxEnabled, fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id) }, }; } else if (options.sideChat) { @@ -3706,7 +3780,6 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - managedSandboxEnabled: this._managedSandboxEnabled, fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id) }, }; } else { @@ -3722,7 +3795,6 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - managedSandboxEnabled: this._managedSandboxEnabled, model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id), @@ -4124,7 +4196,6 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - managedSandboxEnabled: this._managedSandboxEnabled, fallback: { model: info.model, longContextWindow: this._longContextWindowFor(info.model?.id), freeLongContext: this._isFreeLongContext(info.model?.id) }, }; agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, { sessionUri: configurationResource, chatChannelUri: chat, resource: context.resource }); @@ -4423,8 +4494,6 @@ export class CopilotAgent extends Disposable implements IAgent { sessionLauncher: this._sessionLauncher, launchPlan, shellManager: launchPlan.shellManager, - managedSandboxEnabled: this._managedSandboxEnabled, - onManagedSettingsResolved: data => this._updateManagedSandbox(data), workingDirectory: launchPlan.workingDirectory, customizationDirectory, clientSnapshot: launchPlan.snapshot, @@ -4596,7 +4665,6 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, - managedSandboxEnabled: this._managedSandboxEnabled, workspaceless: storedMetadata.workspaceless, fallback: { model: storedMetadata.model, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 4f86023d1392f..bf80387d41135 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotSession, CurrentToolMetadata, ElicitationContext, ElicitationFieldValue, ElicitationResult, ElicitationSchema, ElicitationSchemaField, ExitPlanModeCompletedData, ExitPlanModeRequest, ExitPlanModeResult, ManagedSettingsResolvedData, JsonValue, McpServersLoadedServer, MessageOptions, PermissionAllowAllMode, PermissionAutoApproval, PermissionRequest, PermissionRequestResult, PermissionResult, SessionConfig, SessionHooks, SessionMode as CopilotSdkMode, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; +import type { CopilotSession, CurrentToolMetadata, ElicitationContext, ElicitationFieldValue, ElicitationResult, ElicitationSchema, ElicitationSchemaField, ExitPlanModeCompletedData, ExitPlanModeRequest, ExitPlanModeResult, JsonValue, McpServersLoadedServer, MessageOptions, PermissionAllowAllMode, PermissionAutoApproval, PermissionRequest, PermissionRequestResult, PermissionResult, SessionConfig, SessionHooks, SessionMode as CopilotSdkMode, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; import { cp, rm } from 'fs/promises'; import { raceCancellation, RunOnceScheduler, Sequencer, SequencerByKey, Throttler } from '../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; @@ -61,7 +61,7 @@ import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; import { parseLeadingSlashCommand } from '../../common/agentHostSlashCommand.js'; import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; import { NonPtyShellTerminalStreams } from './copilotNonPtyShellTerminals.js'; -import { buildSandboxConfigForSdk, type CopilotSandboxConfig } from './sandboxConfigForSdk.js'; +import { buildSandboxConfigForSdk, type SandboxConfig } from './sandboxConfigForSdk.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getStreamingInvocationMessage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isAgentCoordinationTool, isCopilotSdkToolOutputFile, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, parseCopilotStreamingToolInput, synthesizeSkillToolCall, tryStringify } from './copilotToolDisplay.js'; import { FileEditTracker } from '../shared/fileEditTracker.js'; @@ -381,8 +381,6 @@ export interface ICopilotAgentSessionOptions { readonly sessionLauncher: ICopilotSessionLauncher; readonly launchPlan: CopilotSessionLaunchPlan; readonly shellManager: ShellManager | undefined; - readonly managedSandboxEnabled?: boolean; - readonly onManagedSettingsResolved?: (data: ManagedSettingsResolvedData) => void; /** Working directory associated with the session, used to strip redundant `cd` prefixes from shell commands. */ readonly workingDirectory?: URI; /** Directory used to resolve workspace-scoped customizations for this session. */ @@ -750,7 +748,7 @@ export class CopilotAgentSession extends Disposable { destination: { kind: 'directory', outputDirectory: outputDirectory.fsPath }, include: { events: includeSessionLogs, - processLogs: true, + processLogs: false, shellLogs: includeSessionLogs, }, }); @@ -902,8 +900,6 @@ export class CopilotAgentSession extends Disposable { /** Platform used to compute the SDK sandbox policy (injectable for tests). */ private readonly _platform: NodeJS.Platform; - private _managedSandboxEnabled: boolean | undefined; - private readonly _onManagedSettingsResolved: (data: ManagedSettingsResolvedData) => void; get mcpServerStates() { return this._mcpCustomizations.runtimeStates; @@ -946,9 +942,6 @@ export class CopilotAgentSession extends Disposable { this._isLaunchTokenStillCurrent = options.isLaunchTokenCurrent ?? (() => true); this._onTurnEnded = options.onTurnEnded ?? (() => { }); this._shellManager = options.shellManager; - this._managedSandboxEnabled = options.managedSandboxEnabled; - this._shellManager?.setManagedSandboxEnabled(this._managedSandboxEnabled); - this._onManagedSettingsResolved = options.onManagedSettingsResolved ?? (() => { }); this._nonPtyShellTerminals = this._register(this._instantiationService.createInstance(NonPtyShellTerminalStreams, options.sessionUri)); this._workingDirectory = options.workingDirectory; this._customizationDirectory = options.customizationDirectory; @@ -3193,9 +3186,6 @@ export class CopilotAgentSession extends Disposable { } return this._shellManager.getOrCreateSandboxEngine().isEnabled(); } - if (this._managedSandboxEnabled !== undefined) { - return this._managedSandboxEnabled; - } // SDK-managed shell path: gate on the same host config that // `CopilotSessionLauncher` reads when forwarding `sandboxConfig` to // the SDK, so the two stay in lock-step. @@ -3218,21 +3208,12 @@ export class CopilotAgentSession extends Disposable { * containment) or when the host sandbox config evaluates to disabled * (including on Windows, where the sandbox is not supported). */ - private _computeSdkSandboxConfig(): CopilotSandboxConfig | undefined { + private _computeSdkSandboxConfig(): SandboxConfig | undefined { if (this._isCustomTerminalToolEnabled()) { return undefined; } const sandbox = this._configurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox); - return buildSandboxConfigForSdk(this._platform, sandbox, this._managedSandboxEnabled); - } - - setManagedSandboxEnabled(enabled: boolean | undefined): void { - if (this._managedSandboxEnabled === enabled) { - return; - } - this._managedSandboxEnabled = enabled; - this._shellManager?.setManagedSandboxEnabled(enabled); - void this._applyEffectiveSandboxConfig(); + return buildSandboxConfigForSdk(this._platform, sandbox); } /** @@ -3276,6 +3257,9 @@ export class CopilotAgentSession extends Disposable { } private async _syncPermissionModeAfterConfigChange(): Promise { + if (!this.hasActiveTurn) { + return; + } try { await this.syncPermissionMode('config-change'); await this._applyEffectiveSandboxConfig(true); @@ -3335,18 +3319,15 @@ export class CopilotAgentSession extends Disposable { * * Skips the SDK sandbox entirely when the custom terminal tool is enabled * (the host's own terminal sandbox engine handles containment and the SDK's - * built-in shell is unused). Otherwise it always pushes the effective state - * when sandboxing is locally controlled. When managed enablement is defined, - * the runtime owns the effective configuration and the host sends no local - * sandbox update. + * built-in shell is unused). Otherwise it always pushes the effective state. */ private async _applyEffectiveSandboxConfig(failOnError = false): Promise { - if (this._isCustomTerminalToolEnabled() || this._managedSandboxEnabled !== undefined) { + if (this._isCustomTerminalToolEnabled()) { return; } const sandbox = this._configurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox); - const base = buildSandboxConfigForSdk(this._platform, sandbox, this._managedSandboxEnabled); - const sandboxConfig: CopilotSandboxConfig | { enabled: false } = base ?? { enabled: false }; + const base = buildSandboxConfigForSdk(this._platform, sandbox); + const sandboxConfig: SandboxConfig = base ?? { enabled: false }; try { const result = await this._wrapper.session.rpc.options.update({ sandboxConfig }); if (!result.success) { @@ -5393,7 +5374,6 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onManagedSettingsResolved(e => { this._logService.info(`[Copilot:${sessionId}] Managed settings resolved: source=${e.data.source}, managedKeys=${e.data.managedKeys.join(',') || '(none)'}, bypassPermissionsDisabled=${e.data.bypassPermissionsDisabled}, failClosed=${e.data.failClosed}`); - this._onManagedSettingsResolved(e.data); })); this._register(wrapper.onManagedSettingsEnforced(e => { diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 8bf3c2c62c8ab..cded739b32faa 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -36,7 +36,7 @@ import { isGpt56Model } from './modelIdentifiers.js'; import './prompts/allPrompts.js'; import { agentHostPromptRegistry, type IAgentHostPromptContext } from './prompts/promptRegistry.js'; import { describeSystemMessageConfig } from './prompts/systemMessage.js'; -import { buildSandboxConfigForSdk, type CopilotSandboxConfig } from './sandboxConfigForSdk.js'; +import { buildSandboxConfigForSdk, type SandboxConfig } from './sandboxConfigForSdk.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, agentHostModelSupportsToolSearch } from './toolSearchDeferral.js'; export const ThinkingLevelConfigKey = 'thinkingLevel'; @@ -232,7 +232,6 @@ interface ICopilotSessionLaunchBase { readonly activeClientToolSet: ActiveClientToolSet; readonly shellManager: ShellManager | undefined; readonly githubToken: string | undefined; - readonly managedSandboxEnabled?: boolean; /** * Whether this is a workspace-less session. Threaded into the @@ -535,7 +534,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { async launch(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { const config = await this._buildSessionConfig(plan, runtime); - const sandboxConfig = this._computeSandboxConfig(plan.managedSandboxEnabled); + const sandboxConfig = this._computeSandboxConfig(); if (plan.kind === 'create') { return this._createSession(plan, config, sandboxConfig); } @@ -591,7 +590,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { return this._otelService.withTraceContext(this._otelService.getSessionTraceContext(sessionId, sessionUri), fn); } - private async _createSession(plan: ICopilotCreateSessionLaunchPlan, config: ResumeSessionConfig, sandboxConfig: CopilotSandboxConfig | undefined): Promise { + private async _createSession(plan: ICopilotCreateSessionLaunchPlan, config: ResumeSessionConfig, sandboxConfig: SandboxConfig | undefined): Promise { const raw = await this._withTraceContext(plan.sessionId, () => plan.client.createSession({ ...config, sessionId: plan.sessionId, @@ -605,7 +604,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { return this._finalizeSession(raw, sandboxConfig, plan.sessionId, plan.model?.id); } - private async _finalizeSession(raw: CopilotSessionWrapper['session'], sandboxConfig: CopilotSandboxConfig | undefined, sessionId: string, modelId: string | undefined): Promise { + private async _finalizeSession(raw: CopilotSessionWrapper['session'], sandboxConfig: SandboxConfig | undefined, sessionId: string, modelId: string | undefined): Promise { await this._applySandboxConfig(raw, sandboxConfig, sessionId); // TODO: Remove these post-launch updates once the SDK exposes verbosity and // reasoningSummary in SessionConfig, alongside launch options such as reasoningEffort. @@ -656,12 +655,12 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { * `chat.agent.sandbox.*` settings), mirroring what * `buildSandboxConfigForCLI` does for the Copilot extension's CLI path. */ - private _computeSandboxConfig(managedSandboxEnabled: boolean | undefined): CopilotSandboxConfig | undefined { + private _computeSandboxConfig(): SandboxConfig | undefined { const enableCustomTerminalTool = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableCustomTerminalTool) === true; if (enableCustomTerminalTool) { return undefined; } - return buildSandboxConfigForSdk(process.platform, this._configurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox), managedSandboxEnabled); + return buildSandboxConfigForSdk(process.platform, this._configurationService.getRootValue(sandboxConfigSchema, AgentHostSandboxConfigKey.Sandbox)); } /** @@ -672,7 +671,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { * No-op when {@link _computeSandboxConfig} returned `undefined` (custom * terminal tool enabled, or the host sandbox config evaluates to disabled). */ - private async _applySandboxConfig(session: CopilotSessionWrapper['session'], sandboxConfig: CopilotSandboxConfig | undefined, sessionId: string): Promise { + private async _applySandboxConfig(session: CopilotSessionWrapper['session'], sandboxConfig: SandboxConfig | undefined, sessionId: string): Promise { if (!sandboxConfig) { return; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index 044b07e2c0f2e..433d82b066cc8 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -57,8 +57,6 @@ interface IManagedShell { * the session ends. */ export class ShellManager extends Disposable { - private _managedSandboxEnabled: boolean | undefined; - private readonly _shells = new Map(); private readonly _toolCallShells = new Map(); private _resolvedExecutable: Promise | undefined; @@ -112,10 +110,6 @@ export class ShellManager extends Disposable { return this._resolvedExecutable; } - setManagedSandboxEnabled(enabled: boolean | undefined): void { - this._managedSandboxEnabled = enabled; - } - /** * Lazily constructs the per-session {@link TerminalSandboxEngine}. The engine * is registered for disposal alongside the {@link ShellManager}; its temp dir @@ -132,7 +126,6 @@ export class ShellManager extends Disposable { this._sandboxHelper, sessionId, this.workingDirectory, - () => this._managedSandboxEnabled, ); this._register(engine); this._register(toDisposable(() => { diff --git a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts index bae8d0f5df400..3e4d2ee0f2954 100644 --- a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts +++ b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotSession } from '@github/copilot-sdk'; import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; import { AgentHostSandboxKey, type ISandboxConfigValue } from '../../common/sandboxConfigSchema.js'; @@ -20,31 +19,84 @@ export interface IAgentSandboxFileSystemSetting { denyWrite?: string[]; } -type SdkSandboxConfig = NonNullable[0]['sandboxConfig']>; +/** + * ToDo: This will be removed as the SDK's built-in sandbox configuration types are exported. + */ +export interface SandboxConfig { + /** Whether sandboxing is enabled for the session. */ + enabled: boolean; + + /** Whether all sandbox restrictions can be bypassed. */ + allowBypass?: boolean; + + /** Automatically grant read/write access to the current working directory. */ + addCurrentWorkingDirectory?: boolean; -export type CopilotSandboxConfig = SdkSandboxConfig & { - readonly allowBypass?: boolean; -}; + /** Automatically grant access to common developer tools and caches. */ + allowDevToolAccess?: boolean; -export interface IManagedSandboxSettingsSnapshot { - readonly serverManaged?: boolean; - readonly settings?: unknown; + /** Credential injection available while sandboxing is enabled. */ + auth?: SandboxAuthConfig; + + /** User-defined filesystem, network, and macOS policies. */ + userPolicy?: SandboxUserPolicy; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; +export interface SandboxAuthConfig { + /** Inject credentials for authenticated Git operations. */ + git?: boolean; + + /** Export GH_TOKEN for GitHub CLI operations. */ + gh?: boolean; } -export function getServerManagedSandboxEnabled(snapshot: IManagedSandboxSettingsSnapshot): boolean | undefined { - if (snapshot.serverManaged !== true || !isRecord(snapshot.settings)) { - return undefined; - } - const sandbox = snapshot.settings['sandbox']; - if (!isRecord(sandbox)) { - return undefined; - } - const enabled = sandbox['enabled']; - return typeof enabled === 'boolean' ? enabled : undefined; +export interface SandboxUserPolicy { + filesystem?: SandboxFilesystemPolicy; + network?: SandboxNetworkPolicy; + + /** Only relevant on macOS. */ + seatbelt?: SandboxSeatbeltPolicy; +} + +export interface SandboxFilesystemPolicy { + /** Paths that sandboxed processes can read and write. */ + readwritePaths?: string[]; + + /** Paths that sandboxed processes can only read. */ + readonlyPaths?: string[]; + + /** Paths that sandboxed processes cannot access. */ + deniedPaths?: string[]; + + /** Whether to clear the filesystem policy when the session exits. */ + clearPolicyOnExit?: boolean; +} + +export interface SandboxNetworkPolicy { + /** Whether outbound network connections are permitted. */ + allowOutbound?: boolean; + + /** Whether localhost and local-network connections are permitted. */ + allowLocalNetwork?: boolean; + + /** Optional proxy used by sandboxed processes. */ + proxy?: SandboxNetworkProxyPolicy; +} + +export interface SandboxNetworkProxyPolicy { + /** HTTP or HTTPS proxy URL. */ + url: string; + + /** Optional proxy username. */ + username?: string; + + /** Optional proxy password or secret/environment reference. */ + password?: string; +} + +export interface SandboxSeatbeltPolicy { + /** Whether macOS Keychain access is permitted. */ + keychainAccess?: boolean; } /** @@ -58,9 +110,6 @@ export function getServerManagedSandboxEnabled(snapshot: IManagedSandboxSettings * ON, the AgentHost's own {@link TerminalSandboxEngine} wraps commands and * this function is not consulted. * - * When managed sandbox enablement is defined, the runtime owns the effective - * sandbox configuration and the host must not apply local sandbox settings. - * * Mirrors `buildSandboxConfigForCLI` in * `extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts` * so the two surfaces behave the same: @@ -78,12 +127,7 @@ export function getServerManagedSandboxEnabled(snapshot: IManagedSandboxSettings export function buildSandboxConfigForSdk( platform: NodeJS.Platform, sandbox: ISandboxConfigValue | undefined, - managedEnabled?: boolean, -): CopilotSandboxConfig | undefined { - if (managedEnabled !== undefined) { - return undefined; - } - +): SandboxConfig | undefined { const enabledRaw = platform === 'win32' ? sandbox?.[AgentHostSandboxKey.WindowsEnabled] : sandbox?.[AgentHostSandboxKey.Enabled]; @@ -119,24 +163,28 @@ export function buildSandboxConfigForSdk( } const allowNetwork = sandbox?.[AgentHostSandboxKey.AllowNetwork]; - const allowBypass = sandbox?.[AgentHostSandboxKey.AllowUnsandboxedCommands]; - const filesystem = hasFileSystemPolicy - ? { - ...(denied.size ? { deniedPaths: [...denied] } : {}), - ...(readonly.size ? { readonlyPaths: [...readonly] } : {}), - ...(readwrite.size ? { readwritePaths: [...readwrite] } : {}), - } - : undefined; - const network = typeof allowNetwork === 'boolean' ? { allowOutbound: allowNetwork } : undefined; - const userPolicy = filesystem || network - ? { - ...(filesystem ? { filesystem } : {}), - ...(network ? { network } : {}), - } - : undefined; - return { + const allowBypass = sandbox?.[AgentHostSandboxKey.AllowUnsandboxedCommands] ?? false; + const sandboxConfig: SandboxConfig = { enabled: true, - ...(typeof allowBypass === 'boolean' ? { allowBypass } : {}), - ...(userPolicy ? { userPolicy } : {}), + allowBypass, + addCurrentWorkingDirectory: true, + allowDevToolAccess: true, + auth: { + git: false, + gh: false, + }, + userPolicy: { + filesystem: { + ...(denied.size ? { deniedPaths: [...denied] } : {}), + ...(readonly.size ? { readonlyPaths: [...readonly] } : {}), + ...(readwrite.size ? { readwritePaths: [...readwrite] } : {}), + clearPolicyOnExit: true, + }, + network: { + allowOutbound: typeof allowNetwork === 'boolean' ? allowNetwork : false, + allowLocalNetwork: true, + }, + }, }; + return sandboxConfig; } diff --git a/src/vs/platform/agentHost/node/shared/artifactServerTools.ts b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts new file mode 100644 index 0000000000000..ce14e16bf967f --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/artifactServerTools.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ArtifactServerToolName } from '../../common/serverToolNames.js'; +import { parseSessionArtifactInput, SessionArtifactCollection } from '../../common/sessionArtifactCollection.js'; +import { readSessionArtifacts, SESSION_ARTIFACT_TYPES, SessionArtifactType, withSessionArtifacts, type ISessionArtifact } from '../../common/sessionArtifacts.js'; +import { parseRequiredSessionUriFromChatUri, type ToolDefinition } from '../../common/state/sessionState.js'; +import type { AgentHostStateManager } from '../agentHostStateManager.js'; +import type { IServerToolDisplay, IServerToolExecutionContext, IServerToolGroup } from './agentServerToolHost.js'; + +const addArtifactInputSchema: ToolDefinition['inputSchema'] = { + type: 'object', + properties: { + type: { + type: 'string', + enum: [...SESSION_ARTIFACT_TYPES], + description: 'The kind of artifact. Use `resource` only when no other kind applies.', + }, + label: { type: 'string', description: 'Short label shown to the user.' }, + link: { type: 'string', description: 'URL of the pull request, issue, commit or website. Required for those kinds.' }, + uri: { type: 'string', description: 'URI of the file or resource. Required for the `file` and `resource` kinds.' }, + commitHash: { type: 'string', description: 'The commit hash. Required for the `commit` kind.' }, + createdByThisSession: { type: 'boolean', description: 'Required for the `pullRequest` kind: `true` when this session created the pull request, `false` when it only references an existing one.' }, + }, + required: ['type', 'label'], +}; + +const removeArtifactInputSchema: ToolDefinition['inputSchema'] = { + type: 'object', + properties: { + id: { type: 'string', description: 'The artifact id returned by `add_artifact` or `list_artifacts`.' }, + }, + required: ['id'], +}; + +const listArtifactsInputSchema: ToolDefinition['inputSchema'] = { + type: 'object', + properties: {}, +}; + +export const artifactServerToolDefinitions: ToolDefinition[] = [ + { + name: ArtifactServerToolName.AddArtifact, + title: 'Add Artifact', + description: 'Record something the user will want to open — a pull request, issue, notable commit, website, file or other resource — so it is surfaced next to the chat input.', + inputSchema: addArtifactInputSchema, + annotations: { readOnlyHint: false }, + }, + { + name: ArtifactServerToolName.RemoveArtifact, + title: 'Remove Artifact', + description: 'Remove an artifact from this session by id.', + inputSchema: removeArtifactInputSchema, + annotations: { readOnlyHint: false, destructiveHint: true }, + }, + { + name: ArtifactServerToolName.ListArtifacts, + title: 'List Artifacts', + description: 'List the artifacts recorded on this session, with their ids.', + inputSchema: listArtifactsInputSchema, + annotations: { readOnlyHint: true }, + }, +]; + +/** Host services the artifact tools need beyond the session state. */ +export interface IArtifactServerToolAccessor { + /** Whether the artifact tools are advertised and executable. */ + readonly isEnabled: () => boolean; + /** Persists a session's artifacts so they survive a host restart. */ + readonly persist: (session: string, artifacts: readonly ISessionArtifact[]) => void; +} + +function describeArtifact(artifact: ISessionArtifact): string { + const value = artifact.link ?? artifact.uri ?? artifact.commitHash ?? ''; + return `${artifact.id} (${artifact.type}) ${artifact.label}${value ? ` — ${value}` : ''}`; +} + +/** + * Reads, mutates and republishes the artifacts of the session that owns the + * executing chat. The artifacts live on the session's `_meta` bag, so a change + * reaches subscribed clients through the regular action envelope. + */ +class SessionArtifacts { + + private readonly _session: string; + + constructor( + private readonly _stateManager: AgentHostStateManager, + context: IServerToolExecutionContext, + ) { + this._session = parseRequiredSessionUriFromChatUri(context.chatUri); + } + + read(): SessionArtifactCollection { + return new SessionArtifactCollection(readSessionArtifacts(this._stateManager.getSessionState(this._session)?._meta)); + } + + write(artifacts: readonly ISessionArtifact[], accessor: IArtifactServerToolAccessor): void { + const meta = this._stateManager.getSessionState(this._session)?._meta; + this._stateManager.setSessionMeta(this._session, withSessionArtifacts(meta, artifacts)); + accessor.persist(this._session, artifacts); + } +} + +export function createArtifactServerToolGroup(accessor?: IArtifactServerToolAccessor): IServerToolGroup { + return { + definitions: artifactServerToolDefinitions, + isEnabled(): boolean { + return accessor?.isEnabled() === true; + }, + getDisplay(toolName, args): IServerToolDisplay | undefined { + switch (toolName) { + case ArtifactServerToolName.AddArtifact: { + const label = (args as { label?: unknown } | undefined)?.label; + return typeof label === 'string' && label.length > 0 + ? { displayName: 'Add Artifact', invocationMessage: `Add artifact "${label}"`, pastTenseMessage: `Added artifact "${label}"` } + : { displayName: 'Add Artifact', invocationMessage: 'Add artifact', pastTenseMessage: 'Added artifact' }; + } + case ArtifactServerToolName.RemoveArtifact: + return { displayName: 'Remove Artifact', invocationMessage: 'Remove artifact', pastTenseMessage: 'Removed artifact' }; + case ArtifactServerToolName.ListArtifacts: + return { displayName: 'List Artifacts', invocationMessage: 'List artifacts', pastTenseMessage: 'Listed artifacts' }; + default: + return undefined; + } + }, + execute(stateManager, context, toolName, rawArgs): string { + if (!accessor) { + throw new Error(`${toolName} is unavailable in this host.`); + } + + const artifacts = new SessionArtifacts(stateManager, context); + switch (toolName) { + case ArtifactServerToolName.AddArtifact: { + const input = parseSessionArtifactInput(rawArgs, ArtifactServerToolName.AddArtifact); + const result = artifacts.read().add(input, generateUuid); + if (!result.added) { + return `Artifact already recorded: ${describeArtifact(result.artifact)}`; + } + artifacts.write(result.artifacts, accessor); + return `Added artifact: ${describeArtifact(result.artifact)}`; + } + case ArtifactServerToolName.RemoveArtifact: { + const id = (rawArgs as { id?: unknown } | undefined)?.id; + if (typeof id !== 'string' || id.length === 0) { + throw new Error(`Invalid ${ArtifactServerToolName.RemoveArtifact} input: id must be a non-empty string.`); + } + const result = artifacts.read().remove(id); + if (!result.removed) { + return `No artifact with id ${id}.`; + } + artifacts.write(result.artifacts, accessor); + return `Removed artifact: ${describeArtifact(result.removed)}`; + } + case ArtifactServerToolName.ListArtifacts: { + const current = artifacts.read().artifacts; + return current.length === 0 + ? 'No artifacts recorded for this session.' + : current.map(describeArtifact).join('\n'); + } + default: + throw new Error(`Unknown artifact tool: ${toolName}`); + } + }, + }; +} + +/** + * The instruction appended to every agent's host instructions while the + * artifact tools are enabled. + */ +export const ARTIFACT_TOOLS_INSTRUCTION = `When you produce something the user will want to open — a pull request, an issue, a notable commit, a website, a plan file or another resource — record it once with \`${ArtifactServerToolName.AddArtifact}\` (types: ${SESSION_ARTIFACT_TYPES.join(', ')}; use \`${SessionArtifactType.Resource}\` when nothing else fits). Do not record routine files you merely edited, and do not record every commit you make — record a commit only when the user asked you to commit, or when you found a commit worth showing them, for example while investigating.`; diff --git a/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts b/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts index eed868163d8df..0bb37e2197417 100644 --- a/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts +++ b/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts @@ -9,6 +9,7 @@ import type { ISessionDataService } from '../../common/sessionDataService.js'; export const SESSION_CUSTOM_TITLE_KEY = 'customTitle'; export const SESSION_CUSTOM_TITLE_SOURCE_KEY = 'customTitleSource'; +export const SESSION_ARTIFACTS_KEY = 'sessionArtifacts'; export const AGENT_HOST_TITLE_SOURCE_USER = 'user'; export const AGENT_HOST_TITLE_SOURCE_AGENT = 'agent'; export const AGENT_HOST_TITLE_SOURCE_AUTO = 'auto'; diff --git a/src/vs/platform/agentHost/node/shared/serverToolGroups.ts b/src/vs/platform/agentHost/node/shared/serverToolGroups.ts index 207bfa10a5a26..706f9a3a44dbc 100644 --- a/src/vs/platform/agentHost/node/shared/serverToolGroups.ts +++ b/src/vs/platform/agentHost/node/shared/serverToolGroups.ts @@ -7,6 +7,7 @@ import { feedbackServerToolGroup } from './agentFeedbackServerTools.js'; import { createSessionServerToolGroup, type ISessionServerToolAccessor } from './sessionServerTools.js'; import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolGroup } from './agentServerToolHost.js'; import { createAgentMergeServerToolGroup, type IAgentMergeToolAccessor } from './agentMergeServerTools.js'; +import { createArtifactServerToolGroup, type IArtifactServerToolAccessor } from './artifactServerTools.js'; /** * Builds the server-tool groups contributed to every agent host session, in @@ -24,11 +25,12 @@ import { createAgentMergeServerToolGroup, type IAgentMergeToolAccessor } from '. * When omitted (the pure display path) the session group's `execute` is inert, * but its definitions and display remain available. */ -export function buildServerToolGroups(sessionAccessor?: ISessionServerToolAccessor, agentMergeAccessor?: IAgentMergeToolAccessor): readonly IServerToolGroup[] { +export function buildServerToolGroups(sessionAccessor?: ISessionServerToolAccessor, agentMergeAccessor?: IAgentMergeToolAccessor, artifactAccessor?: IArtifactServerToolAccessor): readonly IServerToolGroup[] { return [ feedbackServerToolGroup, createSessionServerToolGroup(sessionAccessor), createAgentMergeServerToolGroup(agentMergeAccessor), + createArtifactServerToolGroup(artifactAccessor), ]; } diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index eaca142c4452d..8695d93b44a0e 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -47,6 +47,8 @@ const WORKTREE_META_BRANCH = 'copilot.worktree.branchName'; const WORKTREE_META_PATH = 'copilot.worktree.path'; export const WORKTREE_META_REPOSITORY_ROOT = 'copilot.worktree.repositoryRoot'; const WORKTREE_META_CREATION_FAILURE = 'copilot.worktree.creationFailure'; +// TODO@roblourens: Remove after ~November 2026, when pre-July 2026 sessions no longer need their worktree path/root reconstructed from this legacy key. +const LEGACY_WORKTREE_META_WORKING_DIRECTORY = 'copilot.workingDirectory'; const MAX_WORKTREE_FAILURE_DIAGNOSTIC_LENGTH = 200; /** Thrown when a persisted session working directory is missing and cannot be repaired. */ @@ -1043,16 +1045,25 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI } try { - const [branchName, worktreePathRaw, repositoryRootRaw] = await Promise.all([ + const [branchName, worktreePathRaw, repositoryRootRaw, legacyWorkingDirectoryRaw] = await Promise.all([ ref.object.getMetadata(WORKTREE_META_BRANCH), ref.object.getMetadata(WORKTREE_META_PATH), ref.object.getMetadata(WORKTREE_META_REPOSITORY_ROOT), + ref.object.getMetadata(LEGACY_WORKTREE_META_WORKING_DIRECTORY), ]); if (!branchName) { return undefined; } - const worktreePath = worktreePathRaw ? URI.parse(worktreePathRaw) : undefined; - let repositoryRoot = repositoryRootRaw ? URI.parse(repositoryRootRaw) : undefined; + const worktreePath = worktreePathRaw + ? URI.parse(worktreePathRaw) + : legacyWorkingDirectoryRaw + ? URI.parse(legacyWorkingDirectoryRaw) + : undefined; + let repositoryRoot = repositoryRootRaw + ? URI.parse(repositoryRootRaw) + : worktreePath + ? deriveRepositoryRootFromWorktree(worktreePath) + : undefined; if (repositoryRoot) { const checkoutRoot = worktreePath && await fileExists(worktreePath.fsPath) ? worktreePath : repositoryRoot; const primaryRoot = await this._resolvePrimaryWorktreeRoot(checkoutRoot, repositoryRoot); @@ -1131,6 +1142,20 @@ function projectFromRepositoryRoot(repositoryRoot: URI): IAgentSessionProjectInf return { uri: repositoryRoot, displayName: basename(repositoryRoot.fsPath) || repositoryRoot.toString() }; } +function deriveRepositoryRootFromWorktree(worktree: URI): URI | undefined { + if (worktree.scheme !== Schemas.file) { + return undefined; + } + const worktreesRoot = URI.joinPath(worktree, '..'); + const worktreesRootName = basename(worktreesRoot.fsPath); + const suffix = '.worktrees'; + if (!worktreesRootName.endsWith(suffix)) { + return undefined; + } + const repositoryName = worktreesRootName.slice(0, -suffix.length); + return repositoryName ? URI.joinPath(worktreesRoot, '..', repositoryName) : undefined; +} + /** * Builds the repository {@link IAgentSessionProjectInfo} from a persisted * {@link WORKTREE_META_REPOSITORY_ROOT} value (a URI string), or `undefined` diff --git a/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts index 74be7fa87e447..320d48231f030 100644 --- a/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts @@ -46,7 +46,7 @@ suite('AgentHostAuthority - encoding', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('purely alphanumeric address is returned as-is', () => { + test('lowercase alphanumeric address is returned as-is', () => { assert.strictEqual(agentHostAuthority('localhost'), 'localhost'); }); @@ -57,15 +57,16 @@ suite('AgentHostAuthority - encoding', () => { assert.strictEqual(agentHostAuthority('host.name:80'), 'host.name__80'); }); - test('address with underscore falls through to base64', () => { + test('address with uppercase or underscore falls through to hex', () => { + assert.strictEqual(agentHostAuthority('LOCALHOST'), 'hex-4c4f43414c484f5354'); const authority = agentHostAuthority('host_name:8080'); - assert.ok(authority.startsWith('b64-'), `expected base64 for underscore address, got: ${authority}`); + assert.ok(authority.startsWith('hex-'), `expected hex for underscore address, got: ${authority}`); }); - test('address with exotic characters is base64-encoded', () => { - assert.ok(agentHostAuthority('user@host:8080').startsWith('b64-')); - assert.ok(agentHostAuthority('host with spaces').startsWith('b64-')); - assert.ok(agentHostAuthority('http://myhost:3000').startsWith('b64-')); + test('address with exotic characters is hex-encoded', () => { + assert.ok(agentHostAuthority('user@host:8080').startsWith('hex-')); + assert.ok(agentHostAuthority('host with spaces').startsWith('hex-')); + assert.ok(agentHostAuthority('http://myhost:3000').startsWith('hex-')); }); test('ws:// prefix is normalized so authority matches bare address', () => { @@ -86,35 +87,35 @@ suite('AgentHostAuthority - encoding', () => { }, { authority: 'remote_local', normalizedAuthority: 'remote_local', - similarAddressAuthority: 'b64-cmVtb3RlX2xvY2Fs', + similarAddressAuthority: 'hex-72656d6f74655f6c6f63616c', wrappedScheme: AGENT_HOST_SCHEME, wrappedAuthority: 'remote_local', }); }); test('different addresses produce different authorities', () => { - const cases = ['localhost:8080', 'localhost:8081', '192.168.1.1:8080', 'host-name:80', 'host.name:80', 'host_name:80', 'user@host:8080']; + const cases = ['localhost:8080', 'localhost:8081', '192.168.1.1:8080', 'host-name:80', 'host.name:80', 'host_name:80', 'user@host:8080', '_', 'hex-5f', 'HEX-5f']; const results = cases.map(agentHostAuthority); const unique = new Set(results); assert.strictEqual(unique.size, cases.length, 'all authorities must be unique'); }); test('authority is valid in a URI authority position', () => { - const addresses = ['localhost', 'localhost:8081', 'user@host:8080', 'host with spaces', '192.168.1.1:9090']; + const addresses = ['localhost', 'LOCALHOST', 'localhost:8081', 'user@host:8080', 'host with spaces', 'wss://example.com/Path', '192.168.1.1:9090']; for (const address of addresses) { const authority = agentHostAuthority(address); const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority, path: '/test' }); - assert.strictEqual(uri.authority, authority, `authority for '${address}' must round-trip through URI`); + assert.strictEqual(URI.parse(uri.toString()).authority, authority, `authority for '${address}' must round-trip through URI serialization`); } }); test('authority is valid in a URI scheme position', () => { - const addresses = ['localhost', 'localhost:8081', 'user@host:8080', 'host with spaces']; + const addresses = ['localhost', 'LOCALHOST', 'localhost:8081', 'user@host:8080', 'host with spaces', 'wss://example.com/Path']; for (const address of addresses) { const authority = agentHostAuthority(address); const scheme = remoteAgentHostSessionTypeId(authority, 'copilot'); const uri = URI.from({ scheme, path: '/test' }); - assert.strictEqual(uri.scheme, scheme, `scheme for '${address}' must round-trip through URI`); + assert.strictEqual(URI.parse(uri.toString()).scheme, scheme, `scheme for '${address}' must round-trip through URI serialization`); } }); }); diff --git a/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts b/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts new file mode 100644 index 0000000000000..192d86b305bf2 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/sessionArtifacts.test.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { parseSessionArtifactInput, SessionArtifactCollection } from '../../common/sessionArtifactCollection.js'; +import { isGitHubArtifactLink, parseSessionArtifacts, readSessionArtifacts, SessionArtifactType, stringifySessionArtifacts, withSessionArtifacts } from '../../common/sessionArtifacts.js'; + +suite('Session Artifacts', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + let nextId = 0; + const createId = () => `id-${++nextId}`; + + setup(() => { nextId = 0; }); + + test('adds typed artifacts and stamps isGitHub for pull requests and issues', () => { + const collection = new SessionArtifactCollection(); + const pullRequest = collection.add(parseSessionArtifactInput({ type: 'pullRequest', label: 'Fix login', link: 'https://github.com/microsoft/vscode/pull/1', createdByThisSession: true }, 'add_artifact'), createId); + const issue = new SessionArtifactCollection(pullRequest.artifacts).add(parseSessionArtifactInput({ type: 'issue', label: 'Crash', link: 'https://example.com/issues/2' }, 'add_artifact'), createId); + const commit = new SessionArtifactCollection(issue.artifacts).add(parseSessionArtifactInput({ type: 'commit', label: 'Refactor', link: 'https://github.com/microsoft/vscode/commit/abc', commitHash: 'abc123' }, 'add_artifact'), createId); + + assert.deepStrictEqual(commit.artifacts, [ + { id: 'id-1', type: SessionArtifactType.PullRequest, label: 'Fix login', link: 'https://github.com/microsoft/vscode/pull/1', isGitHub: true, createdByThisSession: true }, + { id: 'id-2', type: SessionArtifactType.Issue, label: 'Crash', link: 'https://example.com/issues/2', isGitHub: false }, + { id: 'id-3', type: SessionArtifactType.Commit, label: 'Refactor', link: 'https://github.com/microsoft/vscode/commit/abc', commitHash: 'abc123' }, + ]); + }); + + test('rejects a duplicate value and returns the existing artifact', () => { + const first = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'file', label: 'Plan', uri: 'file:///repo/plan.md' }, 'add_artifact'), createId); + const duplicate = new SessionArtifactCollection(first.artifacts).add(parseSessionArtifactInput({ type: 'file', label: 'Plan again', uri: 'file:///repo/plan.md' }, 'add_artifact'), createId); + + assert.deepStrictEqual({ + added: duplicate.added, + id: duplicate.artifact.id, + count: duplicate.artifacts.length, + }, { + added: false, + id: 'id-1', + count: 1, + }); + }); + + test('removes by id and reports unknown ids', () => { + const added = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'website', label: 'Docs', link: 'https://example.com' }, 'add_artifact'), createId); + const collection = new SessionArtifactCollection(added.artifacts); + + assert.deepStrictEqual({ + removed: collection.remove('id-1').artifacts.length, + unknown: collection.remove('missing').removed, + }, { + removed: 0, + unknown: undefined, + }); + }); + + test('validates required fields per type', () => { + assert.throws(() => parseSessionArtifactInput({ type: 'pullRequest', label: 'No link' }, 'add_artifact'), /link/); + assert.throws(() => parseSessionArtifactInput({ type: 'pullRequest', label: 'No flag', link: 'https://github.com/microsoft/vscode/pull/1' }, 'add_artifact'), /createdByThisSession/); + assert.throws(() => parseSessionArtifactInput({ type: 'file', label: 'No uri' }, 'add_artifact'), /uri/); + assert.throws(() => parseSessionArtifactInput({ type: 'commit', label: 'No hash', link: 'https://example.com' }, 'add_artifact'), /commitHash/); + assert.throws(() => parseSessionArtifactInput({ type: 'unknown', label: 'Bad' }, 'add_artifact'), /type/); + }); + + test('rejects links that are not http(s), since a link is opened externally', () => { + const parse = (link: string) => () => parseSessionArtifactInput({ type: 'website', label: 'Link', link }, 'add_artifact'); + + assert.throws(parse('file:///etc/passwd'), /http\(s\)/); + assert.throws(parse('vscode://extension/evil'), /http\(s\)/); + assert.throws(parse('javascript:alert(1)'), /http\(s\)/); + assert.throws(parse('/not/absolute'), /absolute http\(s\) URL/); + assert.strictEqual(parseSessionArtifactInput({ type: 'website', label: 'Docs', link: 'https://example.com/x' }, 'add_artifact').link, 'https://example.com/x'); + }); + + test('round-trips artifacts through the meta bag and the session database', () => { + const added = new SessionArtifactCollection().add(parseSessionArtifactInput({ type: 'resource', label: 'Dashboard', uri: 'https://example.com/dash' }, 'add_artifact'), createId); + const meta = withSessionArtifacts({ other: 'kept' }, added.artifacts); + + assert.deepStrictEqual({ + meta, + fromMeta: readSessionArtifacts(meta), + fromStorage: parseSessionArtifacts(stringifySessionArtifacts(added.artifacts)), + cleared: withSessionArtifacts(meta, []), + corrupted: parseSessionArtifacts('not json'), + }, { + meta: { other: 'kept', 'agentHost/sessionArtifacts': added.artifacts }, + fromMeta: added.artifacts, + fromStorage: added.artifacts, + cleared: { other: 'kept' }, + corrupted: [], + }); + }); + + test('detects GitHub links', () => { + assert.deepStrictEqual([ + isGitHubArtifactLink('https://github.com/microsoft/vscode/pull/1'), + isGitHubArtifactLink('https://github.contoso.com/org/repo/issues/2'), + isGitHubArtifactLink('https://gitlab.com/org/repo/-/merge_requests/3'), + isGitHubArtifactLink('not a url'), + ], [true, true, false, false]); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts b/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts index 046eccbe6f08c..55493135a313e 100644 --- a/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts +++ b/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts @@ -25,19 +25,16 @@ class FakeStream implements ITunnelDuplexStream { on(_event: 'data', _listener: (data: Uint8Array) => void): this; on(_event: 'error', _listener: (error: Error) => void): this; on(_event: 'close', _listener: (hadError?: boolean) => void): this; - on(_event: 'end' | 'drain' | 'pause' | 'resume', _listener: () => void): this; - on(_event: 'data' | 'error' | 'end' | 'close' | 'drain' | 'pause' | 'resume', _listener: ((data: Uint8Array) => void) | ((error: Error) => void) | ((hadError?: boolean) => void) | (() => void)): this { + on(_event: 'end', _listener: () => void): this; + on(_event: 'data' | 'error' | 'end' | 'close', _listener: ((data: Uint8Array) => void) | ((error: Error) => void) | ((hadError?: boolean) => void) | (() => void)): this { return this; } removeListener(_event: 'data', _listener: (data: Uint8Array) => void): void; removeListener(_event: 'error', _listener: (error: Error) => void): void; removeListener(_event: 'close', _listener: (hadError?: boolean) => void): void; - removeListener(_event: 'end' | 'drain' | 'pause' | 'resume', _listener: () => void): void; - removeListener(_event: 'data' | 'error' | 'end' | 'close' | 'drain' | 'pause' | 'resume', _listener: ((data: Uint8Array) => void) | ((error: Error) => void) | ((hadError?: boolean) => void) | (() => void)): void { - } - - removeAllListeners(_event: 'error'): void { + removeListener(_event: 'end', _listener: () => void): void; + removeListener(_event: 'data' | 'error' | 'end' | 'close', _listener: ((data: Uint8Array) => void) | ((error: Error) => void) | ((hadError?: boolean) => void) | (() => void)): void { } write(_data: string | Uint8Array): boolean { @@ -50,12 +47,6 @@ class FakeStream implements ITunnelDuplexStream { destroy(): void { } - pause(): void { - } - - resume(): void { - } - } class FakeRelayClient implements ITunnelRelayClient { diff --git a/src/vs/platform/agentHost/test/common/webSocketOverDuplex.test.ts b/src/vs/platform/agentHost/test/common/webSocketOverDuplex.test.ts index eb282f943666a..ca006f7e49183 100644 --- a/src/vs/platform/agentHost/test/common/webSocketOverDuplex.test.ts +++ b/src/vs/platform/agentHost/test/common/webSocketOverDuplex.test.ts @@ -5,15 +5,14 @@ import assert from 'assert'; import { EventEmitter } from 'events'; -import { createRequire } from 'module'; +import { VSBuffer } from '../../../../base/common/buffer.js'; import { Event } from '../../../../base/common/event.js'; -import { hasKey } from '../../../../base/common/types.js'; +import { encodeWebSocketFrame, type IWebSocketFrame, WebSocketFrameParser, WebSocketOpcode } from '../../../../base/parts/ipc/common/webSocketFraming.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { connectWebSocketOverDuplex, createWebSocketAccept } from '../../common/webSocketOverDuplex.js'; -import type { ITunnelDuplexStream, IWebSocketDuplexStream, WebSocketConnectionCtor } from '../../common/tunnelMessageSocket.js'; +import { connectWebSocketOverDuplex, createWebSocketAccept, type IWebSocketOverDuplexOptions } from '../../common/webSocketOverDuplex.js'; +import type { ITunnelDuplexStream } from '../../common/tunnelMessageSocket.js'; -const WebSocketConnection = createRequire(import.meta.url)('websocket/lib/WebSocketConnection') as WebSocketConnectionCtor; const websocketAcceptGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; suite('connectWebSocketOverDuplex', () => { @@ -45,36 +44,6 @@ suite('connectWebSocketOverDuplex', () => { ); }); - test('adapts a bare tunnel duplex stream without TCP socket methods', async () => { - const stream = new FakeDuplexStream(); - const socketPromise = connect(stream); - stream.push(await createUpgradeResponse(stream.request)); - const socket = await socketPromise; - const socketLike = stream as Partial; - assert.deepStrictEqual({ - socketCreated: !!socket, - hasSetNoDelay: hasKey(socketLike, { setNoDelay: true }), - hasSetTimeout: hasKey(socketLike, { setTimeout: true }), - hasSetKeepAlive: hasKey(socketLike, { setKeepAlive: true }), - }, { - socketCreated: true, - hasSetNoDelay: false, - hasSetTimeout: false, - hasSetKeepAlive: false, - }); - store.add(socket); - }); - - test('does not recurse when ending a re-entrant tunnel stream', async () => { - const stream = new ReentrantEndDuplexStream(); - const socketPromise = connect(stream); - stream.push(await createUpgradeResponse(stream.request)); - const socket = store.add(await socketPromise); - stream.end(); - - assert.deepStrictEqual({ endCalls: stream.endCalls, socketCreated: !!socket }, { endCalls: 2, socketCreated: true }); - }); - test('rejects a non-101 upgrade response', async () => { const stream = new FakeDuplexStream(); const socketPromise = connect(stream); @@ -100,7 +69,7 @@ suite('connectWebSocketOverDuplex', () => { const stream = new FakeDuplexStream(); const socketPromise = connect(stream); const response = await createUpgradeResponse(stream.request); - stream.push(concat(response, createTextFrame('coalesced'))); + stream.push(concat(response, createFrame('coalesced'))); const socket = store.add(await socketPromise); const message = Event.toPromise(socket.onDidReceiveMessage); @@ -113,18 +82,288 @@ suite('connectWebSocketOverDuplex', () => { stream.push(await createUpgradeResponse(stream.request)); const socket = store.add(await socketPromise); const message = Event.toPromise(socket.onDidReceiveMessage); - stream.push(createTextFrame('round trip')); + stream.push(createFrame('round trip')); assert.deepStrictEqual([await message], ['round trip']); }); -}); -function connect(stream: FakeDuplexStream, path = '/', host?: string) { - return connectWebSocketOverDuplex(stream, { - path, - host, - webSocketConnectionCtor: WebSocketConnection, + test('masks outgoing text frames', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + socket.send('outbound'); + + const [frame] = clientFrames(stream); + assert.deepStrictEqual({ + mask: frame.mask !== undefined, + opcode: frame.opcode, + payload: frame.payload.toString(), + }, { + mask: true, + opcode: WebSocketOpcode.Text, + payload: 'outbound', + }); + }); + + test('assembles fragmented inbound text messages', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const message = Event.toPromise(socket.onDidReceiveMessage); + stream.push(concat( + createFrame('frag', { final: false }), + createFrame('mented', { opcode: WebSocketOpcode.Continuation }), + )); + + assert.strictEqual(await message, 'fragmented'); + }); + + test('replies to pings with a masked pong', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + store.add(await socketPromise); + stream.push(createFrame('keepalive', { opcode: WebSocketOpcode.Ping })); + + const [frame] = clientFrames(stream); + assert.deepStrictEqual({ + mask: frame.mask !== undefined, + opcode: frame.opcode, + payload: frame.payload.toString(), + }, { + mask: true, + opcode: WebSocketOpcode.Pong, + payload: 'keepalive', + }); + }); + + test('acknowledges close frames once and ends the stream', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + let closeCount = 0; + store.add(socket.onDidClose(() => closeCount++)); + const close = Event.toPromise(socket.onDidClose); + stream.push(createCloseFrame(1000, 'done')); + + const [frame] = clientFrames(stream); + assert.deepStrictEqual({ + close: await close, + closeCount, + endCalls: stream.endCalls, + mask: frame.mask !== undefined, + opcode: frame.opcode, + payload: Array.from(frame.payload.buffer), + }, { + close: { code: 1000, reason: 'done' }, + closeCount: 1, + endCalls: 1, + mask: true, + opcode: WebSocketOpcode.Close, + payload: [0x03, 0xe8, 0x64, 0x6f, 0x6e, 0x65], + }); }); + + test('forces the stream closed when the close handshake times out', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream, '/', undefined, { closeTimeoutMs: 1 }); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const close = Event.toPromise(socket.onDidClose); + socket.close(); + + const closed = await close; + const [frame] = clientFrames(stream); + assert.deepStrictEqual({ + error: closed.error?.message.includes('close handshake timed out'), + endCalls: stream.endCalls, + destroyCalls: stream.destroyCalls, + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + error: true, + endCalls: 1, + destroyCalls: 1, + closeCode: 1000, + }); + }); + + test('closes when a frame exceeds the configured payload limit', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream, '/', undefined, { maxFramePayloadLength: 4 }); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const close = Event.toPromise(socket.onDidClose); + stream.push(createFrame('12345')); + + const closed = await close; + const [frame] = clientFrames(stream); + assert.deepStrictEqual({ + error: closed.error?.message.includes('configured limit of 4'), + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + error: true, + closeCode: 1009, + }); + }); + + test('closes when a fragmented message exceeds the configured payload limit', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream, '/', undefined, { maxFramePayloadLength: 4, maxMessagePayloadLength: 5 }); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const close = Event.toPromise(socket.onDidClose); + stream.push(concat( + createFrame('abc', { final: false }), + createFrame('def', { opcode: WebSocketOpcode.Continuation }), + )); + + const closed = await close; + const [frame] = clientFrames(stream); + assert.deepStrictEqual({ + error: closed.error?.message.includes('configured limit of 5'), + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + error: true, + closeCode: 1009, + }); + }); + + test('closes with an error for invalid UTF-8 text', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const close = Event.toPromise(socket.onDidClose); + stream.push(Uint8Array.from([0x81, 0x01, 0xc3])); + + const [frame] = clientFrames(stream); + const closed = await close; + assert.deepStrictEqual({ + error: closed.error?.message.includes('invalid UTF-8'), + endCalls: stream.endCalls, + opcode: frame.opcode, + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + error: true, + endCalls: 1, + opcode: WebSocketOpcode.Close, + closeCode: 1007, + }); + }); + + test('closes with an error for binary messages', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const close = Event.toPromise(socket.onDidClose); + stream.push(encodeWebSocketFrame(VSBuffer.fromString('binary'), { opcode: WebSocketOpcode.Binary }).buffer); + + const [frame] = clientFrames(stream); + const closed = await close; + assert.deepStrictEqual({ + error: closed.error?.message.includes('binary'), + endCalls: stream.endCalls, + opcode: frame.opcode, + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + error: true, + endCalls: 1, + opcode: WebSocketOpcode.Close, + closeCode: 1003, + }); + }); + + test('closes with a protocol error for masked server frames', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const close = Event.toPromise(socket.onDidClose); + stream.push(encodeWebSocketFrame(VSBuffer.fromString('masked'), { opcode: WebSocketOpcode.Text, mask: 0x12345678 }).buffer); + + const frames = clientFrames(stream); + const [frame] = frames; + const closed = await close; + assert.deepStrictEqual({ + error: closed.error?.message.includes('masked WebSocket frame'), + endCalls: stream.endCalls, + outgoingFrames: frames.length, + mask: frame.mask !== undefined, + opcode: frame.opcode, + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + error: true, + endCalls: 1, + outgoingFrames: 1, + mask: true, + opcode: WebSocketOpcode.Close, + closeCode: 1002, + }); + }); + + test('ignores coalesced frames after a protocol failure', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const messages: string[] = []; + let closeCount = 0; + store.add(socket.onDidReceiveMessage(message => messages.push(message))); + store.add(socket.onDidClose(() => closeCount++)); + const close = Event.toPromise(socket.onDidClose); + stream.push(concat( + encodeWebSocketFrame(VSBuffer.fromString('binary'), { opcode: WebSocketOpcode.Binary }).buffer, + createFrame('must not be delivered'), + )); + + const [frame] = clientFrames(stream); + await close; + assert.deepStrictEqual({ + closeCount, + messages, + outgoingFrames: clientFrames(stream).length, + closeCode: frame.payload.readUInt8(0) * 2 ** 8 + frame.payload.readUInt8(1), + }, { + closeCount: 1, + messages: [], + outgoingFrames: 1, + closeCode: 1003, + }); + }); + + test('does not recurse when a re-entrant stream ends during a close reply', async () => { + const stream = new ReentrantEndDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + stream.push(createCloseFrame(1000, 'done')); + + assert.deepStrictEqual({ + endCalls: stream.endCalls, + socketCreated: !!socket, + }, { + endCalls: 1, + socketCreated: true, + }); + }); + + test('does not recurse when disposing a re-entrant tunnel stream', async () => { + const stream = new ReentrantDestroyDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = await socketPromise; + socket.dispose(); + + assert.strictEqual(stream.destroyCalls, 1); + }); +}); + +function connect(stream: FakeDuplexStream, path = '/', host?: string, options: Omit = {}) { + return connectWebSocketOverDuplex(stream, { path, host, ...options }); } async function createUpgradeResponse(request: string): Promise { @@ -148,9 +387,27 @@ function requestKey(request: string): string { return match[1]; } -function createTextFrame(message: string): Uint8Array { - const data = new TextEncoder().encode(message); - return Uint8Array.from([0x81, data.byteLength, ...data]); +function createFrame(message: string, options: { readonly final?: boolean; readonly opcode?: WebSocketOpcode } = {}): Uint8Array { + return encodeWebSocketFrame(VSBuffer.fromString(message), { + final: options.final, + opcode: options.opcode ?? WebSocketOpcode.Text, + }).buffer; +} + +function createCloseFrame(code: number, reason: string): Uint8Array { + const reasonPayload = VSBuffer.fromString(reason); + const payload = VSBuffer.alloc(2 + reasonPayload.byteLength); + payload.writeUInt8(code >>> 8, 0); + payload.writeUInt8(code, 1); + payload.set(reasonPayload, 2); + return encodeWebSocketFrame(payload, { opcode: WebSocketOpcode.Close }).buffer; +} + +function clientFrames(stream: FakeDuplexStream): readonly IWebSocketFrame[] { + const parser = new WebSocketFrameParser(); + return stream.writes + .filter((write): write is Uint8Array => write instanceof Uint8Array) + .flatMap(write => parser.acceptChunk(VSBuffer.wrap(write))); } function concat(...chunks: Uint8Array[]): Uint8Array { @@ -165,6 +422,8 @@ function concat(...chunks: Uint8Array[]): Uint8Array { class FakeDuplexStream extends EventEmitter implements ITunnelDuplexStream { readonly writes: (Uint8Array | string)[] = []; + endCalls = 0; + destroyCalls = 0; private _ended = false; private _destroyed = false; @@ -178,6 +437,7 @@ class FakeDuplexStream extends EventEmitter implements ITunnelDuplexStream { } end(): void { + this.endCalls++; if (!this._ended) { this._ended = true; this.emit('end'); @@ -185,28 +445,28 @@ class FakeDuplexStream extends EventEmitter implements ITunnelDuplexStream { } destroy(): void { + this.destroyCalls++; if (!this._destroyed) { this._destroyed = true; this.emit('close'); } } - pause(): void { - } - - resume(): void { - } - push(chunk: Uint8Array | string): void { - this.emit('data', Buffer.from(chunk)); + this.emit('data', typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk); } } class ReentrantEndDuplexStream extends FakeDuplexStream { - endCalls = 0; - override end(): void { this.endCalls++; this.emit('end'); } } + +class ReentrantDestroyDuplexStream extends FakeDuplexStream { + override destroy(): void { + this.destroyCalls++; + this.emit('close'); + } +} diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 209dae1064fa7..71c6ed558225b 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -1436,7 +1436,7 @@ suite('RemoteAgentHostProtocolClient', () => { assert.strictEqual(await resultPromise, undefined); }); - test('collectDebugLogs accepts an archive that expands beyond the transfer limit', async () => { + test('collectDebugLogs accepts an archive with a larger uncompressed size', async () => { const { client, transport } = createClient(); const resultPromise = client.collectDebugLogs(URI.parse('copilotcli:/session-1'), 'archive'); const entrySize = 10 * 1024 * 1024; @@ -1452,6 +1452,25 @@ suite('RemoteAgentHostProtocolClient', () => { assert.strictEqual((await resultPromise).uncompressedSize, entrySize * 2); }); + test('collectDebugLogs accepts a directory containing 30 MiB of rotated logs', async () => { + const { client, transport } = createClient(); + const resultPromise = client.collectDebugLogs(URI.parse('copilotcli:/session-1'), 'directory'); + const entrySize = 5 * 1024 * 1024; + const entries = Array.from({ length: 6 }, (_, index) => ({ + path: index === 0 ? 'agenthost.log' : `agenthost.${index}.log`, + size: entrySize, + })); + transport.fireMessage({ + jsonrpc: '2.0', id: 1, + result: { + kind: 'directory', resource: 'file:///tmp/agent-host-debug-logs', providerLogsIncluded: true, + size: entrySize * entries.length, uncompressedSize: entrySize * entries.length, entries, + }, + }); + + assert.strictEqual((await resultPromise).uncompressedSize, 30 * 1024 * 1024); + }); + test('collectDebugLogs rejects an unsafe or inconsistent artifact manifest', async () => { const unsafe = createClient(); const unsafeResult = unsafe.client.collectDebugLogs(URI.parse('copilotcli:/session-1'), 'archive'); diff --git a/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts b/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts index be5f5ae89c32d..ae5b866956d2e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts @@ -14,7 +14,7 @@ import { buffer } from '../../../../base/node/zip.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentHostDebugLogsCollector } from '../../node/agentHostDebugLogs.js'; -import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES } from '../../common/agentService.js'; +import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES } from '../../common/agentService.js'; suite('AgentHostDebugLogsCollector', () => { const emptyProvider = { id: 'test', collectDebugLogs: async () => false }; @@ -89,13 +89,10 @@ suite('AgentHostDebugLogsCollector', () => { await assert.rejects(collector.collect([{ id: 'test', collectDebugLogs: async (_session, outputDirectory) => { - // A directory artifact is copied file-by-file, so its total - // uncompressed size is what must stay bounded. No single file can - // exceed the per-file cap, so it takes several to go over. for (let i = 0; i < 3; i++) { const largeLog = join(outputDirectory.fsPath, `large-${i}.log`); await writeFile(largeLog, ''); - await truncate(largeLog, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES - 1); + await truncate(largeLog, Math.floor(AGENT_HOST_DEBUG_LOGS_MAX_BYTES / 2)); } return true; }, @@ -214,7 +211,7 @@ suite('AgentHostDebugLogsCollector', () => { }); }); - test('accepts logs that exceed the transfer limit only before compression', async () => { + test('accepts a large compressible archive', async () => { const logsHome = join(testRoot, 'logs'); const outputRoot = join(testRoot, 'tmp'); await mkdir(logsHome, { recursive: true }); @@ -227,26 +224,24 @@ suite('AgentHostDebugLogsCollector', () => { const result = await collector.collect([{ id: 'test', collectDebugLogs: async (_session, outputDirectory) => { - // Highly compressible, like real log text: together these exceed - // the transfer limit uncompressed while each stays under the - // per-file cap, yet they compress to well under the limit. + // Highly compressible, like real log text. for (let i = 0; i < 3; i++) { - await writeFile(join(outputDirectory.fsPath, `big-${i}.log`), Buffer.alloc(AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES - 1024)); + await writeFile(join(outputDirectory.fsPath, `big-${i}.log`), Buffer.alloc(8 * 1024 * 1024)); } return true; }, }], URI.parse('test:/session-1'), 'archive'); assert.deepStrictEqual({ - uncompressedOverLimit: result.uncompressedSize > AGENT_HOST_DEBUG_LOGS_MAX_BYTES, - archiveUnderLimit: result.size < AGENT_HOST_DEBUG_LOGS_MAX_BYTES, + uncompressedSize: result.uncompressedSize, + archiveUnderLimit: result.size < result.uncompressedSize, }, { - uncompressedOverLimit: true, + uncompressedSize: 24 * 1024 * 1024, archiveUnderLimit: true, }); }); - test('keeps the tail of a file that exceeds the per-file cap', async () => { + test('preserves provider logs larger than 10 MiB', async () => { const logsHome = join(testRoot, 'logs'); const outputRoot = join(testRoot, 'tmp'); await mkdir(logsHome, { recursive: true }); @@ -256,7 +251,7 @@ suite('AgentHostDebugLogsCollector', () => { tmpDir: URI.file(outputRoot), }, new NullLogService())); - const head = Buffer.alloc(AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES, 'A'); + const head = Buffer.alloc(12 * 1024 * 1024, 'A'); const tail = Buffer.from('THE-INTERESTING-END'); const artifact = await collector.collect([{ id: 'test', @@ -268,14 +263,49 @@ suite('AgentHostDebugLogsCollector', () => { const kept = await buffer(artifact.resource.fsPath, 'huge.log'); assert.deepStrictEqual({ - cappedToLimit: kept.length === AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES, + size: kept.length, keptTheTail: kept.subarray(kept.length - tail.length).toString(), }, { - cappedToLimit: true, + size: head.length + tail.length, keptTheTail: 'THE-INTERESTING-END', }); }); + test('includes all rotated Agent Host process logs', async () => { + const logsHome = join(testRoot, 'logs'); + const outputRoot = join(testRoot, 'tmp'); + await mkdir(logsHome, { recursive: true }); + await mkdir(outputRoot, { recursive: true }); + await writeFile(join(logsHome, 'agenthost.log'), 'current'); + await writeFile(join(logsHome, 'agenthost.1.log'), 'previous'); + await writeFile(join(logsHome, 'agenthost.5.log'), 'oldest'); + await writeFile(join(logsHome, 'agenthost-server.log'), 'server current'); + await writeFile(join(logsHome, 'agenthost-server.1.log'), 'server previous'); + await writeFile(join(logsHome, 'agenthost.old.log'), 'not a rotated log'); + const collector = disposables.add(new AgentHostDebugLogsCollector({ + logsHome: URI.file(logsHome), + tmpDir: URI.file(outputRoot), + }, new NullLogService())); + + const artifact = await collector.collect([emptyProvider], URI.parse('test:/session-1'), 'archive'); + + assert.deepStrictEqual({ + paths: artifact.entries.map(entry => entry.path).sort(), + current: (await buffer(artifact.resource.fsPath, 'agenthost.log')).toString(), + previous: (await buffer(artifact.resource.fsPath, 'agenthost.1.log')).toString(), + oldest: (await buffer(artifact.resource.fsPath, 'agenthost.5.log')).toString(), + serverCurrent: (await buffer(artifact.resource.fsPath, 'agenthost-server.log')).toString(), + serverPrevious: (await buffer(artifact.resource.fsPath, 'agenthost-server.1.log')).toString(), + }, { + paths: ['agenthost-server.1.log', 'agenthost-server.log', 'agenthost.1.log', 'agenthost.5.log', 'agenthost.log'], + current: 'current', + previous: 'previous', + oldest: 'oldest', + serverCurrent: 'server current', + serverPrevious: 'server previous', + }); + }); + test('propagates provider collection failures and cleans staging', async () => { const logsHome = join(testRoot, 'logs'); const outputRoot = join(testRoot, 'tmp'); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 651d8485dfc3f..e5b6a84a3d8a2 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -3483,6 +3483,38 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('an adoptable chat retracted by disabling migration is re-surfaced when it is re-enabled', async () => { + const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MockAgent('copilot')); + svc.registerProvider(agent); + svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + await svc.listSessions(); + + const session = AgentSession.uri('copilot', 'toggled-adoptable'); + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); + agent.fireDiscoveredChats([{ ...discoveredChat(session, false), _meta: withSessionEhcliAdoptable(undefined) }]); + for (let i = 0; i < 50 && !svc.stateManager.getSurfacedSessionSummary(session.toString()); i++) { + await timeout(0); + } + const afterFirstEnable = !!svc.stateManager.getSurfacedSessionSummary(session.toString()); + + svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: false }); + await timeout(0); + const whileDisabled = !!svc.stateManager.getSurfacedSessionSummary(session.toString()); + + // Discovery skips chats already in the registry, so re-enabling must restore + // them from the registry rather than waiting for another discovery pass. + svc.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + for (let i = 0; i < 50 && !svc.stateManager.getSurfacedSessionSummary(session.toString()); i++) { + await timeout(0); + } + + assert.deepStrictEqual( + { afterFirstEnable, whileDisabled, afterReEnable: !!svc.stateManager.getSurfacedSessionSummary(session.toString()) }, + { afterFirstEnable: true, whileDisabled: false, afterReEnable: true }, + ); + }); + test('rediscovering a registered chat with different provenance performs no per-session database I/O', async () => { const perSession = createPerSessionDataService(); const svc = disposables.add(new AgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); @@ -6379,6 +6411,48 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(await db.getChatDraft(chat), expected); } + test('waits for initial provider migration before restoring a session', async () => { + class DelayedMigrationAgent extends MockAgent { + readonly migrationGate = new DeferredPromise(); + migrationComplete = false; + metadataCalls = 0; + + override async listChatsToMigrate(): Promise { + await this.migrationGate.p; + this.migrationComplete = true; + return this.listExternalChats(); + } + + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { + this.metadataCalls++; + return this.migrationComplete ? super.getChatMetadata(chat, context) : undefined; + } + } + + const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new DelayedMigrationAgent('copilot')); + const { session } = await createAgentSession(agent); + svc.registerProvider(agent); + + const restore = svc.restoreSession(session); + await timeout(0); + const metadataCallsBeforeMigration = agent.metadataCalls; + agent.migrationGate.complete(); + await restore; + + assert.deepStrictEqual({ + metadataCallsBeforeMigration, + metadataReadAfterMigration: agent.metadataCalls > 0, + registeredSessions: (await svc.getRegisteredSessions()).map(resource => resource.toString()), + restored: !!svc.stateManager.getSessionState(session.toString()), + }, { + metadataCallsBeforeMigration: 0, + metadataReadAfterMigration: true, + registeredSessions: [session.toString()], + restored: true, + }); + }); + test('rejects restoring a session that has been explicitly deleted (tombstoned) without resurrecting it', async () => { const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MockAgent('copilot')); @@ -6397,6 +6471,205 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(svc.stateManager.getSessionState(session.toString()), undefined, 'a rejected restore must not have populated any state'); }); + suite('initial provider migration race (#331648)', () => { + /** Provider whose catalog migration is gated; per-session metadata is unavailable until it completes. */ + class StartupRaceAgent extends MockAgent { + override readonly onDidDiscoverChats = Event.None; + readonly migrationGate = new DeferredPromise(); + sdkReady = false; + catalogAvailable = true; + listChatsToMigrateCalls = 0; + getChatMetadataCalls = 0; + + override async listChatsToMigrate(): Promise { + this.listChatsToMigrateCalls++; + await this.migrationGate.p; + if (!this.catalogAvailable) { + return undefined; + } + this.sdkReady = true; + return []; + } + + override async getChatMetadata(chat: URI, context: URI | IAgentChatContext): Promise { + this.getChatMetadataCalls++; + return this.sdkReady ? super.getChatMetadata(chat, context) : undefined; + } + } + + function makeService(): AgentService { + return disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + } + + function seedSession(agent: MockAgent, session: URI): void { + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(session), session); + } + + async function advanceUntil(predicate: () => boolean): Promise { + for (let i = 0; i < 50 && !predicate(); i++) { + await timeout(0); + } + } + + test('waits for initial provider migration instead of a false SESSION_NOT_FOUND', async () => { + const svc = makeService(); + const agent = disposables.add(new StartupRaceAgent('copilot')); + const session = AgentSession.uri('copilot', 'race-session'); + seedSession(agent, session); + svc.registerProvider(agent); + + let rejected: unknown; + const restore = svc.restoreSession(session).catch(err => { rejected = err; }); + await advanceUntil(() => agent.listChatsToMigrateCalls > 0); + const beforeGate = { + metadataRead: agent.getChatMetadataCalls, + hydrated: !!svc.stateManager.getSessionState(session.toString()), + }; + + agent.migrationGate.complete(); + await restore; + + assert.deepStrictEqual({ + beforeGate, + rejected, + hydratedAfter: !!svc.stateManager.getSessionState(session.toString()), + }, { + beforeGate: { metadataRead: 0, hydrated: false }, + rejected: undefined, + hydratedAfter: true, + }); + }); + + test('re-checks the tombstone after the wait and does not resurrect a deleted session', async () => { + const svc = makeService(); + const agent = disposables.add(new StartupRaceAgent('copilot')); + const session = AgentSession.uri('copilot', 'deleted-during-wait'); + seedSession(agent, session); + svc.registerProvider(agent); + + let rejected: unknown; + const restore = svc.restoreSession(session).catch(err => { rejected = err; }); + await advanceUntil(() => agent.listChatsToMigrateCalls > 0); + await svc.disposeSession(session); + agent.migrationGate.complete(); + await restore; + + assert.deepStrictEqual({ + isProtocolError: rejected instanceof ProtocolError, + code: (rejected as ProtocolError)?.code, + metadataRead: agent.getChatMetadataCalls, + hydrated: !!svc.stateManager.getSessionState(session.toString()), + }, { + isProtocolError: true, + code: AHP_SESSION_NOT_FOUND, + metadataRead: 0, + hydrated: false, + }); + }); + + test('reports a genuinely missing session as not found once migration completes', async () => { + const svc = makeService(); + const agent = disposables.add(new StartupRaceAgent('copilot')); + const session = AgentSession.uri('copilot', 'never-existed'); + svc.registerProvider(agent); + agent.migrationGate.complete(); + + let rejected: unknown; + await svc.restoreSession(session).catch(err => { rejected = err; }); + + assert.deepStrictEqual({ + isProtocolError: rejected instanceof ProtocolError, + code: (rejected as ProtocolError)?.code, + hydrated: !!svc.stateManager.getSessionState(session.toString()), + }, { + isProtocolError: true, + code: AHP_SESSION_NOT_FOUND, + hydrated: false, + }); + }); + + test('reports an unavailable catalog as an internal error, never a false not found', async () => { + const svc = makeService(); + const agent = disposables.add(new StartupRaceAgent('copilot')); + const session = AgentSession.uri('copilot', 'catalog-unavailable'); + seedSession(agent, session); + agent.catalogAvailable = false; + svc.registerProvider(agent); + agent.migrationGate.complete(); + + let rejected: unknown; + await svc.restoreSession(session).catch(err => { rejected = err; }); + + assert.deepStrictEqual({ + isProtocolError: rejected instanceof ProtocolError, + code: (rejected as ProtocolError)?.code, + hydrated: !!svc.stateManager.getSessionState(session.toString()), + }, { + isProtocolError: true, + code: JSON_RPC_INTERNAL_ERROR, + hydrated: false, + }); + }); + + test('reports a known (registered) session whose provider is currently unavailable as internal error, not not-found', async () => { + // Reviewer scenario (#331721): on a backfilled restart the one-time + // migration short-circuits without contacting the provider, so a + // provider that cannot currently describe the session (e.g. Claude + // whose SDK is not downloaded yet) returns `undefined`. Because the + // session is known to the registry, that miss must be transient, not + // the sticky false not-found. + const db = new TransientRegistryWriteDatabase(); + const session = AgentSession.uri('copilot', 'registered-but-unavailable'); + await db.registerSession(session.toString(), { provider: 'copilot', startTime: 1, source: 'restore' }, { checkTombstone: false }); + await db.markProviderBackfilled('copilot'); + const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); + const agent = disposables.add(new StartupRaceAgent('copilot')); + agent.migrationGate.complete(); + svc.registerProvider(agent); + + let rejected: unknown; + await svc.restoreSession(session).catch(err => { rejected = err; }); + + assert.deepStrictEqual({ + isProtocolError: rejected instanceof ProtocolError, + code: (rejected as ProtocolError)?.code, + migrationShortCircuited: agent.listChatsToMigrateCalls === 0, + hydrated: !!svc.stateManager.getSessionState(session.toString()), + }, { + isProtocolError: true, + code: JSON_RPC_INTERNAL_ERROR, + migrationShortCircuited: true, + hydrated: false, + }); + }); + + test('a stalled provider migration does not block restoring a ready provider', async () => { + const svc = makeService(); + const stalled = disposables.add(new StartupRaceAgent('copilot')); + const ready = disposables.add(new MockAgent('claude')); + const session = AgentSession.uri('claude', 'ready-session'); + seedSession(ready, session); + svc.registerProvider(stalled); + svc.registerProvider(ready); + await advanceUntil(() => stalled.listChatsToMigrateCalls > 0); + + await svc.restoreSession(session); + + assert.deepStrictEqual({ + stalledStarted: stalled.listChatsToMigrateCalls > 0, + stalledCompleted: stalled.sdkReady, + hydrated: !!svc.stateManager.getSessionState(session.toString()), + }, { + stalledStarted: true, + stalledCompleted: false, + hydrated: true, + }); + + stalled.migrationGate.complete(); + await svc.listSessions(); + }); + }); + test('restores the AH-owned workspaceless marker onto the summary _meta for any agent', async () => { // The workspace-less marker is owned by the AH service and overlaid on // restore from the central session DB — the agent (MockAgent) re-emits @@ -6861,6 +7134,27 @@ suite('AgentService (node dispatcher)', () => { ); }); + test('does not materialize state for an unregistered chat that is not adoptable', async () => { + // An external chat (e.g. created by the GitHub app) is hidden while + // `showExternalSessions` is `none`, so it is absent from the registered + // list. Restoring it would write `agentSessionData/` and thereby claim + // it away from the extension host's own Copilot CLI list. + class NotAdoptableAgent extends MockAgent { + constructor() { super('copilot'); } + async ensureChatAdopted(_chat: URI, _context: URI | IAgentChatContext): Promise { + return { adopted: false, eligible: false }; + } + } + + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(new TestSessionDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(disposables.add(new NotAdoptableAgent())); + localService.configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + + const session = AgentSession.uri('copilot', 'external-chat'); + await assert.rejects(() => localService.restoreSession(session), /not an adoptable legacy chat/); + assert.strictEqual(localService.stateManager.getSessionState(session.toString()), undefined); + }); + test('a passive read/archive action does not adopt a surfaced legacy session (listing must not migrate)', async () => { // Regression for #330383: a passive read/archive toggle from the sessions list must not restore/adopt an un-opened legacy session. for (const action of [{ type: ActionType.SessionIsReadChanged, isRead: true } as const, { type: ActionType.SessionIsArchivedChanged, isArchived: true } as const]) { @@ -7023,6 +7317,9 @@ suite('AgentService (node dispatcher)', () => { test('coalesces concurrent restores for the same session', async () => { class BlockingRestoreAgent extends MockAgent { + // Disable discovery so only restore drives `getChatMetadata` (discovery's + // reconciliation read is incidental and would race the assertions). + override readonly onDidDiscoverChats = Event.None; readonly metadataReached = new DeferredPromise(); readonly metadataGate = new DeferredPromise(); getChatMetadataCalls = 0; @@ -7059,11 +7356,9 @@ suite('AgentService (node dispatcher)', () => { await Promise.all([firstRestore, secondRestore]); assert.deepStrictEqual({ - metadataCalls: agent.getChatMetadataCalls, messageCalls: agent.getSessionMessagesCalls, restored: !!service.stateManager.getSessionState(session.toString()), }, { - metadataCalls: 1, messageCalls: 1, restored: true, }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 764c3a0fe4954..92fac546cb4ff 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -5552,6 +5552,97 @@ suite('CopilotAgent', () => { await disposeAgent(agent); } }); + /** Discovered chats with the working directory each one resolved to. */ + async function collectDiscoveredWorkingDirectories(agent: CopilotAgent): Promise> { + const discovered: IAgentDiscoveredChat[] = []; + const listener = agent.onDidDiscoverChats(chats => discovered.push(...chats)); + try { + await (agent as unknown as { _startCopilotChatDiscovery(): Promise })._startCopilotChatDiscovery(); + return discovered.map(chat => ({ + id: sessionIdOfChat(chat.chat), + workingDirectory: chat.workingDirectories?.[0]?.fsPath, + })); + } finally { + listener.dispose(); + } + } + + test('recovers a cwd-less legacy chat working directory from the extension-host marker', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/marker-cwd-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/marker-cwd-`); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession('marker-cwd')]); + await writeExtensionHostMarker(userHome, 'marker-cwd', { origin: 'vscode', workspaceFolder: { folderPath: workingDirectory } }); + const { agent } = createTestAgentContext(disposables, { + sessionDataService, + copilotClient: client, + userHome, + rootConfig: { [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }, + }); + try { + assert.deepStrictEqual(await collectDiscoveredWorkingDirectories(agent), [ + { id: 'marker-cwd', workingDirectory: URI.file(workingDirectory).fsPath }, + ]); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('prefers the worktree checkout over the repository root when recovering a legacy chat', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/marker-worktree-home-`)); + const repository = await fs.mkdtemp(`${os.tmpdir()}/marker-repo-`); + const worktree = await fs.mkdtemp(`${os.tmpdir()}/marker-worktree-`); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession('marker-worktree')]); + // A worktree session ran in its checkout; keying off the repository root + // would hide it from a window opened on that worktree. + await writeExtensionHostMarker(userHome, 'marker-worktree', { + origin: 'vscode', + repositoryProperties: { repositoryPath: repository }, + worktreeProperties: { worktreePath: worktree, repositoryPath: repository }, + }); + const { agent } = createTestAgentContext(disposables, { + sessionDataService, + copilotClient: client, + userHome, + rootConfig: { [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }, + }); + try { + assert.deepStrictEqual(await collectDiscoveredWorkingDirectories(agent), [ + { id: 'marker-worktree', workingDirectory: URI.file(worktree).fsPath }, + ]); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(repository, { recursive: true, force: true }); + await fs.rm(worktree, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('a marker written after an initial read miss is picked up without a restart', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/marker-late-home-`)); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); + const isLegacy = (agent as unknown as { _isExtensionHostCliSession(id: string): Promise })._isExtensionHostCliSession.bind(agent); + try { + const beforeMarker = await isLegacy('late-marker'); + await writeExtensionHostMarker(userHome, 'late-marker'); + + // A miss must not be memoized: the extension host can write the marker + // after the probe, and the session would stay non-adoptable until restart. + assert.deepStrictEqual( + { beforeMarker, afterMarker: await isLegacy('late-marker') }, + { beforeMarker: false, afterMarker: true }, + ); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + test('a chat whose database cannot be read is skipped without withholding the rest of the catalog', async () => { const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/corrupt-discovery-home-`)); const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/corrupt-discovery-cwd-`); @@ -10982,7 +11073,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { first, second, configValues }, - { first: { adopted: true, eligible: true }, second: { adopted: false, eligible: false }, configValues: JSON.stringify({ [SessionConfigKey.Isolation]: 'folder' }) }, + { first: { adopted: true, eligible: true }, second: { adopted: false, eligible: false, native: true }, configValues: JSON.stringify({ [SessionConfigKey.Isolation]: 'folder' }) }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); @@ -11224,7 +11315,7 @@ suite('CopilotAgent', () => { assert.deepStrictEqual( { adopted, getSessionMetadataCalls: client.getSessionMetadataCalls, usages }, - { adopted: { adopted: false, eligible: false }, getSessionMetadataCalls: [], usages: [] }, + { adopted: { adopted: false, eligible: false, native: true }, getSessionMetadataCalls: [], usages: [] }, ); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 72efdeba256a6..b0b984a5d4d6e 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -85,6 +85,7 @@ class MockCopilotSession { readonly gitHubCredentialUpdates: Array<{ credentials?: { type: 'token'; host: string; token: string } }> = []; gitHubCredentialUpdateResult = { success: true, copilotUserResolved: true }; gitHubCredentialUpdateError: Error | undefined; + readonly collectLogsCalls: Parameters[0][] = []; readonly experimentalModeUpdates: boolean[] = []; experimentalModeUpdateSuccess = true; sandboxConfigUpdateSuccess = true; @@ -252,6 +253,15 @@ class MockCopilotSession { } readonly rpc = { + debug: { + collectLogs: async (params: Parameters[0]) => { + this.collectLogsCalls.push(params); + const { destination } = params; + return destination.kind === 'directory' + ? { kind: 'directory' as const, path: destination.outputDirectory, entries: [] } + : { kind: 'archive' as const, path: destination.outputPath, entries: [] }; + }, + }, mode: { get: async () => ({ mode: 'interactive' as const }), set: async (params: { mode: 'interactive' | 'plan' | 'autopilot' }) => { @@ -1012,6 +1022,22 @@ suite('CopilotAgentSession', () => { }); }); + test('collects SDK debug logs without process logs', async () => { + const { session, mockSession } = await createAgentSession(disposables); + const outputDirectory = URI.file('/tmp/agent-host-debug'); + + await session.collectDebugLogs(outputDirectory, true); + await session.collectDebugLogs(outputDirectory, false); + + assert.deepStrictEqual(mockSession.collectLogsCalls, [{ + destination: { kind: 'directory', outputDirectory: outputDirectory.fsPath }, + include: { events: true, processLogs: false, shellLogs: true }, + }, { + destination: { kind: 'directory', outputDirectory: outputDirectory.fsPath }, + include: { events: false, processLogs: false, shellLogs: false }, + }]); + }); + suite('CopilotSessionWrapper', () => { test('fires unhandled events when no wrapped listener is registered', () => { const mockSession = new MockCopilotSession(); @@ -4040,7 +4066,7 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(mockSession.sendRequests, []); }); - test('syncs permission mode when the session approval level changes', async () => { + test('defers an idle session approval change until the next turn', async () => { const { session, mockSession, setConfigValue, fireSessionConfigChange } = await createAgentSession(disposables, { configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, }); @@ -4049,8 +4075,16 @@ suite('CopilotAgentSession', () => { fireSessionConfigChange({ [SessionConfigKey.AutoApprove]: 'default' }); await timeout(0); + const beforeTurn = [...mockSession.permissionModeSetCalls]; + await session.send('hello', undefined, 'turn-1'); - assert.deepStrictEqual(mockSession.permissionModeSetCalls, ['auto', 'off']); + assert.deepStrictEqual({ + beforeTurn, + afterTurn: mockSession.permissionModeSetCalls, + }, { + beforeTurn: ['auto'], + afterTurn: ['auto', 'off'], + }); }); test('keeps sandbox enabled when the session approval level changes', async () => { @@ -4098,6 +4132,7 @@ suite('CopilotAgentSession', () => { test('syncs permission mode when root approval configuration changes', async () => { const { session, mockSession, setRootValue, fireRootConfigChange } = await createAgentSession(disposables); await session.syncPermissionMode('turn-start'); + session.resetTurnState('active-turn'); setRootValue(AgentHostGlobalAutoApproveEnabledConfigKey, true); fireRootConfigChange(); @@ -4111,6 +4146,7 @@ suite('CopilotAgentSession', () => { configValues: { [SessionConfigKey.AutoApprove]: 'assisted' }, }); await session.syncPermissionMode('turn-start'); + session.resetTurnState('active-turn'); mockSession.permissionModeSetSuccess = false; setConfigValue(SessionConfigKey.AutoApprove, 'default'); @@ -4123,6 +4159,7 @@ suite('CopilotAgentSession', () => { test('aborts when a live sandbox update fails', async () => { const { session, mockSession, setConfigValue, fireSessionConfigChange } = await createAgentSession(disposables); await session.syncPermissionMode('turn-start'); + session.resetTurnState('active-turn'); mockSession.sandboxConfigUpdateSuccess = false; setConfigValue(SessionConfigKey.AutoApprove, 'autoApprove'); @@ -4193,32 +4230,6 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(mockSession.sandboxConfigUpdates.at(-1), { enabled: false }); }); - test('server-managed sandbox enablement skips host updates and removal restores the local setting', async () => { - const { session, mockSession } = await createAgentSession(disposables); - - session.setManagedSandboxEnabled(true); - await timeout(0); - const managedEnabled = mockSession.sandboxConfigUpdates.at(-1); - - session.setManagedSandboxEnabled(false); - await timeout(0); - const managedDisabled = mockSession.sandboxConfigUpdates.at(-1); - - session.setManagedSandboxEnabled(undefined); - await timeout(0); - const localRestored = mockSession.sandboxConfigUpdates.at(-1); - - assert.deepStrictEqual({ - managedEnabled, - managedDisabled, - localRestored, - }, { - managedEnabled: buildSandboxConfigForSdk('linux', undefined, true), - managedDisabled: undefined, - localRestored: { enabled: false }, - }); - }); - test('per-request sandbox: left untouched when the custom terminal tool is enabled', async () => { const { session, mockSession } = await createAgentSession(disposables, { rootValues: { @@ -4434,6 +4445,7 @@ suite('CopilotAgentSession', () => { const { session: initialSession, mockSession: initialMockSession, setConfigValue: setInitialConfigValue, fireSessionConfigChange: fireInitialSessionConfigChange } = await createAgentSession(disposables, { configValues: { ...configValues } }); await initialSession.syncPermissionMode('turn-start'); + initialSession.resetTurnState('active-turn'); setInitialConfigValue(SessionConfigKey.AutoApprove, 'default'); fireInitialSessionConfigChange({ [SessionConfigKey.AutoApprove]: 'default' }); await timeout(0); @@ -4445,6 +4457,7 @@ suite('CopilotAgentSession', () => { configValues: { ...configValues }, }); await peerSession.syncPermissionMode('turn-start'); + peerSession.resetTurnState('active-turn'); setPeerConfigValue(SessionConfigKey.AutoApprove, 'default'); // Config changes are always emitted keyed by the owning session URI // (the default here), never by this peer chat's own `resource`. diff --git a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts index 2ca7dc5f2883c..28b914f56e6cc 100644 --- a/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts +++ b/src/vs/platform/agentHost/test/node/sandboxConfigForSdk.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentHostSandboxKey, type ISandboxConfigValue } from '../../common/sandboxConfigSchema.js'; import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; -import { buildSandboxConfigForSdk, getServerManagedSandboxEnabled, type CopilotSandboxConfig, type IAgentSandboxFileSystemSetting } from '../../node/copilot/sandboxConfigForSdk.js'; +import { buildSandboxConfigForSdk, type IAgentSandboxFileSystemSetting, type SandboxConfig } from '../../node/copilot/sandboxConfigForSdk.js'; /** * Build the host-side `sandbox` root-config bag (the shape the workbench @@ -54,38 +54,33 @@ function sandbox( } function expectedSandboxConfig(options?: { - hasFileSystemPolicy?: boolean; readwritePaths?: string[]; readonlyPaths?: string[]; deniedPaths?: string[]; allowOutbound?: boolean; allowBypass?: boolean; -}): CopilotSandboxConfig { - const hasFileSystemPolicy = options?.hasFileSystemPolicy === true - || options?.readwritePaths !== undefined - || options?.readonlyPaths !== undefined - || options?.deniedPaths !== undefined; +}): SandboxConfig { return { enabled: true, - ...(options?.allowBypass !== undefined ? { allowBypass: options.allowBypass } : {}), - ...(hasFileSystemPolicy || options?.allowOutbound !== undefined - ? { - userPolicy: { - ...(hasFileSystemPolicy - ? { - filesystem: { - ...(options?.deniedPaths?.length ? { deniedPaths: options.deniedPaths } : {}), - ...(options?.readonlyPaths?.length ? { readonlyPaths: options.readonlyPaths } : {}), - ...(options?.readwritePaths?.length ? { readwritePaths: options.readwritePaths } : {}), - }, - } - : {}), - ...(options?.allowOutbound !== undefined - ? { network: { allowOutbound: options.allowOutbound } } - : {}), - }, - } - : {}), + allowBypass: options?.allowBypass ?? false, + addCurrentWorkingDirectory: true, + allowDevToolAccess: true, + auth: { + git: false, + gh: false, + }, + userPolicy: { + filesystem: { + ...(options?.deniedPaths?.length ? { deniedPaths: options.deniedPaths } : {}), + ...(options?.readonlyPaths?.length ? { readonlyPaths: options.readonlyPaths } : {}), + ...(options?.readwritePaths?.length ? { readwritePaths: options.readwritePaths } : {}), + clearPolicyOnExit: true, + }, + network: { + allowOutbound: options?.allowOutbound === true, + allowLocalNetwork: true, + }, + }, }; } @@ -155,22 +150,6 @@ suite('buildSandboxConfigForSdk', () => { }), undefined); }); - test('server-managed enablement overrides the local setting', () => { - assert.strictEqual(buildSandboxConfigForSdk('linux', undefined, true), undefined); - assert.strictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On), false), undefined); - }); - - test('does not apply local sandbox settings when enablement is server-managed', () => { - const localSandbox: ISandboxConfigValue = { - [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On, - [AgentHostSandboxKey.AllowNetwork]: true, - [AgentHostSandboxKey.AllowUnsandboxedCommands]: true, - [AgentHostSandboxKey.LinuxFileSystem]: { allowWrite: ['/workspace'] }, - }; - - assert.strictEqual(buildSandboxConfigForSdk('linux', localSandbox, true), undefined); - assert.strictEqual(buildSandboxConfigForSdk('linux', localSandbox, false), undefined); - }); }); suite('filesystem policy', () => { @@ -187,24 +166,6 @@ suite('buildSandboxConfigForSdk', () => { assert.deepStrictEqual(buildSandboxConfigForSdk('win32', cfg)?.userPolicy?.filesystem, expectedSandboxConfig({ readwritePaths: ['C:\\windows'] }).userPolicy?.filesystem); }); - suite('getServerManagedSandboxEnabled', () => { - test('returns explicit server-managed sandbox enablement', () => { - assert.deepStrictEqual([ - getServerManagedSandboxEnabled({ serverManaged: true, settings: { sandbox: { enabled: true } } }), - getServerManagedSandboxEnabled({ serverManaged: true, settings: { sandbox: { enabled: false } } }), - ], [true, false]); - }); - - test('ignores non-server-managed, absent, and malformed sandbox values', () => { - assert.deepStrictEqual([ - getServerManagedSandboxEnabled({ serverManaged: false, settings: { sandbox: { enabled: true } } }), - getServerManagedSandboxEnabled({ serverManaged: true, settings: {} }), - getServerManagedSandboxEnabled({ serverManaged: true, settings: { sandbox: { enabled: 'true' } } }), - getServerManagedSandboxEnabled({ serverManaged: true, settings: undefined }), - ], [undefined, undefined, undefined, undefined]); - }); - }); - test('maps each setting to the corresponding SDK list', () => { const fs: IAgentSandboxFileSystemSetting = { allowWrite: ['/work'], @@ -220,7 +181,7 @@ suite('buildSandboxConfigForSdk', () => { }); test('does not add defaults for an empty filesystem policy', () => { - assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, {})), expectedSandboxConfig({ hasFileSystemPolicy: true })); + assert.deepStrictEqual(buildSandboxConfigForSdk('darwin', sandbox('darwin', AgentSandboxEnabledValue.On, {})), expectedSandboxConfig()); }); test('denyRead wins over every other setting for the same path', () => { @@ -265,7 +226,10 @@ suite('buildSandboxConfigForSdk', () => { suite('network hosts', () => { test('drops host lists without adding a network policy', () => { for (const platform of ['darwin', 'linux'] as const) { - assert.strictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['github.com'], blockedHosts: ['evil.example'] }))?.userPolicy?.network, undefined, platform); + assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['github.com'], blockedHosts: ['evil.example'] }))?.userPolicy?.network, { + allowOutbound: false, + allowLocalNetwork: true, + }, platform); } }); @@ -273,12 +237,16 @@ suite('buildSandboxConfigForSdk', () => { for (const platform of ['darwin', 'linux'] as const) { assert.deepStrictEqual(buildSandboxConfigForSdk(platform, sandbox(platform, AgentSandboxEnabledValue.On, undefined, { allowedHosts: ['a.example'], blockedHosts: ['b.example'] }, true))?.userPolicy?.network, { allowOutbound: true, + allowLocalNetwork: true, }, platform); } }); test('ignores empty host lists', () => { - assert.strictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { allowedHosts: [], blockedHosts: [] }))?.userPolicy?.network, undefined); + assert.deepStrictEqual(buildSandboxConfigForSdk('linux', sandbox('linux', AgentSandboxEnabledValue.On, undefined, { allowedHosts: [], blockedHosts: [] }))?.userPolicy?.network, { + allowOutbound: false, + allowLocalNetwork: true, + }); }); }); }); diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index 71072d580b34a..24497ef0e8cb9 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -533,6 +533,25 @@ suite('WorktreeIsolation', () => { }); }); + test('resolveWorkingDirectoryForResume recreates a missing live worktree from legacy metadata', async () => { + const isolation = createIsolation(disposables); + const missingWorktree = URI.joinPath(worktreesRoot, 'missing-legacy-live-worktree'); + await Promise.all([ + db.setMetadata('copilot.worktree.branchName', 'feature/x'), + db.setMetadata('copilot.workingDirectory', missingWorktree.toString()), + ]); + + const resolved = await isolation.resolveWorkingDirectoryForResume(sessionUri, sessionId, missingWorktree); + + assert.deepStrictEqual({ + resolved: resolved.toString(), + recreatedWorktrees: addExistingCalls.map(call => ({ worktree: call.worktree.toString(), branchName: call.branchName })), + }, { + resolved: missingWorktree.toString(), + recreatedWorktrees: [{ worktree: missingWorktree.toString(), branchName: 'feature/x' }], + }); + }); + test('resolveWorkingDirectoryForResume uses the repository root for archived history', async () => { const isolation = createIsolation(disposables); const missingWorktree = URI.joinPath(worktreesRoot, 'missing-archived-worktree'); diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 440fba137a1fe..fd5c10d5fd755 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -41,6 +41,11 @@ export type INativeZipFile = | { readonly path: string; readonly source: URI; readonly size: number } | { readonly sourceArchive: URI }; +export interface INativeZipOptions { + readonly maxSize: number; + readonly maxEntries: number; +} + export interface IOpenAgentsWindowOptions { readonly folderUri?: UriComponents; readonly sessionResource?: UriComponents; @@ -387,7 +392,7 @@ export interface ICommonNativeHostService { * file `source` URI together with the number of leading bytes (`size`) to * stream from it. */ - createZipFile(zipPath: URI, files: INativeZipFile[]): Promise; + createZipFile(zipPath: URI, files: INativeZipFile[], options?: INativeZipOptions): Promise; // Power getSystemIdleState(idleThreshold: number): Promise; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 8a8ab3fb42083..cb912063fe68c 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -27,7 +27,7 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILifecycleMainService, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; -import { FocusMode, ICommonNativeHostService, INativeHostOptions, INativeSystemWideKeybinding, INativeSystemWideKeybindingResult, INativeZipFile, IOpenAgentsWindowOptions, IOSProperties, IOSProxy, IOSProxyConfig, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js'; +import { FocusMode, ICommonNativeHostService, INativeHostOptions, INativeSystemWideKeybinding, INativeSystemWideKeybindingResult, INativeZipFile, INativeZipOptions, IOpenAgentsWindowOptions, IOSProperties, IOSProxy, IOSProxyConfig, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js'; import { IGlobalKeybindingsMainService } from '../../globalKeybindings/electron-main/globalKeybindingsMainService.js'; import { IProductService } from '../../product/common/productService.js'; import { IPartsSplash } from '../../theme/common/themeService.js'; @@ -50,13 +50,10 @@ import { IProxyAuthService } from './auth.js'; import { AuthInfo, Credentials, IRequestService } from '../../request/common/request.js'; import { randomPath } from '../../../base/common/extpath.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; -import { AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES } from '../../agentHost/common/agentService.js'; export interface INativeHostMainService extends AddFirstParameterToFunctions /* only methods, not events */, number | undefined /* window ID */> { } export const INativeHostMainService = createDecorator('nativeHostMainService'); -const MAX_MERGED_ZIP_SIZE = 16 * 1024 * 1024; - export class NativeHostMainService extends Disposable implements INativeHostMainService { declare readonly _serviceBrand: undefined; @@ -1424,7 +1421,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain //#region Zip - async createZipFile(windowId: number | undefined, zipPath: URI, files: INativeZipFile[]): Promise { + async createZipFile(windowId: number | undefined, zipPath: URI, files: INativeZipFile[], options?: INativeZipOptions): Promise { const zipFiles: IFile[] = []; const temporaryDirectories: string[] = []; try { @@ -1441,13 +1438,15 @@ export class NativeHostMainService extends Disposable implements INativeHostMain const temporaryDirectory = join(this.environmentMainService.tmpDir.fsPath, `vscode-zip-merge-${randomPath()}`); temporaryDirectories.push(temporaryDirectory); const archiveSize = (await fs.promises.stat(sourceArchive.fsPath)).size; - if (archiveSize > MAX_MERGED_ZIP_SIZE) { - throw new Error(`ZIP is too large to merge (${archiveSize} bytes; limit ${MAX_MERGED_ZIP_SIZE} bytes)`); + if (options && archiveSize > options.maxSize) { + throw new Error(`ZIP is too large to merge (${archiveSize} bytes; limit ${options.maxSize} bytes)`); + } + if (options) { + await validateZip(sourceArchive.fsPath, { + maxEntries: options.maxEntries, + maxUncompressedSize: options.maxSize, + }); } - await validateZip(sourceArchive.fsPath, { - maxEntries: AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, - maxUncompressedSize: AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES, - }); await extract(sourceArchive.fsPath, temporaryDirectory, {}, CancellationToken.None); zipFiles.push(...await collectZipFiles(temporaryDirectory)); continue; @@ -1460,13 +1459,31 @@ export class NativeHostMainService extends Disposable implements INativeHostMain } const paths = new Set(); + let uncompressedSize = 0; for (const file of zipFiles) { if (paths.has(file.path)) { throw new Error(`Duplicate ZIP entry '${file.path}'`); } paths.add(file.path); + if (file.contents !== undefined) { + uncompressedSize += typeof file.contents === 'string' ? Buffer.byteLength(file.contents) : file.contents.byteLength; + } else if (file.localPath) { + const size = (await fs.promises.stat(file.localPath)).size; + uncompressedSize += file.localPathSize === undefined ? size : Math.min(size, file.localPathSize); + } + if (options && uncompressedSize > options.maxSize) { + throw new Error(`ZIP expands beyond the allowed size (${uncompressedSize} bytes; limit ${options.maxSize} bytes)`); + } + } + if (options && zipFiles.length > options.maxEntries) { + throw new Error(`ZIP contains too many entries (${zipFiles.length}; limit ${options.maxEntries})`); } await zip(zipPath.fsPath, zipFiles); + const zipSize = (await fs.promises.stat(zipPath.fsPath)).size; + if (options && zipSize > options.maxSize) { + await fs.promises.rm(zipPath.fsPath, { force: true }); + throw new Error(`ZIP is too large (${zipSize} bytes; limit ${options.maxSize} bytes)`); + } } finally { await Promise.all(temporaryDirectories.map(directory => Promises.rm(directory))); } diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index f027fec91d7d5..aab69e83fcdee 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -86,7 +86,11 @@ The documentation link follows the migration note so the page reads in decision The two settings are independent: enabling one does not surface the other, and candidates are only scanned for enabled categories, so a disabled migration costs no prompt-file discovery. Each category declares its own `enablementSetting` on its descriptor, so adding a future migration means adding a descriptor rather than touching the editor. -Both pages share the same machinery: search, per-item and per-group selection, independently collapsible groups, opening a file before migrating, deleting an obsolete file, an opt-out for deleting originals, collision-safe target names, and partial-failure reporting. Selection identity includes both URI and storage because one physical file can be configured as both workspace and user storage; the two rows remain independently selectable. Opening a candidate uses the shared `Button` widget around its name and path, leaving the checkbox and delete action as separate keyboard targets. Its accessible name includes both visible labels so same-named files remain distinguishable to screen-reader users. +Both pages share the same machinery: search, per-item and per-group selection, independently collapsible groups, opening a file before migrating, deleting an obsolete file, an opt-out for deleting originals, collision-safe target names, and partial-failure reporting. Candidates are offered only when the active session's harness provides a writable destination folder for that customization type and storage, so harnesses without agent or instruction roots never offer the User Data migration; candidate discovery reruns when the active session changes even if its harness type stays the same. Destination resolution and confirmation remain bound to the initiating session and stop if another session becomes active, while deleting a candidate preserves destination metadata for the remaining rows. Toggling a group checkbox updates its item checkboxes in place so keyboard focus is preserved, and individual selection changes keep the group checkbox synchronized. Selection identity includes both URI and storage because one physical file can be configured as both workspace and user storage; the two rows remain independently selectable. Opening a candidate uses the shared `Button` widget around its name and path, leaving the checkbox and delete action as separate keyboard targets. Its accessible name includes both visible labels so same-named files remain distinguishable to screen-reader users. + +The User Data migration names the resolved destination folder in its banner and confirmation. The banner clarifies that files moved there remain available to both VS Code and the selected harness, and accurately notes that those files are not currently included in Settings Sync without recommending that users commit a broader harness data directory. + +Agent-host component fixtures provide writable source folders for agents, instructions, and skills so migration availability and destination copy are exercised instead of rendering an unsupported-harness empty state. Migration is transactional per source URI. All selected storage identities for one source are copied before the original is deleted once. Targets are created with overwrite disabled and become rollback-owned only after creation succeeds, so a conflicting pre-existing target is preserved. If any target creation or the source deletion fails, every target created by this migration for that source is rolled back, so retrying does not create suffixed duplicates. When a destination type exposes multiple matching roots, migration prompts once for that target and reuses it for every selected file of that type and storage. @@ -381,9 +385,15 @@ All commands and UI respect `ChatContextKeys.enabled`. | Command ID | Purpose | |-----------|---------| -| `aiCustomization.openManagementEditor` | Opens the management editor, optionally accepting an `AICustomizationManagementSection` to deep-link | +| `aiCustomization.openManagementEditor` | Opens the management editor, optionally accepting an `AICustomizationManagementSection` to deep-link, or an object with `section`, `sessionType`, and `revealUri` | | `aiCustomization.openMarketplace` | Opens the management editor with marketplace browse mode active. Accepts an optional section (`mcpServers` or `plugins`); defaults to `mcpServers` | +### Revealing a Specific Customization + +`aiCustomization.openManagementEditor` accepts a `revealUri` alongside `section`, which selects that section and then reveals and selects the row backed by the URI (`AICustomizationManagementEditor.revealCustomizationByUri`). The reveal retries while the list loads, and clears the search box once so a filtered list cannot hide the target. Only prompt-backed sections have URI-addressable rows; for MCP servers and plugins, selecting the section is the whole reveal. + +The customizations pill above the Agents-window chat input is the main consumer: it lists the customizations a chat used or read and reveals the one the user picks. + ## Settings User-facing settings use the `chat.customizations.` namespace. Currently, no settings are exposed for the management editor. diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index a7d71b90df2a0..74a03439e313b 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -206,6 +206,8 @@ A `SessionView` ([browser/parts/sessionView.ts](src/vs/sessions/browser/parts/se The header is centered and capped to 990px via its own CSS class (`.chat-composite-bar.session-header-bar` in [chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css)); `SessionView` measures the header's reported height and lays the chat groups grid out below it. The chat groups grid is laid out at full session width so each group's scrollable viewport (and scrollbar) stays flush to the far-right edge; only the inner chat content (message/input cards, via `.interactive-item-container`, capped to 950px in [browser/media/style.css](src/vs/sessions/browser/media/style.css)) is width-constrained and centered via CSS. The scroll-to-bottom button follows the trailing edge of this centered content column rather than the full-width viewport edge. Each constrained message row is also the positioning context for request overlays such as steering-message actions, keeping those controls anchored to the message instead of the full-width scroll viewport. +Session metadata defaults to a second header row containing workspace, aggregate changes, pull requests, issues, and Chats. When `chat.agentSessions.showSessionMetadataInInput` is enabled, that row is removed: aggregate changes, pull requests, and issues join the horizontally scrollable pill row above the input; Chats moves into the title toolbar with its existing visibility rules; and read-only workspace metadata appears inline after the session title. Last-turn status pills remain available after the turn completes in this placement. The artifacts pill merges the artifacts the agent recorded with the previewable files the session wrote outside its workspace, de-duplicated by resource with the agent's entries winning; a single artifact opens directly, while several collapse into an `N Artifacts` pill whose dropdown groups them by type. Right-clicking the row — on a pill or the empty space beside it — offers the pill visibility menu: `Hide ` for the pill under the cursor, then the kinds the session has data for, then the kinds it does not, separated into those three groups. Changes is never listed because it always shows once it has data. Customizations and Subagents start hidden and are turned on from this menu; choices persist across windows. The pills opt into `allowContextMenu` so the toolbar does not swallow the right-click per item. The customizations pill is chat-scoped rather than session-scoped and always summarizes — one customization still reads `1 Customization` — with a dropdown grouped by customization type that reveals the picked entry in the customizations editor. The shared `ChatPillsWidget` lives in the workbench layer and consumes observable pill descriptors; the artifacts and customizations pills share one `ChatSectionPillActionViewItem` configured by presentation options. Sessions owns the adapters from session state and menus so the workbench layer never imports Sessions. + **Composer clipping.** Monaco measures its host from `clientWidth`, which includes padding. The new-session editor therefore expresses its horizontal inset with margin so its scrollable element remains inside the clipped input surface; the running-session editor's rounded working-state clip extends through the input's trailing padding so the full scrollbar remains visible. **Pitfall:** absolute request overlays must not remain positioned against the full-width `.interactive-session` after message rows are independently constrained. Make the constrained row their positioning context or hover actions drift into the viewport gutter. Request rows must also override the tree's `.monaco-tl-contents { overflow: hidden; }`, otherwise controls positioned above the request are clipped at the row boundary. diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 42788d620f7bb..4a1d9d8768a54 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -210,6 +210,14 @@ A provider exposes: Session catalog events distinguish added, removed, and changed facades. Durable mutable fields remain observable on each facade. +A provider that supersedes another provider's sessions may also expose +`resolveSessionResource`, which redirects a resource to the one that should +actually be opened. Open paths address a session by URI — restored editors and +grid slots, links, and commands all bypass the session list — so filtering the +list is not sufficient to keep a superseded resource from being opened. The hook +must decline unfamiliar resources cheaply, and callers fall back to the original +resource when no provider claims it. + ### Draft creation `createNewSession` and `createQuickChat` return untitled drafts. A draft is not diff --git a/src/vs/sessions/browser/parts/customViewNode.ts b/src/vs/sessions/browser/parts/customViewNode.ts index 91c58b4739c41..4a34e358636f1 100644 --- a/src/vs/sessions/browser/parts/customViewNode.ts +++ b/src/vs/sessions/browser/parts/customViewNode.ts @@ -16,7 +16,7 @@ import { asCssVariable } from '../../../platform/theme/common/colorUtils.js'; import { AGENTS_CENTERED_CONTENT_MAX_WIDTH } from '../../common/layoutConstants.js'; import { activeSessionViewBackground, activeSessionViewForeground } from '../../common/theme.js'; import { AbstractCustomView, ICustomViewDescriptor } from '../../services/customView/browser/customView.js'; -import { SessionHeaderMetaActionViewItem } from './sessionHeaderMetaActionViewItem.js'; +import { ChatPillActionViewItem } from '../../../workbench/browser/chatPills.js'; /** * A leaf of the custom view grid. Owns the shared chrome — a header with the @@ -81,7 +81,7 @@ export class CustomViewNode extends Disposable { toolbarOptions: { primaryGroup: () => true }, actionViewItemProvider: buttonBar ? (action, options) => action instanceof MenuItemAction - ? instantiationService.createInstance(SessionHeaderMetaActionViewItem, undefined, action, options) + ? instantiationService.createInstance(ChatPillActionViewItem, undefined, action, options) : undefined : undefined, })); diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 655338e9ba042..93943c6187a03 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -100,6 +100,45 @@ white-space: nowrap; } +.chat-composite-bar-workspace-meta { + display: inline-flex; + align-items: center; + gap: var(--vscode-spacing-size40); + flex: 0 1 auto; + min-width: 0; + max-width: 40%; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-agents-fontSize-label1); + font-weight: var(--vscode-agents-fontWeight-regular); + white-space: nowrap; +} + +.chat-composite-bar-workspace-meta.hidden { + display: none; +} + +/* Compact glyph at the compact size. The compound selector outranks the base + `.codicon` font shorthand; the clamped box keeps combined glyphs (wider + advance) tight against the label, and the padding optically centers it. */ +.monaco-workbench .chat-composite-bar-workspace-meta-icon.codicon[class*='codicon-'] { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--vscode-codiconFontSize-compact); + height: var(--vscode-codiconFontSize-compact); + margin: 0; + padding: 3px 1px 0 2px; + font-size: var(--vscode-codiconFontSize-compact); + flex-shrink: 0; +} + +.chat-composite-bar-workspace-meta-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* Hover feedback: only when the title can actually be renamed and we aren't currently editing it. */ .chat-composite-bar-session-title.editable { @@ -167,7 +206,7 @@ white-space: nowrap; } -/* Session header meta toolbar: contributed actions render as compact secondary buttons. */ +/* Session header meta toolbar */ .chat-composite-bar-meta-toolbar, .chat-composite-bar-meta-toolbar .monaco-action-bar, .chat-composite-bar-meta-toolbar .actions-container { @@ -183,66 +222,6 @@ gap: 6px; } -.chat-composite-bar-meta-item { - display: inline-flex; - align-items: center; - flex-shrink: 0; -} - -.chat-composite-bar-meta-item.chat-composite-bar-meta-workspace-item { - flex: 1 1 auto; - min-width: 0; -} - -/* Inline, auto-width layout only — the secondary-button sizing/colors come from the standard - `.monaco-text-button.small.secondary` styles. */ -.chat-composite-bar-meta-item .monaco-button.chat-composite-bar-meta-item-button { - display: inline-flex; - width: auto; - gap: 4px; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -.chat-composite-bar-meta-item .monaco-button.chat-composite-bar-meta-workspace-button { - min-width: 0; - max-width: 100%; - overflow: hidden; -} - -.chat-composite-bar-meta-workspace-button .chat-composite-bar-meta-item-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Tighten the focus ring on these compact pills. The base `.monaco-text-button` - uses `outline-offset: 2px`, which on a small pill reads as a bloated ring - detached from the border — hug the border instead. */ -.chat-composite-bar-meta-item .monaco-button.chat-composite-bar-meta-item-button:focus { - outline-offset: 0 !important; -} - -.monaco-workbench .chat-composite-bar-meta-item-icon.codicon[class*='codicon-'] { - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--vscode-codiconFontSize-compact, 12px); - height: var(--vscode-codiconFontSize-compact, 12px); - margin: 0; - font-size: var(--vscode-codiconFontSize-compact, 12px); - flex-shrink: 0; -} - -.chat-composite-bar-meta-added { - color: var(--vscode-chat-linesAddedForeground); -} - -.chat-composite-bar-meta-removed { - color: var(--vscode-chat-linesRemovedForeground); -} - /* Tabs row */ .chat-composite-bar-tabs-row { display: flex; diff --git a/src/vs/sessions/browser/parts/sessionHeader.ts b/src/vs/sessions/browser/parts/sessionHeader.ts index aaae2c4878187..3baf192e26a74 100644 --- a/src/vs/sessions/browser/parts/sessionHeader.ts +++ b/src/vs/sessions/browser/parts/sessionHeader.ts @@ -6,7 +6,7 @@ import './media/chatCompositeBar.css'; import { Disposable, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { $, addDisposableGenericMouseDownListener, addDisposableListener, addStandardDisposableListener, DisposableResizeObserver, EventType, getWindow, isMouseEvent } from '../../../base/browser/dom.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableListener, addStandardDisposableListener, DisposableResizeObserver, EventType, getWindow, isMouseEvent, reset } from '../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; import { IKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; @@ -16,7 +16,6 @@ import { localize } from '../../../nls.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; import { getUntitledSessionTitle } from '../../services/sessions/common/session.js'; -import { ActionRunner, IAction } from '../../../base/common/actions.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; import { MenuItemAction } from '../../../platform/actions/common/actions.js'; @@ -29,37 +28,19 @@ import { applySessionBarThemeColors } from './sessionBarStyles.js'; import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { SessionStatusIcon } from '../sessionStatusIcon.js'; -import { SessionHeaderMetaActionViewItem } from './sessionHeaderMetaActionViewItem.js'; - -/** - * An action runner for the session header toolbars that promotes the header's - * session to be the active session before running any contributed command. This - * ensures commands (e.g. View All Changes) operate on the clicked session even when - * a different session is currently active. - */ -class SessionActivatingActionRunner extends ActionRunner { - - constructor( - private readonly _getSession: () => IActiveSession | undefined, - private readonly _sessionsService: ISessionsService, - ) { - super(); - } - - protected override async runAction(action: IAction, context?: unknown): Promise { - const session = this._getSession(); - if (session) { - this._sessionsService.setActive(session); - } - await super.runAction(action, context); - } -} +import { ChatPillActionViewItem } from '../../../workbench/browser/chatPills.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { observableConfigValue } from '../../../platform/observable/common/platformObservableUtils.js'; +import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../common/sessionConfig.js'; +import { getSessionWorkspaceDisplayInfo } from '../sessionWorkspace.js'; +import { ThemeIcon } from '../../../base/common/themables.js'; +import { IHoverService } from '../../../platform/hover/browser/hover.js'; +import { SessionActivatingActionRunner } from '../sessionActionRunner.js'; /** * The session header shown at the top of a session view. It surfaces the session - * identity (status icon + title), a meta row (contributed workspace folder / - * changes / pull request pills), and the session toolbars (e.g. Run, Open in - * VS Code, New Chat). + * identity, optional workspace metadata, contributed metadata pills, and the + * session toolbars. * * It is intentionally decoupled from the {@link ChatCompositeBar} (the chat tab * strip) so the two surfaces evolve independently. The hosting view tells the @@ -71,6 +52,7 @@ export class SessionHeader extends Disposable { private readonly _iconEl: HTMLElement; private readonly _titleEl: HTMLElement; private readonly _titleTextEl: HTMLElement; + private readonly _workspaceMetaEl: HTMLElement; private readonly _metaRow: HTMLElement; private readonly _toolbar: MenuWorkbenchToolBar; private readonly _metaToolbar: MenuWorkbenchToolBar; @@ -96,6 +78,8 @@ export class SessionHeader extends Disposable { private readonly _sessionTransfer = LocalSelectionTransfer.getInstance(); private readonly _metaActionsSignal: IObservable; + private readonly _showMetadataInChatInput: IObservable; + private readonly _workspaceHover = this._register(new MutableDisposable()); private readonly _statusIcon: SessionStatusIcon; @@ -118,9 +102,12 @@ export class SessionHeader extends Disposable { @IContextKeyService private readonly _contextKeyService: IContextKeyService, @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly _sessionsService: ISessionsService, + @IConfigurationService configurationService: IConfigurationService, + @IHoverService private readonly _hoverService: IHoverService, ) { super(); + this._showMetadataInChatInput = observableConfigValue(SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING, false, configurationService); this._container = $('.chat-composite-bar.session-header-bar'); // Header: a status icon column alongside a main column that stacks the title @@ -148,6 +135,9 @@ export class SessionHeader extends Disposable { this._titleTextEl = $('span.chat-composite-bar-session-title-text'); this._titleEl.appendChild(this._titleTextEl); + this._workspaceMetaEl = $('.chat-composite-bar-workspace-meta'); + titleRow.appendChild(this._workspaceMetaEl); + // Click the title to start an inline rename. Click is preferred over // mousedown so that initiating a drag from the title doesn't also // flip into edit mode. @@ -179,7 +169,7 @@ export class SessionHeader extends Disposable { // diff-stats action (opens the multi-file diff editor) and the GitHub // contribution contributes the pull request pill (opens the PR on GitHub), // each rendered as a compact secondary button pill via - // SessionHeaderMetaActionViewItem. + // ChatPillActionViewItem. const metaToolbarContainer = $('.chat-composite-bar-meta-toolbar'); this._metaRow.appendChild(metaToolbarContainer); // Commands contributed into the header meta toolbar (e.g. View All Changes) @@ -195,7 +185,7 @@ export class SessionHeader extends Disposable { // registers its own action view item via IActionViewItemService. actionViewItemProvider: (action, options) => { if (action instanceof MenuItemAction) { - return instantiationService.createInstance(SessionHeaderMetaActionViewItem, undefined, action, options); + return instantiationService.createInstance(ChatPillActionViewItem, undefined, action, options); } return undefined; }, @@ -330,13 +320,29 @@ export class SessionHeader extends Disposable { const isQuickChat = session.isQuickChat?.read(reader) ?? false; this._titleTextEl.textContent = session.title.read(reader) || getUntitledSessionTitle(isQuickChat); this._titleEl.classList.toggle('editable', this._isTitleEditable()); + const showMetadataInChatInput = this._showMetadataInChatInput.read(reader); + const workspaceInfo = showMetadataInChatInput && !isQuickChat ? getSessionWorkspaceDisplayInfo(session, reader) : undefined; + this._workspaceMetaEl.classList.toggle('hidden', !workspaceInfo); + this._workspaceHover.clear(); + if (workspaceInfo) { + const label = $('span.chat-composite-bar-workspace-meta-label', undefined, workspaceInfo.label); + reset( + this._workspaceMetaEl, + $('span.chat-composite-bar-workspace-meta-separator', { 'aria-hidden': 'true' }, '·'), + $(`span.chat-composite-bar-workspace-meta-icon${ThemeIcon.asCSSSelector(workspaceInfo.icon)}`, { 'aria-hidden': 'true' }), + label, + ); + this._workspaceHover.value = this._hoverService.setupDelayedHover(label, { content: workspaceInfo.label }); + } else { + reset(this._workspaceMetaEl); + } // Meta row: contributed action pills (workspace folder · diff stats · pull request). // Reading the signal re-runs this on menu changes. this._metaActionsSignal.read(reader); const hasMetaActions = !this._metaToolbar.isEmpty(); - this._metaRow.style.display = hasMetaActions ? '' : 'none'; + this._metaRow.style.display = !showMetadataInChatInput && hasMetaActions ? '' : 'none'; this._onDidChangeHeight.fire(); } diff --git a/src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts b/src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts deleted file mode 100644 index d273f26f513d3..0000000000000 --- a/src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts +++ /dev/null @@ -1,173 +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 { $, addDisposableListener, EventType, reset } from '../../../base/browser/dom.js'; -import { BaseActionViewItem, IActionViewItemOptions } from '../../../base/browser/ui/actionbar/actionViewItems.js'; -import { Button } from '../../../base/browser/ui/button/button.js'; -import { IAction } from '../../../base/common/actions.js'; -import { isMacintosh } from '../../../base/common/platform.js'; -import { defaultButtonStyles } from '../../../platform/theme/browser/defaultStyles.js'; - -/** - * Renders an action contributed into the session header meta row ({@link Menus.SessionHeaderMeta}) - * as a secondary {@link Button} with an inline `icon title` label so every contributed action reads - * consistently. Used as the default rendering for meta actions that don't register their own - * action view item. - * - * Subclasses can override {@link getLabelText} (e.g. the pull request `#`) or append dynamic - * content via {@link getAdditionalLabelContent} (e.g. the changes diff stats), calling - * {@link updateLabel} when it changes. - */ -export class SessionHeaderMetaActionViewItem extends BaseActionViewItem { - - protected button: Button | undefined; - - constructor(context: unknown, action: IAction, options: IActionViewItemOptions) { - super(context, action, options); - } - - override render(container: HTMLElement): void { - this.element = container; - container.classList.add('chat-composite-bar-meta-item'); - - const button = this.button = this._register(new Button(container, { secondary: true, small: true, ...defaultButtonStyles })); - button.element.classList.add('monaco-text-button', 'chat-composite-bar-meta-item-button'); - this._register(addDisposableListener(button.element.ownerDocument.body, EventType.MOUSE_DOWN, event => { - if (event.button === 0 && (!isMacintosh || !event.ctrlKey) && this.hasOpenDropdown() && button.element.contains(event.target as Node | null)) { - event.stopPropagation(); - } - })); - this._register(button.onDidClick(() => { - if (this._action.enabled) { - this.onDidClickButton(); - } - })); - - this.updateLabel(); - this.updateEnabled(); - this.updateTooltip(); - } - - /** - * Whether this item currently owns an open dropdown. - */ - protected hasOpenDropdown(): boolean { - return false; - } - - /** - * Invoked when the pill is activated. Runs the action by default; subclasses can - * override to present their own affordance (e.g. a picker when the pill stands - * for several items). - */ - protected onDidClickButton(): void { - this.actionRunner.run(this._action, this._context); - } - - override focus(): void { - this.button?.focus(); - } - - override blur(): void { - if (this.button) { - this.button.element.tabIndex = -1; - this.button.element.blur(); - } - } - - override setFocusable(focusable: boolean): void { - if (this.button) { - this.button.element.tabIndex = focusable ? 0 : -1; - } - } - - override isFocused(): boolean { - return !!this.button?.hasFocus(); - } - - protected override updateClass(): void { - this.updateLabel(); - } - - protected override updateEnabled(): void { - if (this.button) { - this.button.enabled = this._action.enabled; - } - } - - protected override updateLabel(): void { - if (!this.button) { - return; - } - reset(this.button.element, ...this.getLabelContent()); - } - - protected override updateAriaLabel(): void { - const ariaLabel = this.getAriaLabel(); - if (ariaLabel) { - this.button?.element.setAttribute('aria-label', ariaLabel); - } else { - this.button?.element.removeAttribute('aria-label'); - } - } - - /** - * The button's accessible name. Defaults to {@link getTooltip}. Subclasses that render - * meaningful state in the visible label (e.g. the workspace name, or diff counts) should - * override this so screen readers announce the same information that is shown visually. - */ - protected getAriaLabel(): string | undefined { - return this.getTooltip(); - } - - protected override getTooltip(): string | undefined { - // `MenuItemAction.tooltip` defaults to '' when not provided, which would - // leave the pill without a managed hover and an empty aria-label. Fall - // back to the action label so the pill is always labelled. - return this._action.tooltip || this._action.label || undefined; - } - - private getLabelContent(): Array { - const content: Array = []; - - const iconElement = this.getIconElement(); - if (iconElement) { - content.push(iconElement); - } - - const labelText = this.getLabelText(); - if (labelText) { - content.push($('span.chat-composite-bar-meta-item-label', undefined, labelText)); - } - - content.push(...this.getAdditionalLabelContent()); - return content; - } - - /** - * The leading icon element. Defaults to the action's icon (without color). - */ - protected getIconElement(): HTMLElement | undefined { - const iconClasses = this._action.class?.split(' ').filter(cssClass => !!cssClass); - if (!iconClasses?.length) { - return undefined; - } - return $(`span.chat-composite-bar-meta-item-icon${iconClasses.map(cssClass => `.${cssClass}`).join('')}`); - } - - /** - * The button's title text. Defaults to the action label. - */ - protected getLabelText(): string { - return this._action.label; - } - - /** - * Additional label content rendered after the title. Defaults to none. - */ - protected getAdditionalLabelContent(): Array { - return []; - } -} diff --git a/src/vs/sessions/browser/sessionActionRunner.ts b/src/vs/sessions/browser/sessionActionRunner.ts new file mode 100644 index 0000000000000..6612387bc7aec --- /dev/null +++ b/src/vs/sessions/browser/sessionActionRunner.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ActionRunner, IAction } from '../../base/common/actions.js'; +import { ISessionsService } from '../services/sessions/browser/sessionsService.js'; +import { IActiveSession } from '../services/sessions/common/sessionsManagement.js'; + +/** Activates the originating session before running a session-scoped action. */ +export class SessionActivatingActionRunner extends ActionRunner { + + constructor( + private readonly _getSession: () => IActiveSession | undefined, + private readonly _sessionsService: ISessionsService, + ) { + super(); + } + + protected override async runAction(action: IAction, context?: unknown): Promise { + const session = this._getSession(); + if (session) { + this._sessionsService.setActive(session); + } + await super.runAction(action, context); + } +} diff --git a/src/vs/sessions/browser/sessionWorkspace.ts b/src/vs/sessions/browser/sessionWorkspace.ts new file mode 100644 index 0000000000000..a23cc17e24c50 --- /dev/null +++ b/src/vs/sessions/browser/sessionWorkspace.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../base/common/codicons.js'; +import { IReader } from '../../base/common/observable.js'; +import { ThemeIcon } from '../../base/common/themables.js'; +import { getSessionWorkspaceKind, ISession, SessionWorkspaceKind } from '../services/sessions/common/session.js'; + +export interface ISessionWorkspaceDisplayInfo { + readonly label: string; + readonly icon: ThemeIcon; + readonly workingDirectoryPath: string | undefined; + readonly branch: string | undefined; + readonly worktreePending: boolean; +} + +/** Returns the workspace presentation shared by the session header and Files pill. */ +export function getSessionWorkspaceDisplayInfo(session: ISession | undefined, reader: IReader): ISessionWorkspaceDisplayInfo | undefined { + const workspace = session?.workspace.read(reader); + if (!workspace?.label) { + return undefined; + } + + const worktreePending = session?.worktreePending?.read(reader) ?? false; + const kind = getSessionWorkspaceKind(workspace, worktreePending); + const icon = workspace.typeIcon ?? (kind === SessionWorkspaceKind.Virtual ? Codicon.cloudCompact : kind === SessionWorkspaceKind.Folder ? Codicon.folderCompact : Codicon.worktreeCompact); + const folder = workspace.folders[0]; + const branch = worktreePending ? undefined : folder?.gitRepository?.branchName?.trim() || undefined; + const workingDirectoryPath = worktreePending ? undefined : folder?.workingDirectory.fsPath; + return { label: workspace.label, icon, workingDirectoryPath, branch, worktreePending }; +} diff --git a/src/vs/sessions/common/sessionConfig.ts b/src/vs/sessions/common/sessionConfig.ts index deda8edc5edf2..09d79fe367365 100644 --- a/src/vs/sessions/common/sessionConfig.ts +++ b/src/vs/sessions/common/sessionConfig.ts @@ -13,6 +13,8 @@ import type { ResolveSessionConfigResult } from '../../platform/agentHost/common */ export const DOCK_DETAIL_PANEL_SETTING = 'sessions.layout.singlePaneDetailPanel'; +export const SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING = 'chat.agentSessions.showSessionMetadataInInput'; + export function isSessionConfigComplete(config: ResolveSessionConfigResult): boolean { return (config.schema.required ?? []).every(property => config.values[property] !== undefined); } diff --git a/src/vs/sessions/contrib/changes/browser/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index 65891d05b4cd1..c42c3fc1b3bd5 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -26,7 +26,7 @@ import { MultiDiffEditor } from '../../../../workbench/contrib/multiDiffEditor/b import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/diffEditorWidget.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; -import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; +import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; import { IsQuickChatSessionContext, SessionHasChangesContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; @@ -235,7 +235,7 @@ interface IDiffStats { /** * Renders the {@link ViewAllChangesAction} menu item contributed into {@link Menus.SessionHeaderMeta} * (the session header meta row) as a ` files +insertions -deletions` pill. It extends the - * generic {@link SessionHeaderMetaActionViewItem} (so the icon and label render consistently with other + * generic {@link ChatPillActionViewItem} (so the icon and label render consistently with other * meta actions) and appends the session's live aggregate diff stats. Activating the item runs the * action, which opens the multi-file diff editor. * @@ -245,7 +245,7 @@ interface IDiffStats { * changeset the provider marks as {@link ISessionChangeset.isDefault} (or the session's * top-level {@link IActiveSession.changes} when none is default). */ -export class ViewAllChangesActionViewItem extends SessionHeaderMetaActionViewItem { +export class ViewAllChangesActionViewItem extends ChatPillActionViewItem { private readonly _diffStatsObs: IObservable; @@ -309,8 +309,8 @@ export class ViewAllChangesActionViewItem extends SessionHeaderMetaActionViewIte protected override getAdditionalLabelContent(): Array { const { insertions, deletions } = this._diffStatsObs.get(); return [ - $('span.chat-composite-bar-meta-added', undefined, `+${insertions}`), - $('span.chat-composite-bar-meta-removed', undefined, `-${deletions}`), + $('span.chat-pill-added', undefined, `+${insertions}`), + $('span.chat-pill-removed', undefined, `-${deletions}`), ]; } diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index aaeea6145e50a..745aeeb2b0e4b 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -9,6 +9,7 @@ import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import product from '../../../../platform/product/common/product.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISessionsManagementService, inheritableSessionTarget } from '../../../services/sessions/common/sessionsManagement.js'; @@ -47,6 +48,7 @@ import { Menus } from '../../../browser/menus.js'; import { ISessionsChatViewStateService, SessionsChatViewStateService } from './chatViewStateService.js'; import { SessionsChatResponseFileChangesService } from './sessionTurnChanges.js'; import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; +import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../../common/sessionConfig.js'; class NewChatInSessionsWindowAction extends Action2 { @@ -149,5 +151,11 @@ Registry.as(ConfigurationExtensions.Configuration).regis scope: ConfigurationScope.APPLICATION, description: localize('chat.agentSessions.scopedInputHistory', "Controls whether chat input history in the Agents Window is scoped to the current session. Disable this to use shared input history across sessions."), }, + [SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING]: { + type: 'boolean', + default: product.quality !== 'stable', + scope: ConfigurationScope.APPLICATION, + description: localize('chat.agentSessions.showSessionMetadataInInput', "Controls whether session metadata such as changes, pull requests, and issues appears above the chat input instead of in the session header."), + }, }, }); diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionActivityPill.css b/src/vs/sessions/contrib/chat/browser/media/sessionActivityPill.css deleted file mode 100644 index 56cd36a24ae96..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/media/sessionActivityPill.css +++ /dev/null @@ -1,41 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* Several pills can share the row above the input (browsers, background - activities, turn status), so a pill shrinks below its content and ellipsizes - its label rather than pushing its neighbours out of the row. The cap keeps a - single long label from crowding out the other pills when there is room. */ -.session-activity-pill { - display: inline-flex; - flex: 0 1 auto; - min-width: 0; -} - -.session-activity-pill.hidden { - display: none; -} - -.session-activity-pill .session-activity-pill-button { - display: inline-flex; - width: fit-content; - min-width: 0; - max-width: 280px; - gap: var(--vscode-spacing-size40); - overflow: hidden; - white-space: nowrap; - touch-action: manipulation; -} - -.session-activity-pill .session-activity-pill-button > span:not(.codicon) { - min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} - -.session-activity-pill .session-activity-pill-button .codicon { - font-size: var(--vscode-codiconFontSize-compact); - flex-shrink: 0; -} diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css b/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css index 431c40ba33696..82b0df92352d3 100644 --- a/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css +++ b/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css @@ -3,25 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* Floating status pills centered above the chat input. The pills themselves are - the shared `.chat-turn-pills` widget (styled in chatTurnPills.css) and the - session activity pills (sessionActivityPill.css); this file only positions and - centers them above the input. */ +/* Horizontally scrollable status pills above the chat input. */ .session-chat-input-toolbar { + width: 100%; + min-width: 0; +} + +.session-chat-input-toolbar-content { display: flex; align-items: center; - justify-content: center; + justify-content: flex-start; gap: var(--vscode-spacing-size60); + width: 100%; min-width: 0; + box-sizing: border-box; padding: var(--vscode-spacing-size20) 0 var(--vscode-spacing-size60) 0; } -/* The turn pills size to their content and deliberately don't shrink internally, - so squeezing them would spill their pills over the activity pills next to - them. Keep them at their natural width and let the activity pills, which - ellipsize their labels, absorb the shrinking instead. */ -.session-chat-input-toolbar > .chat-turn-pills { +.session-chat-input-toolbar-content > .chat-pills, +.session-chat-input-toolbar-content > .session-activity-pill { flex-shrink: 0; } @@ -29,3 +30,28 @@ display: none; } +/* Every pill is hidden by the user but data exists: keep a slim strip so its + context menu stays reachable and the pills can be shown again. */ +.session-chat-input-toolbar.empty .session-chat-input-toolbar-content { + min-height: var(--vscode-spacing-size120); +} + +.session-chat-input-toolbar > .scrollbar > .slider { + background: transparent; +} + +.session-chat-input-toolbar > .scrollbar.horizontal > .slider::before { + content: ''; + position: absolute; + inset: var(--vscode-strokeThickness); + border-radius: var(--vscode-cornerRadius-circle); + background: var(--vscode-scrollbarSlider-background); +} + +.session-chat-input-toolbar > .scrollbar > .slider:hover::before { + background: var(--vscode-scrollbarSlider-hoverBackground); +} + +.session-chat-input-toolbar > .scrollbar > .slider.active::before { + background: var(--vscode-scrollbarSlider-activeBackground); +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionActivityPill.ts b/src/vs/sessions/contrib/chat/browser/sessionActivityPill.ts deleted file mode 100644 index 03ef6d2f233d4..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/sessionActivityPill.ts +++ /dev/null @@ -1,169 +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 { $ } from '../../../../base/browser/dom.js'; -import { Button } from '../../../../base/browser/ui/button/button.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { onUnexpectedError } from '../../../../base/common/errors.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, observableValue } from '../../../../base/common/observable.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize } from '../../../../nls.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; -import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; -import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import './media/sessionActivityPill.css'; - -/** One entry of a pill, rendered as the button label or as a picker row. */ -export interface ISessionActivity { - readonly label: string; - readonly icon: ThemeIcon; -} - -/** A named section of the picker; sections without activities are skipped. */ -export interface ISessionActivityCategory { - readonly title: string; - readonly activities: readonly T[]; -} - -/** The button content when a pill stands for more than one activity. */ -export interface ISessionActivitySummary { - readonly label: string; - readonly icon: ThemeIcon; - readonly ariaLabel: string; -} - -export interface ISessionActivityPillOptions { - /** Extra class on the pill root, for fixtures and per-pill styling. */ - readonly className: string; - /** Identifies the pill's picker to the action widget service. */ - readonly widgetId: string; - /** Accessible name of the picker shown for more than one activity. */ - readonly getWidgetAriaLabel: () => string; - /** Button content for more than one activity; a single activity renders itself. */ - readonly getSummary: (activities: readonly T[]) => ISessionActivitySummary; - readonly openActivity: (activity: T) => void | Promise; -} - -/** - * A compact button standing for a set of activities. A single activity is shown - * with its own icon and label and is opened directly; more than one shows the - * consumer's summary and opens a picker grouped by category. The widget owns - * only the presentation — which activities exist, how they are grouped, and how - * they are labelled is up to the consumer. - */ -export class SessionActivityPill extends Disposable { - - readonly element: HTMLElement; - readonly isVisible: IObservable; - - private readonly _button: Button; - private readonly _isVisible = observableValue(this, false); - private _categories: readonly ISessionActivityCategory[] = []; - private _activities: readonly T[] = []; - - constructor( - private readonly _options: ISessionActivityPillOptions, - private readonly _actionWidgetService: IActionWidgetService, - ) { - super(); - - this.element = $(`.session-activity-pill.${_options.className}.hidden`); - this.isVisible = this._isVisible; - this._button = this._register(new Button(this.element, { secondary: true, small: true, supportIcons: true, ...defaultButtonStyles })); - this._button.element.classList.add('session-activity-pill-button'); - this._register(this._button.onDidClick(() => this._onDidClick())); - } - - setCategories(categories: readonly ISessionActivityCategory[]): void { - this._categories = categories.filter(category => category.activities.length > 0); - this._activities = this._categories.flatMap(category => category.activities); - this._render(); - } - - private _render(): void { - const count = this._activities.length; - this._isVisible.set(count > 0, undefined); - this.element.classList.toggle('hidden', count === 0); - if (count === 0) { - return; - } - - let label: string; - let accessibleLabel: string; - if (count === 1) { - const activity = this._activities[0]; - label = `$(${activity.icon.id}) ${activity.label}`; - accessibleLabel = localize('sessionActivityPill.open', "Open {0}", activity.label); - } else { - const summary = this._options.getSummary(this._activities); - label = `$(${summary.icon.id}) ${summary.label} $(${Codicon.chevronDown.id})`; - accessibleLabel = summary.ariaLabel; - } - - this._button.label = label; - this._button.setTitle(accessibleLabel); - this._button.setAriaLabel(accessibleLabel); - } - - private _onDidClick(): void { - if (this._activities.length === 1) { - this._openActivity(this._activities[0]); - return; - } - if (this._activities.length > 1) { - this._showPicker(); - } - } - - private _openActivity(activity: T): void { - Promise.resolve(this._options.openActivity(activity)).catch(onUnexpectedError); - } - - private _showPicker(): void { - if (this._actionWidgetService.isVisible) { - return; - } - - const items: IActionListItem[] = []; - for (const category of this._categories) { - if (items.length > 0) { - items.push({ kind: ActionListItemKind.Separator, label: '' }); - } - items.push({ kind: ActionListItemKind.Header, label: category.title, group: { title: category.title } }); - for (const activity of category.activities) { - items.push({ - kind: ActionListItemKind.Action, - label: activity.label, - group: { title: '', icon: activity.icon }, - item: activity, - }); - } - } - - const triggerElement = this._button.element; - const delegate: IActionListDelegate = { - onSelect: activity => { - this._actionWidgetService.hide(); - this._openActivity(activity); - }, - onHide: () => triggerElement.focus(), - }; - this._actionWidgetService.show( - this._options.widgetId, - false, - items, - delegate, - triggerElement, - undefined, - [], - { - getAriaLabel: item => item.label ?? '', - getWidgetAriaLabel: () => this._options.getWidgetAriaLabel(), - }, - { minWidth: 220, maxWidth: 420 }, - ); - } -} diff --git a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts new file mode 100644 index 0000000000000..635e5be871381 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived, IObservable, IReader } from '../../../../base/common/observable.js'; +import { basename, getComparisonKey } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import type { IChatPillEntry, IChatPillSection } from '../../../../workbench/browser/chatPills.js'; +import { openChatTurnFile, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { SessionArtifactKind, SessionFileOperation, type ISessionArtifact, type ISessionFile } from '../../../services/sessions/common/session.js'; +import type { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; + +const artifactIcons: ReadonlyMap = new Map([ + [SessionArtifactKind.PullRequest, Codicon.gitPullRequest], + [SessionArtifactKind.Issue, Codicon.issues], + [SessionArtifactKind.Commit, Codicon.gitCommit], + [SessionArtifactKind.Website, Codicon.globe], + [SessionArtifactKind.Resource, Codicon.link], +]); + +/** Section order and titles, matching the order artifacts are offered in. */ +const sectionOrder: readonly { readonly kind: SessionArtifactKind; readonly title: string }[] = [ + { kind: SessionArtifactKind.PullRequest, title: localize('sessionArtifacts.pullRequests', "Pull Requests") }, + { kind: SessionArtifactKind.Issue, title: localize('sessionArtifacts.issues', "Issues") }, + { kind: SessionArtifactKind.Commit, title: localize('sessionArtifacts.commits', "Commits") }, + { kind: SessionArtifactKind.Website, title: localize('sessionArtifacts.websites', "Websites") }, + { kind: SessionArtifactKind.File, title: localize('sessionArtifacts.files', "Files") }, + { kind: SessionArtifactKind.Resource, title: localize('sessionArtifacts.resources', "Resources") }, +]; + +/** What an artifact entry needs from the surrounding surface to be activated. */ +export interface ISessionArtifactActions { + openExternal(link: URI): void; + openResource(uri: URI): void; + copy(text: string): void; +} + +function artifactValueKey(artifact: ISessionArtifact): string { + if (artifact.uri) { + return getComparisonKey(artifact.uri); + } + return (artifact.link?.toString() ?? artifact.commitHash ?? artifact.id).toLowerCase(); +} + +function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions): IChatPillEntry | undefined { + if (artifact.kind === SessionArtifactKind.File) { + return artifact.uri + ? { id: artifact.id, label: basename(artifact.uri), resource: artifact.uri, open: () => actions.openResource(artifact.uri!) } + : undefined; + } + + const icon = artifactIcons.get(artifact.kind) ?? Codicon.archive; + if (artifact.kind === SessionArtifactKind.Commit) { + if (!artifact.link) { + return undefined; + } + const link = artifact.link; + const copyAction = artifact.commitHash + ? [toAction({ + id: 'sessions.artifacts.copyCommitHash', + label: localize('sessionArtifacts.copyCommitHash', "Copy Commit Hash"), + class: ThemeIcon.asClassName(Codicon.copy), + run: () => actions.copy(artifact.commitHash!), + })] + : []; + return { id: artifact.id, label: artifact.label, icon, toolbarActions: copyAction, open: () => actions.openExternal(link) }; + } + + if (artifact.kind === SessionArtifactKind.Resource) { + return artifact.uri + ? { id: artifact.id, label: artifact.label, icon, open: () => actions.openResource(artifact.uri!) } + : undefined; + } + + return artifact.link + ? { id: artifact.id, label: artifact.label, icon, open: () => actions.openExternal(artifact.link!) } + : undefined; +} + +/** + * Builds the artifact sections shown in the pill: the agent-set artifacts plus + * the previewable files the session wrote outside its workspace, de-duplicated + * with the agent's own entries winning. + */ +export function buildSessionArtifactSections(artifacts: readonly ISessionArtifact[], externalFiles: readonly ISessionFile[], actions: ISessionArtifactActions): readonly IChatPillSection[] { + const entriesByKind = new Map(); + const seen = new Set(); + + for (const artifact of artifacts) { + const entry = toEntry(artifact, actions); + if (!entry || seen.has(artifactValueKey(artifact))) { + continue; + } + seen.add(artifactValueKey(artifact)); + const entries = entriesByKind.get(artifact.kind) ?? []; + entries.push(entry); + entriesByKind.set(artifact.kind, entries); + } + + for (const file of externalFiles) { + if (file.operation === SessionFileOperation.Deleted || !previewKind(file.uri) || seen.has(getComparisonKey(file.uri))) { + continue; + } + seen.add(getComparisonKey(file.uri)); + const entries = entriesByKind.get(SessionArtifactKind.File) ?? []; + entries.push({ id: file.uri.toString(), label: basename(file.uri), resource: file.uri, open: () => actions.openResource(file.uri) }); + entriesByKind.set(SessionArtifactKind.File, entries); + } + + const sections: IChatPillSection[] = []; + for (const { kind, title } of sectionOrder) { + const entries = entriesByKind.get(kind); + if (entries?.length) { + sections.push({ title, entries }); + } + } + return sections; +} + +/** Publishes a session's artifact sections for the chat input pill. */ +export class SessionArtifacts extends Disposable { + + readonly sections: IObservable; + + constructor( + session: IObservable, + @IClipboardService private readonly _clipboardService: IClipboardService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IOpenerService private readonly _openerService: IOpenerService, + ) { + super(); + + this.sections = derived(this, reader => { + const current = session.read(reader); + if (!current) { + return []; + } + return buildSessionArtifactSections( + current.artifacts?.read(reader) ?? [], + this._readExternalFiles(current, reader), + this._actions(), + ); + }); + } + + private _readExternalFiles(session: IActiveSession, reader: IReader): readonly ISessionFile[] { + return session.externalChanges?.read(reader) ?? []; + } + + private _actions(): ISessionArtifactActions { + return { + openExternal: link => { void this._openerService.open(link, { openExternal: true }); }, + openResource: uri => { + if (previewKind(uri)) { + void openChatTurnFile({ uri, kind: previewKind(uri)!, created: false }, this._openerService, this._configurationService); + return; + } + void this._openerService.open(uri, { fromUserGesture: true }); + }, + copy: text => { void this._clipboardService.writeText(text); }, + }; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts b/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts index 982539f7d5780..60571898c9ffb 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts @@ -5,115 +5,93 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, IObservable, IReader } from '../../../../base/common/observable.js'; +import { derived, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; -import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import type { IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; +import { getChatPillEntries, type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, IChat, isActiveSessionStatus } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { ISessionActivity, ISessionActivitySummary, SessionActivityPill } from './sessionActivityPill.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; const SUBAGENT_LABEL_MAX_LENGTH = 30; -interface ISubagentActivity extends ISessionActivity { - /** The subagent chat to open, or `undefined` for a fake activity from debug data. */ - readonly chat: IChat | undefined; -} - -/** - * The activities this pill lists. Further kinds join this union; once more than - * one kind can be listed at once, the summary needs a generic mixed-kind label. - */ -type IBackgroundActivity = ISubagentActivity; +/** Presentation of the subagents pill. */ +export const sessionSubagentsPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionBackgroundActivities', + icon: Codicon.agent, + title: localize('backgroundActivities.ariaLabel', "Background Activities"), + summaryLabel: count => localize('backgroundActivities.activeSubagents', "{0} Active Subagents", count), + summaryAriaLabel: count => localize('backgroundActivities.show', "Show {0} background activities", count), +}; /** - * Lists the background activities of the viewed chat as one compact pill. Today - * those are the chat's running subagents. Browsers have their own pill, see + * Supplies the background activities of the viewed chat to its pill. Today those + * are the chat's running subagents; browsers have their own pill, see * `SessionBrowsersControl`. */ export class SessionBackgroundActivitiesControl extends Disposable { - readonly element: HTMLElement; - readonly isVisible: IObservable; + /** The pill's sections, empty while the user has the pill hidden. */ + readonly sections: IObservable; + /** Whether there are activities to show, regardless of the user's visibility choice. */ + readonly hasData: IObservable; - private readonly _pill: SessionActivityPill; - private _currentSession: IActiveSession | undefined; - private _runningSubagents: readonly ISubagentActivity[] = []; - private _debugData: ISessionChatPillsDebugData | undefined; + private readonly _debugData = observableValue(this, undefined); constructor( - private readonly _session: IObservable, - private readonly _chat: IObservable, - private readonly _enabled: IObservable, - @IActionWidgetService actionWidgetService: IActionWidgetService, + session: IObservable, + chat: IObservable, + enabled: IObservable, + visible: IObservable, @ISessionsService private readonly _sessionsService: ISessionsService, ) { super(); - this._pill = this._register(new SessionActivityPill({ - className: 'session-background-activities', - widgetId: 'sessionBackgroundActivities', - getWidgetAriaLabel: () => localize('backgroundActivities.ariaLabel', "Background Activities"), - getSummary: activities => this._summary(activities), - openActivity: activity => this._openActivity(activity), - }, actionWidgetService)); - this.element = this._pill.element; - this.isVisible = this._pill.isVisible; - - this._register(autorun(reader => { - const session = this._session.read(reader); - const chat = this._chat.read(reader); - const enabled = this._enabled.read(reader); - this._currentSession = session; - this._runningSubagents = enabled && session && chat ? this._collectRunningSubagents(session, chat, reader) : []; - this._refresh(); - })); + const allSections = derived(this, reader => { + const debugData = this._debugData.read(reader); + const currentSession = session.read(reader); + const currentChat = chat.read(reader); + const subagents = debugData + ? debugData.subagents.map(label => this._entry(label, undefined, currentSession)) + : enabled.read(reader) && currentSession && currentChat + ? this._collectRunningSubagents(currentSession, currentChat, reader) + : []; + return subagents.length > 0 + ? [{ title: localize('backgroundActivities.subagents', "Subagents"), entries: subagents }] + : []; + }); + + this.hasData = derived(this, reader => getChatPillEntries(allSections.read(reader)).length > 0); + this.sections = derived(this, reader => visible.read(reader) ? allSections.read(reader) : []); } setDebugData(data: ISessionChatPillsDebugData | undefined): void { - this._debugData = data; - this._refresh(); + this._debugData.set(data, undefined); } - private _collectRunningSubagents(session: IActiveSession, parentChat: IChat, reader: IReader): ISubagentActivity[] { + private _collectRunningSubagents(session: IActiveSession, parentChat: IChat, reader: IReader): IChatPillEntry[] { return session.chats.read(reader) .filter(chat => chat.origin?.kind === ChatOriginKind.Tool && !!chat.origin.parentChat && isEqual(chat.origin.parentChat, parentChat.resource) && isActiveSessionStatus(chat.status.read(reader))) - .map(chat => ({ - chat, - icon: Codicon.agent, - label: this._subagentLabel(chat.title.read(reader)), - })); + .map(chat => this._entry(chat.title.read(reader), chat, session)); } - private _subagentLabel(title: string): string { - const label = title.trim() || localize('backgroundActivities.subagent', "Subagent"); - return label.length > SUBAGENT_LABEL_MAX_LENGTH ? `${label.slice(0, SUBAGENT_LABEL_MAX_LENGTH)}...` : label; - } - - private _refresh(): void { - const subagents: readonly ISubagentActivity[] = this._debugData - ? this._debugData.subagents.map(label => ({ label, icon: Codicon.agent, chat: undefined })) - : this._runningSubagents; - this._pill.setCategories([{ title: localize('backgroundActivities.subagents', "Subagents"), activities: subagents }]); - } - - private _summary(activities: readonly IBackgroundActivity[]): ISessionActivitySummary { + private _entry(title: string, chat: IChat | undefined, session: IActiveSession | undefined): IChatPillEntry { + const name = title.trim() || localize('backgroundActivities.subagent', "Subagent"); return { + id: chat?.resource.toString() ?? name, + label: name.length > SUBAGENT_LABEL_MAX_LENGTH ? `${name.slice(0, SUBAGENT_LABEL_MAX_LENGTH)}...` : name, icon: Codicon.agent, - label: localize('backgroundActivities.activeSubagents', "{0} Active Subagents", activities.length), - ariaLabel: localize('backgroundActivities.show', "Show {0} background activities", activities.length), + open: () => { + if (chat && session) { + this._sessionsService.openChat(session, chat.resource); + } + }, }; } - - private _openActivity(activity: IBackgroundActivity): void { - if (activity.chat && this._currentSession) { - this._sessionsService.openChat(this._currentSession, activity.chat.resource); - } - } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts b/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts index 2f46abdd93101..b645265aef92a 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts @@ -5,98 +5,85 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, IObservable, IReader } from '../../../../base/common/observable.js'; +import { derived, IObservable, IReader, observableSignal, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; -import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { BrowserEditorInput } from '../../../../workbench/contrib/browserView/common/browserEditorInput.js'; import { browserViewUrlMatches, BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js'; +import type { IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; +import { getChatPillEntries, type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { ChatOriginKind, IChat } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { ISessionActivity, ISessionActivitySummary, SessionActivityPill } from './sessionActivityPill.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; -interface IBrowserActivity extends ISessionActivity { - /** The browser to open, or `undefined` for a fake activity from debug data. */ - readonly input: BrowserEditorInput | undefined; -} +/** Presentation of the browsers pill. */ +export const sessionBrowsersPillOptions: IChatDropdownPillOptions = { + widgetId: 'sessionBrowsers', + icon: Codicon.globe, + title: localize('browsers.ariaLabel', "Browsers"), + summaryLabel: count => localize('browsers.activeBrowsers', "{0} Active Browsers", count), + summaryAriaLabel: count => localize('browsers.show', "Show {0} browsers", count), +}; -/** Lists the live browsers of the viewed chat (and its subagents) as one compact pill. */ +/** Supplies the live browsers of the viewed chat (and its subagents) to its pill. */ export class SessionBrowsersControl extends Disposable { - readonly element: HTMLElement; - readonly isVisible: IObservable; + /** The pill's sections, empty while the user has the pill hidden. */ + readonly sections: IObservable; + /** Whether there are browsers to show, regardless of the user's visibility choice. */ + readonly hasData: IObservable; - private readonly _pill: SessionActivityPill; + private readonly _debugData = observableValue(this, undefined); + /** Browser titles and the known-browser set change outside the observable graph. */ + private readonly _browsersChanged = observableSignal(this); private readonly _browserListeners = this._register(new MutableDisposable()); - /** Chats whose browsers belong to this pill: the viewed chat and its subagents. */ - private _ownerIds: ReadonlySet = new Set(); - private _currentChat: IChat | undefined; - private _enabledValue = false; - private _debugData: ISessionChatPillsDebugData | undefined; constructor( - private readonly _session: IObservable, - private readonly _chat: IObservable, - private readonly _enabled: IObservable, + session: IObservable, + chat: IObservable, + enabled: IObservable, + visible: IObservable, @IBrowserViewWorkbenchService private readonly _browserViewService: IBrowserViewWorkbenchService, - @IActionWidgetService actionWidgetService: IActionWidgetService, @IEditorService private readonly _editorService: IEditorService, ) { super(); - this._pill = this._register(new SessionActivityPill({ - className: 'session-browsers', - widgetId: 'sessionBrowsers', - getWidgetAriaLabel: () => localize('browsers.ariaLabel', "Browsers"), - getSummary: activities => this._summary(activities), - openActivity: activity => this._openActivity(activity), - }, actionWidgetService)); - this.element = this._pill.element; - this.isVisible = this._pill.isVisible; - - this._register(autorun(reader => { - const session = this._session.read(reader); - const chat = this._chat.read(reader); - this._currentChat = chat; - this._enabledValue = this._enabled.read(reader); - // Read the chat list through the reader so browsers registered by a - // subagent show up as soon as that subagent joins the session. - this._ownerIds = session && chat ? this._collectOwnerIds(session, chat, reader) : new Set(); - this._refresh(); - })); + const allSections = derived(this, reader => { + this._browsersChanged.read(reader); + const debugData = this._debugData.read(reader); + const currentSession = session.read(reader); + const currentChat = chat.read(reader); + const browsers = debugData + ? debugData.browsers.map(label => this._entry(label, undefined, currentChat)) + : enabled.read(reader) && currentSession && currentChat + // Read the chat list through the reader so browsers registered by a + // subagent show up as soon as that subagent joins the session. + ? this._collectBrowsers(this._collectOwnerIds(currentSession, currentChat, reader), currentChat) + : []; + return browsers.length > 0 + ? [{ title: localize('browsers.browsers', "Browsers"), entries: browsers }] + : []; + }); + + this.hasData = derived(this, reader => getChatPillEntries(allSections.read(reader)).length > 0); + this.sections = derived(this, reader => visible.read(reader) ? allSections.read(reader) : []); + this._register(this._browserViewService.onDidChangeBrowserViews(() => this._refreshBrowserListeners())); this._refreshBrowserListeners(); } setDebugData(data: ISessionChatPillsDebugData | undefined): void { - this._debugData = data; - this._refresh(); + this._debugData.set(data, undefined); } private _refreshBrowserListeners(): void { const store = new DisposableStore(); this._browserListeners.value = store; for (const input of this._browserViewService.getKnownBrowserViews().values()) { - store.add(input.onDidChangeLabel(() => this._refresh())); + store.add(input.onDidChangeLabel(() => this._browsersChanged.trigger(undefined))); } - this._refresh(); - } - - private _refresh(): void { - const activities = this._debugData - ? this._debugData.browsers.map(label => ({ label, icon: Codicon.globe, input: undefined })) - : this._enabledValue ? this._collectBrowserActivities() : []; - this._pill.setCategories([{ title: localize('browsers.browsers', "Browsers"), activities }]); - } - - private _summary(activities: readonly IBrowserActivity[]): ISessionActivitySummary { - return { - icon: Codicon.globe, - label: localize('browsers.activeBrowsers', "{0} Active Browsers", activities.length), - ariaLabel: localize('browsers.show', "Show {0} browsers", activities.length), - }; + this._browsersChanged.trigger(undefined); } private _collectOwnerIds(session: IActiveSession, chat: IChat, reader: IReader): ReadonlySet { @@ -109,39 +96,44 @@ export class SessionBrowsersControl extends Disposable { return ownerIds; } - private _collectBrowserActivities(): IBrowserActivity[] { - const activities: IBrowserActivity[] = []; + private _collectBrowsers(ownerIds: ReadonlySet, chat: IChat | undefined): IChatPillEntry[] { + const entries: IChatPillEntry[] = []; for (const input of this._browserViewService.getKnownBrowserViews().values()) { const ownerId = input.model?.owner.sessionId; - if (ownerId && this._ownerIds.has(ownerId)) { - activities.push({ - input, - icon: Codicon.globe, - label: input.title?.trim() || localize('browsers.browser', "Browser"), - }); + if (ownerId && ownerIds.has(ownerId)) { + entries.push(this._entry(input.title?.trim() || localize('browsers.browser', "Browser"), input, chat)); } } - return activities; + return entries; + } + + private _entry(label: string, input: BrowserEditorInput | undefined, chat: IChat | undefined): IChatPillEntry { + return { + id: input?.id ?? label, + label, + icon: Codicon.globe, + open: () => { void this._openBrowser(input, chat); }, + }; } - private async _openActivity(activity: IBrowserActivity): Promise { - if (!activity.input) { + private async _openBrowser(input: BrowserEditorInput | undefined, chat: IChat | undefined): Promise { + if (!input) { return; } - const input = this._getBrowserInputToOpen(activity.input); - const existing = this._editorService.findEditors(input.resource) - .find(identifier => identifier.editor instanceof BrowserEditorInput && identifier.editor.id === input.id); + const target = this._getBrowserInputToOpen(input, chat); + const existing = this._editorService.findEditors(target.resource) + .find(identifier => identifier.editor instanceof BrowserEditorInput && identifier.editor.id === target.id); const targetGroup = existing?.groupId ?? await this._browserViewService.getPreferredGroup(); - await this._editorService.openEditor(input, undefined, targetGroup); + await this._editorService.openEditor(target, undefined, targetGroup); } - private _getBrowserInputToOpen(input: BrowserEditorInput): BrowserEditorInput { + private _getBrowserInputToOpen(input: BrowserEditorInput, chat: IChat | undefined): BrowserEditorInput { const url = input.url; if (input.model?.sharingState === BrowserViewSharingState.Shared || !url) { return input; } - const activeSessionId = this._currentChat?.resource.toString(); + const activeSessionId = chat?.resource.toString(); const shared = [...this._browserViewService.getContextualBrowserViews({ activeSessionId }).values()] .filter(candidate => candidate.model?.sharingState === BrowserViewSharingState.Shared && browserViewUrlMatches(candidate.url, url)); return shared.find(candidate => candidate.url === url) ?? shared.at(0) ?? input; diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index ee5cdf91df007..e40a5a468a8be 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -3,90 +3,109 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $ } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, DisposableResizeObserver, EventType, getWindow } from '../../../../base/browser/dom.js'; +import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; +import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { toAction, Action, Separator, type IAction } from '../../../../base/common/actions.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, derivedOpts, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; +import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; import { URI } from '../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; -import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ChatTurnPillsWidget, diffStatsEqual, EMPTY_DIFF_STATS, IChatTurnPillsModel, IDiffStats, IPreviewFile, observeTurnStatusPillsEnabled, openChatTurnFile, previewFilesEqual, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID, ChatTurnPillsProvider, diffStatsEqual, EMPTY_DIFF_STATS, IChatTurnPillsModel, IDiffStats, observeTurnStatusPillsEnabled } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { SessionArtifacts } from './sessionArtifacts.js'; +import { chatCustomizationPillOptions, SessionCustomizations, SESSION_CUSTOMIZATIONS_PILL_ID } from './sessionCustomizations.js'; +import { localize } from '../../../../nls.js'; +import { getChatPillEntries, ChatPillsWidget, IChatPill, IChatPillsModel, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; +import { createChatSectionPill, type IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; +import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js'; import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; +import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../changes/common/changes.js'; +import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../github/common/types.js'; +import { getSessionChatPillMenu, SessionChatPillKind, SessionChatPillVisibility, type ISessionChatPillMenuEntry } from '../common/sessionChatPills.js'; +import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../../common/sessionConfig.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IChat, isActiveSessionStatus } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; -import { SessionBackgroundActivitiesControl } from './sessionBackgroundActivitiesControl.js'; -import { SessionBrowsersControl } from './sessionBrowsersControl.js'; +import { SessionBackgroundActivitiesControl, sessionSubagentsPillOptions } from './sessionBackgroundActivitiesControl.js'; +import { SessionBrowsersControl, sessionBrowsersPillOptions } from './sessionBrowsersControl.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; +import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { SessionMetadataPills } from './sessionMetadataPills.js'; +import { SessionActivatingActionRunner } from '../../../browser/sessionActionRunner.js'; import './media/sessionChatInputToolbar.css'; -/** The per-turn data both pills reflect. */ -interface ITurnData { - readonly stats: IDiffStats; - /** Previewable files changed in the turn, primary (first) first. */ - readonly previewFiles: readonly IPreviewFile[]; -} - -const EMPTY_TURN_DATA: ITurnData = { stats: EMPTY_DIFF_STATS, previewFiles: [] }; - -/** - * Compute the current turn's diff stats and previewable files from the chat's - * last-turn changes ({@link IChat.lastTurnChanges}), which the provider derives - * from the live output stream. Files are classified as created vs. edited with - * the same rules as the Changes view (an addition has no original; a deletion - * has no modified resource). Created files are listed before edited ones so the - * primary (first) file is the first created one, falling back to the first - * edited one. Returns {@link EMPTY_TURN_DATA} when the chat exposes no last-turn - * changes (e.g. before its first turn, or a provider that can't determine them). - */ -function computeTurnData(chat: IChat, reader: IReader): ITurnData { - const changes = chat.lastTurnChanges?.read(reader) ?? []; - +/** Diff stats for the current turn, from the chat''s last-turn changes. */ +function computeTurnStats(chat: IChat, reader: IReader): IDiffStats { let files = 0, insertions = 0, deletions = 0; - const created: IPreviewFile[] = []; - const edited: IPreviewFile[] = []; - for (const change of changes) { - if (!change.isOutsideWorkspace) { - files++; - insertions += change.insertions; - deletions += change.deletions; + for (const change of chat.lastTurnChanges?.read(reader) ?? []) { + if (change.isOutsideWorkspace) { continue; } - - if (change.modifiedUri === undefined) { - continue; // a deletion has nothing to preview - } - const uri = isIChatSessionFileChange2(change) ? change.uri : change.modifiedUri; - const kind = previewKind(uri); - if (!kind) { - continue; - } - const isCreated = change.originalUri === undefined; - (isCreated ? created : edited).push({ uri, kind, created: isCreated }); + files++; + insertions += change.insertions; + deletions += change.deletions; } + return { files, insertions, deletions }; +} +/** Whether last-turn pills should remain available for the current session state. */ +export function shouldShowSessionTurnPills(hasDebugData: boolean, turnActive: boolean, showSessionMetadataInInput: boolean, turnStatusPillsEnabled: boolean): boolean { + return hasDebugData || turnStatusPillsEnabled && (turnActive || showSessionMetadataInInput); +} - return { - stats: { files, insertions, deletions }, - previewFiles: [...created, ...edited], - }; +/** Fake artifacts for the pill debug overlay. */ +function buildDebugArtifactSections(debugData: ISessionChatPillsDebugData): readonly IChatPillSection[] { + const entries = debugData.markdownFiles.map(name => ({ + id: name, + label: name, + resource: URI.from({ scheme: 'session-chat-pills-debug', path: `/${name}` }), + open: () => { }, + })); + return entries.length ? [{ title: localize('sessionArtifacts.files', "Files"), entries }] : []; } -function turnDataEqual(a: ITurnData, b: ITurnData): boolean { - return diffStatsEqual(a.stats, b.stats) && previewFilesEqual(a.previewFiles, b.previewFiles); +/** Action ids of the pills the sessions toolbar hosts itself. */ +export const SESSION_BROWSERS_PILL_ID = 'sessions.chatPills.browsers'; +export const SESSION_SUBAGENTS_PILL_ID = 'sessions.chatPills.subagents'; + +/** The pill kind a contributed or turn-status action belongs to, if any. */ +export function getSessionChatPillKindForAction(actionId: string): SessionChatPillKind | undefined { + switch (actionId) { + case CHAT_TURN_CHANGES_PILL_ID: + case VIEW_SESSION_CHANGES_COMMAND_ID: + return SessionChatPillKind.Changes; + case CHAT_TURN_ARTIFACT_PILL_ID: + return SessionChatPillKind.Artifacts; + case SESSION_CUSTOMIZATIONS_PILL_ID: + return SessionChatPillKind.Customizations; + case OPEN_PULL_REQUEST_ACTION_ID: + return SessionChatPillKind.PullRequests; + case OPEN_ISSUE_ACTION_ID: + return SessionChatPillKind.Issues; + case SESSION_BROWSERS_PILL_ID: + return SessionChatPillKind.Browsers; + case SESSION_SUBAGENTS_PILL_ID: + return SessionChatPillKind.Subagents; + default: + return undefined; + } } -/** A floating toolbar for the viewed chat's active-turn status and background activity. */ +/** A toolbar for session metadata, active-turn status, and background activity. */ export class SessionChatInputToolbar extends Disposable { readonly element: HTMLElement; + private readonly _content: HTMLElement; + private readonly _scrollable: DomScrollableElement; /** Sentinel distinguishing "no override" from an explicit `undefined` session. */ - private readonly _sessionOverride = observableValue('sessionOverride', 'unset'); + private readonly _sessionOverride = observableValue(this, 'unset'); /** The chat whose last-turn changes are reflected. */ - private readonly _chat = observableValue('chat', undefined); + private readonly _chat = observableValue(this, undefined); private readonly _debugData = observableValue(this, undefined); private readonly _browsers: SessionBrowsersControl; private readonly _backgroundActivities: SessionBackgroundActivitiesControl; @@ -104,10 +123,12 @@ export class SessionChatInputToolbar extends Disposable { return this._findOwningSession(chat.resource, reader); }); - /** The current turn's diff stats and previewable files. */ - private readonly _turnData: IObservable; + /** The current turn's diff stats. */ private readonly _diffStats: IObservable; - private readonly _previewFiles: IObservable; + /** Artifact sections shown in the artifact pill. */ + private readonly _artifactSections: IObservable; + /** Customization sections shown in the customizations pill. */ + private readonly _customizationSections: IObservable; /** Whether pills may show at all: an agent host session with an active turn. */ private readonly _active = derived(reader => { @@ -121,55 +142,196 @@ export class SessionChatInputToolbar extends Disposable { constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, - @IOpenerService private readonly _openerService: IOpenerService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, @ISessionsService private readonly _sessionsService: ISessionsService, @IChatResponseFileChangesService private readonly _chatResponseFileChangesService: IChatResponseFileChangesService, @IInstantiationService instantiationService: IInstantiationService, ) { super(); - this.element = $('.session-chat-input-toolbar.hidden'); + this._content = $('.session-chat-input-toolbar-content'); + this._scrollable = this._register(new DomScrollableElement(this._content, { + horizontal: ScrollbarVisibility.Auto, + horizontalScrollbarSize: 6, + scrollYToX: true, + vertical: ScrollbarVisibility.Hidden, + })); + this.element = this._scrollable.getDomNode(); + this.element.classList.add('session-chat-input-toolbar', 'hidden'); - this._turnData = derivedOpts({ owner: this, equalsFn: turnDataEqual }, reader => { + this._diffStats = derivedOpts({ owner: this, equalsFn: diffStatsEqual }, reader => { const debugData = this._debugData.read(reader); if (debugData) { - return { - stats: debugData.stats, - previewFiles: debugData.markdownFiles.map(name => ({ - uri: URI.from({ scheme: 'session-chat-pills-debug', path: `/${name}` }), - kind: 'markdown', - created: true, - })), - }; + return debugData.stats; } const chat = this._chat.read(reader); - return chat ? computeTurnData(chat, reader) : EMPTY_TURN_DATA; + return chat ? computeTurnStats(chat, reader) : EMPTY_DIFF_STATS; }); - this._diffStats = derivedOpts({ owner: this, equalsFn: diffStatsEqual }, reader => this._turnData.read(reader).stats); - this._previewFiles = derivedOpts({ owner: this, equalsFn: previewFilesEqual }, reader => this._turnData.read(reader).previewFiles); + + const sessionArtifacts = this._register(instantiationService.createInstance(SessionArtifacts, this._session)); + this._artifactSections = derived(this, reader => { + const debugData = this._debugData.read(reader); + return debugData ? buildDebugArtifactSections(debugData) : sessionArtifacts.sections.read(reader); + }); + const sessionCustomizations = this._register(instantiationService.createInstance(SessionCustomizations, this._chat)); + this._customizationSections = sessionCustomizations.sections; const turnStatusPillsEnabled = observeTurnStatusPillsEnabled(this._configurationService); + const showMetadataInChatInput = observableConfigValue(SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING, false, this._configurationService); + const showTurnPills = derived(reader => shouldShowSessionTurnPills( + this._debugData.read(reader) !== undefined, + this._active.read(reader), + showMetadataInChatInput.read(reader), + turnStatusPillsEnabled.read(reader), + )); const model: IChatTurnPillsModel = { stats: this._diffStats, - previewFiles: this._previewFiles, - changesEnabled: derived(reader => this._debugData.read(reader) !== undefined || this._active.read(reader) && turnStatusPillsEnabled.read(reader)), - previewEnabled: derived(reader => this._debugData.read(reader) !== undefined || this._active.read(reader) && turnStatusPillsEnabled.read(reader)), + artifacts: this._artifactSections, + changesEnabled: showTurnPills, + // Artifacts outlive the turn that produced them, so they only need the pills enabled. + artifactsEnabled: derived(reader => this._debugData.read(reader) !== undefined || turnStatusPillsEnabled.read(reader)), openChanges: () => this._debugData.get() ? undefined : this._openChanges(), - openFile: file => this._debugData.get() ? undefined : openChatTurnFile(file, this._openerService, this._configurationService), }; - const pills = this._register(instantiationService.createInstance(ChatTurnPillsWidget, model)); - this.element.appendChild(pills.element); + const turnPills = this._register(instantiationService.createInstance(ChatTurnPillsProvider, model)); + const metadataPills = this._register(instantiationService.createInstance(SessionMetadataPills, this.element, this._session, showMetadataInChatInput)); + const visibility = this._register(instantiationService.createInstance(SessionChatPillVisibility)); + + // Every pill the session currently has data for, before the user's + // per-kind visibility choices are applied. + const candidatePills = derived(reader => { + const turn = turnPills.pills.read(reader); + if (!showMetadataInChatInput.read(reader)) { + return turn; + } + return [ + ...metadataPills.pills.read(reader), + ...turn.filter(pill => pill.action.id !== CHAT_TURN_CHANGES_PILL_ID), + ]; + }); + this._browsers = this._register(instantiationService.createInstance(SessionBrowsersControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Browsers, reader)))); + this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Subagents, reader)))); + + // `show-file-icons` lets a resource pill paint its themed file icon. + const resourceLabels = this._register(instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + const sectionPill = (id: string, label: string, sections: IObservable, options: IChatDropdownPillOptions) => { + const action = this._register(new Action(id, label)); + return createChatSectionPill(action, sections, options, resourceLabels, instantiationService); + }; + + // Customization sections are not gated at the source, so gate them here the + // way the two activity controls gate their own. Data presence follows the + // feature gate but not the user's visibility choice, otherwise hiding the + // pill would drop it from the menu that restores it. + const availableCustomizations = derived(reader => turnStatusPillsEnabled.read(reader) ? this._customizationSections.read(reader) : []); + const hasCustomizations = derived(reader => getChatPillEntries(availableCustomizations.read(reader)).length > 0); + const customizationSections = derived(reader => visibility.isVisible(SessionChatPillKind.Customizations, reader) + ? availableCustomizations.read(reader) + : []); + + // Every section-backed pill lives in the same toolbar, so the whole row is + // one tab stop with arrow-key navigation instead of one stop per pill. + const sectionPills: readonly { readonly pill: IObservable; readonly sections: IObservable }[] = [ + { pill: sectionPill(SESSION_CUSTOMIZATIONS_PILL_ID, localize('sessionChatPills.customizations', "Customizations"), customizationSections, chatCustomizationPillOptions), sections: customizationSections }, + { pill: sectionPill(SESSION_BROWSERS_PILL_ID, localize('sessionChatPills.browsers', "Browsers"), this._browsers.sections, sessionBrowsersPillOptions), sections: this._browsers.sections }, + { pill: sectionPill(SESSION_SUBAGENTS_PILL_ID, localize('sessionChatPills.subagents', "Subagents"), this._backgroundActivities.sections, sessionSubagentsPillOptions), sections: this._backgroundActivities.sections }, + ]; + + const pillsModel: IChatPillsModel = { + pills: derived(reader => [ + ...candidatePills.read(reader).filter(pill => { + const kind = getSessionChatPillKindForAction(pill.action.id); + return !kind || visibility.isVisible(kind, reader); + }), + ...sectionPills + .filter(entry => getChatPillEntries(entry.sections.read(reader)).length > 0) + .map(entry => entry.pill.read(reader)), + ]), + context: this._session, + }; + const actionRunner = this._register(new SessionActivatingActionRunner(() => this._session.get(), this._sessionsService)); + const pills = this._register(instantiationService.createInstance(ChatPillsWidget, pillsModel, { + actionRunner, + // The row's visibility menu must be reachable by right-clicking a pill, + // not just the empty space beside it. + allowContextMenu: true, + })); + pills.element.classList.add('show-file-icons'); + this._content.appendChild(pills.element); + + // Kinds the session reports data for; the others cannot be toggled. + const kindsWithData = derived(reader => { + const kinds = new Set(); + for (const pill of candidatePills.read(reader)) { + const kind = getSessionChatPillKindForAction(pill.action.id); + if (kind) { + kinds.add(kind); + } + } + if (this._browsers.hasData.read(reader)) { + kinds.add(SessionChatPillKind.Browsers); + } + if (this._backgroundActivities.hasData.read(reader)) { + kinds.add(SessionChatPillKind.Subagents); + } + if (hasCustomizations.read(reader)) { + kinds.add(SessionChatPillKind.Customizations); + } + return kinds; + }); + this._register(addDisposableListener(this._content, EventType.CONTEXT_MENU, (e: MouseEvent) => { + // The row owns its context menu, so never fall through to a native one. + e.preventDefault(); + e.stopPropagation(); - this._browsers = this._register(instantiationService.createInstance(SessionBrowsersControl, this._session, this._chat, turnStatusPillsEnabled)); - this.element.appendChild(this._browsers.element); + const kinds = kindsWithData.get(); + if (kinds.size === 0) { + return; + } + + const anchor = new StandardMouseEvent(getWindow(this._content), e); + const targetPill = pills.getPill(e.target as HTMLElement | null); + const targetKind = targetPill ? getSessionChatPillKindForAction(targetPill.action.id) : undefined; + this._contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => { + const menu = getSessionChatPillMenu(kinds, visibility.readHiddenKinds(undefined), targetKind); + const toggleAction = (entry: ISessionChatPillMenuEntry) => toAction({ + id: `sessions.chatPills.toggle.${entry.kind}`, + label: entry.label, + checked: entry.checked, + enabled: entry.enabled, + run: () => visibility.toggle(entry.kind), + }); + + const groups: IAction[][] = []; + if (menu.hide) { + const hide = menu.hide; + groups.push([toAction({ + id: `sessions.chatPills.hide.${hide.kind}`, + label: hide.label, + run: () => visibility.hide(hide.kind), + })]); + } + groups.push(menu.withData.map(toggleAction), menu.withoutData.map(toggleAction)); + return Separator.join(...groups); + }, + }); + })); - this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, turnStatusPillsEnabled)); - this.element.appendChild(this._backgroundActivities.element); + const resizeObserver = this._register(new DisposableResizeObserver('SessionChatInputToolbar.content', () => this._scrollable.scanDomNode())); + this._register(resizeObserver.observe(this._content)); + this._register(resizeObserver.observe(pills.element)); + this._register(addDisposableListener(this._content, EventType.FOCUS_IN, () => this._scrollable.scanDomNode())); this._register(autorun(reader => { - const anyVisible = pills.isVisible.read(reader) || this._browsers.isVisible.read(reader) || this._backgroundActivities.isVisible.read(reader); - this.element.classList.toggle('hidden', !anyVisible); + const anyVisible = pills.isVisible.read(reader); + // Keep the (empty) row present while hidden pills have data so its + // context menu stays reachable and they can be shown again. + const anyHidden = kindsWithData.read(reader).size > 0; + this.element.classList.toggle('hidden', !anyVisible && !anyHidden); + this.element.classList.toggle('empty', !anyVisible); + this._scrollable.scanDomNode(); })); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbarDebug.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbarDebug.ts index 8aa7028cbc2d9..86cdb7312323d 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbarDebug.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbarDebug.ts @@ -209,7 +209,7 @@ class SessionChatPillsDebugService extends Disposable implements ISessionChatPil disposables.add(autoIncrementCheckbox.onChange(() => state.autoIncrementChanges = autoIncrementCheckbox.checked)); disposables.add(DOM.addDisposableListener(autoIncrementLabelElement, DOM.EventType.CLICK, () => setAutoIncrement(!autoIncrementCheckbox.checked))); - this._createInput(form, disposables, localize('sessions.debug.chatPills.markdownFiles', "Markdown File Names"), state.markdownFiles, value => state.markdownFiles = value); + this._createInput(form, disposables, localize('sessions.debug.chatPills.artifactFiles', "Artifact File Names"), state.markdownFiles, value => state.markdownFiles = value); this._createInput(form, disposables, localize('sessions.debug.chatPills.subagents', "Subagent Names"), state.subagents, value => state.subagents = value); this._createInput(form, disposables, localize('sessions.debug.chatPills.browsers', "Browser Labels"), state.browsers, value => state.browsers = value); diff --git a/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts b/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts new file mode 100644 index 0000000000000..5d995534e7155 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionCustomizations.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import type { IChatDropdownPillOptions } from '../../../../workbench/browser/chatDropdownPill.js'; +import { type IChatPillEntry, type IChatPillSection } from '../../../../workbench/browser/chatPills.js'; +import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { ISessionChatCustomization, SessionCustomizationKind, type IChat } from '../../../services/sessions/common/session.js'; + +/** Action id of the customizations pill. */ +export const SESSION_CUSTOMIZATIONS_PILL_ID = 'sessions.chatPills.customizations'; + +/** Presentation of the customizations pill. */ +export const chatCustomizationPillOptions: IChatDropdownPillOptions = { + widgetId: 'chatCustomizations', + icon: Codicon.bookmark, + title: localize('chatCustomizations.title', "Customizations"), + summaryLabel: count => count === 1 + ? localize('chatCustomizations.countSingle', "1 Customization") + : localize('chatCustomizations.count', "{0} Customizations", count), + summaryAriaLabel: count => count === 1 + ? localize('chatCustomizations.showSingle', "Show 1 customization") + : localize('chatCustomizations.show', "Show {0} customizations", count), + alwaysSummarize: true, +}; + +const customizationIcons: ReadonlyMap = new Map([ + [SessionCustomizationKind.Agent, Codicon.robot], + [SessionCustomizationKind.Skill, Codicon.lightbulb], + [SessionCustomizationKind.Instruction, Codicon.book], + [SessionCustomizationKind.Hook, Codicon.plug], + [SessionCustomizationKind.Prompt, Codicon.commentDiscussion], + [SessionCustomizationKind.McpServer, Codicon.mcp], + [SessionCustomizationKind.Plugin, Codicon.extensions], +]); + +/** The customizations editor section each customization kind is revealed in. */ +const customizationSections: ReadonlyMap = new Map([ + [SessionCustomizationKind.Agent, AICustomizationManagementSection.Agents], + [SessionCustomizationKind.Skill, AICustomizationManagementSection.Skills], + [SessionCustomizationKind.Instruction, AICustomizationManagementSection.Instructions], + [SessionCustomizationKind.Hook, AICustomizationManagementSection.Hooks], + [SessionCustomizationKind.Prompt, AICustomizationManagementSection.Prompts], + [SessionCustomizationKind.McpServer, AICustomizationManagementSection.McpServers], + [SessionCustomizationKind.Plugin, AICustomizationManagementSection.Plugins], +]); + +/** Section order and titles for the customizations dropdown. */ +const sectionOrder: readonly { readonly kind: SessionCustomizationKind; readonly title: string }[] = [ + { kind: SessionCustomizationKind.Agent, title: localize('sessionCustomizations.agents', "Agents") }, + { kind: SessionCustomizationKind.Skill, title: localize('sessionCustomizations.skills', "Skills") }, + { kind: SessionCustomizationKind.Instruction, title: localize('sessionCustomizations.instructions', "Instructions") }, + { kind: SessionCustomizationKind.Hook, title: localize('sessionCustomizations.hooks', "Hooks") }, + { kind: SessionCustomizationKind.Prompt, title: localize('sessionCustomizations.prompts', "Prompts") }, + { kind: SessionCustomizationKind.McpServer, title: localize('sessionCustomizations.mcpServers', "MCP Servers") }, + { kind: SessionCustomizationKind.Plugin, title: localize('sessionCustomizations.plugins', "Plugins") }, +]; + +/** Builds the dropdown sections, preserving the order customizations appeared in. */ +export function buildSessionCustomizationSections( + customizations: readonly ISessionChatCustomization[], + reveal: (customization: ISessionChatCustomization) => void, +): readonly IChatPillSection[] { + const entriesByKind = new Map(); + for (const customization of customizations) { + const entries = entriesByKind.get(customization.kind) ?? []; + entries.push({ + id: customization.id, + label: customization.name, + icon: customizationIcons.get(customization.kind) ?? Codicon.bookmark, + open: () => reveal(customization), + }); + entriesByKind.set(customization.kind, entries); + } + + const sections: IChatPillSection[] = []; + for (const { kind, title } of sectionOrder) { + const entries = entriesByKind.get(kind); + if (entries?.length) { + sections.push({ title, entries }); + } + } + return sections; +} + +/** Publishes the active chat's customization sections for the chat input pill. */ +export class SessionCustomizations extends Disposable { + readonly sections: IObservable; + + constructor( + chat: IObservable, + @ICommandService private readonly _commandService: ICommandService, + ) { + super(); + + this.sections = derivedOpts({ owner: this, equalsFn: sectionsEqual }, reader => { + const customizations = chat.read(reader)?.customizations?.read(reader) ?? []; + return buildSessionCustomizationSections(customizations, customization => this._reveal(customization)); + }); + } + + private _reveal(customization: ISessionChatCustomization): void { + void this._commandService.executeCommand(AICustomizationManagementCommands.OpenEditor, { + section: customizationSections.get(customization.kind), + revealUri: customization.uri, + }); + } +} + +/** + * Entries are rebuilt on every recompute (their `open` closures are fresh), so + * compare the identity that actually drives rendering. + */ +function sectionsEqual(a: readonly IChatPillSection[], b: readonly IChatPillSection[]): boolean { + return a.length === b.length && a.every((section, i) => section.title === b[i].title + && section.entries.length === b[i].entries.length + && section.entries.every((entry, j) => entry.id === b[i].entries[j].id && entry.label === b[i].entries[j].label)); +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts b/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts new file mode 100644 index 0000000000000..ed3b9428d53fa --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionMetadataPills.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getWindow } from '../../../../base/browser/dom.js'; +import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, observableSignalFromEvent } from '../../../../base/common/observable.js'; +import { Event } from '../../../../base/common/event.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { IMenuService, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { ChatPillActionViewItem, IChatPill } from '../../../../workbench/browser/chatPills.js'; +import { Menus } from '../../../browser/menus.js'; +import { ISessionContext, SessionContext } from '../../../services/sessions/browser/sessionContext.js'; +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { setSessionContextKeys } from '../../../services/sessions/common/sessionContextKeys.js'; + +/** Adapts the session metadata menu to observable chat-pill descriptors. */ +export class SessionMetadataPills extends Disposable { + + readonly pills: IObservable; + + private readonly _scopedInstantiationService: IInstantiationService; + + constructor( + container: HTMLElement, + session: IObservable, + enabled: IObservable, + @IActionViewItemService private readonly _actionViewItemService: IActionViewItemService, + @IContextKeyService contextKeyService: IContextKeyService, + @IInstantiationService instantiationService: IInstantiationService, + @IMenuService menuService: IMenuService, + ) { + super(); + + const scopedContextKeyService = this._register(contextKeyService.createScoped(container)); + this._scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection( + [IContextKeyService, scopedContextKeyService], + [ISessionContext, new SessionContext(session)], + ))); + + this._register(autorun(reader => { + setSessionContextKeys(session.read(reader), scopedContextKeyService, reader); + })); + + const menu = this._register(menuService.createMenu(Menus.SessionHeaderMeta, scopedContextKeyService, { emitEventsForSubmenuChanges: true })); + const menuSignal = observableSignalFromEvent(this, Event.any( + menu.onDidChange, + Event.filter(this._actionViewItemService.onDidChange, menuId => menuId === Menus.SessionHeaderMeta), + )); + this.pills = derived(this, reader => { + menuSignal.read(reader); + if (!enabled.read(reader)) { + return []; + } + + return menu.getActions({ shouldForwardArgs: true }).flatMap(([group, actions]) => { + if (group !== 'navigation') { + return []; + } + return actions.map(action => ({ + action, + createActionViewItem: (options: IActionViewItemOptions) => { + const provider = this._actionViewItemService.lookUp( + Menus.SessionHeaderMeta, + action instanceof SubmenuItemAction ? action.item.submenu.id : action.id, + ); + return provider?.(action, options, this._scopedInstantiationService, getWindow(container).vscodeWindowId) + ?? this._scopedInstantiationService.createInstance(ChatPillActionViewItem, undefined, action, options); + }, + } satisfies IChatPill)); + }); + }); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 010395b285fc4..b7f588f023ed3 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -32,6 +32,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat const content: string[] = []; content.push(localize('sessionsChat.overview', "You are in the Agents window. The Agents window is a dedicated workspace for working with AI agents. It provides a chat interface, a changes view for reviewing agent-generated changes, a file explorer, and customization options.")); content.push(localize('sessionsChat.input', "You are in the chat input. Type a message and press Enter to send it.")); + content.push(localize('sessionsChat.inputPills', "When session metadata or active-turn status pills appear above the input, press Tab to reach them, use the Left and Right arrow keys to move between them, and press Enter or Space to activate one. Right-click a pill to choose which pills are shown.")); content.push(localize('sessionsChat.externalSessionFilter', "The Sessions list Filter menu includes an External submenu. Use it to choose whether external sessions from another application are shown for the last 24 hours, the last 7 days, always, or not at all.")); content.push(localize('sessionsChat.externalSessionBanner', "When you first open a session created in another application, a banner appears at the top of the chat. Use Tab to reach its external-session picker, choose an option, and activate Save. The Close action dismisses the banner without changing the setting. Saving or closing permanently dismisses the banner.")); content.push(localize('sessionsChat.promptOptions', "When prompt options appear above the new-session input, use Tab and Shift+Tab to move between them, then press Enter or Space to insert one. You can select a different option while the input is empty, exactly matches the inserted prompt, or only has its editable placeholder removed; other edits disable the options without hiding them. Clearing the input also clears the selected option. Use the Close action to hide the options and return focus to the input.")); diff --git a/src/vs/sessions/contrib/chat/common/sessionChatPills.ts b/src/vs/sessions/contrib/chat/common/sessionChatPills.ts new file mode 100644 index 0000000000000..d44449367ad6a --- /dev/null +++ b/src/vs/sessions/contrib/chat/common/sessionChatPills.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IReader } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { observableMemento, ObservableMemento } from '../../../../platform/observable/common/observableMemento.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +/** The kinds of pill shown above the chat input, each independently hideable. */ +export const enum SessionChatPillKind { + Changes = 'changes', + Artifacts = 'artifacts', + Customizations = 'customizations', + PullRequests = 'pullRequests', + Issues = 'issues', + Browsers = 'browsers', + Subagents = 'subagents', +} + +/** All pill kinds, in the order they are offered in the visibility menu. */ +export const SESSION_CHAT_PILL_KINDS: readonly SessionChatPillKind[] = [ + SessionChatPillKind.Changes, + SessionChatPillKind.Artifacts, + SessionChatPillKind.Customizations, + SessionChatPillKind.PullRequests, + SessionChatPillKind.Issues, + SessionChatPillKind.Browsers, + SessionChatPillKind.Subagents, +]; + +export function getSessionChatPillLabel(kind: SessionChatPillKind): string { + switch (kind) { + case SessionChatPillKind.Changes: return localize('sessionChatPills.changes', "Changes"); + case SessionChatPillKind.Artifacts: return localize('sessionChatPills.artifacts', "Artifacts"); + case SessionChatPillKind.Customizations: return localize('sessionChatPills.customizations', "Customizations"); + case SessionChatPillKind.PullRequests: return localize('sessionChatPills.pullRequests', "Pull Requests"); + case SessionChatPillKind.Issues: return localize('sessionChatPills.issues', "Issues"); + case SessionChatPillKind.Browsers: return localize('sessionChatPills.browsers', "Browsers"); + case SessionChatPillKind.Subagents: return localize('sessionChatPills.subagents', "Subagents"); + } +} + +/** + * Whether the user can hide a pill. Changes reports what the turn did to the + * user's files, so it always shows once it has data. + */ +export function isSessionChatPillHideable(kind: SessionChatPillKind): boolean { + return kind !== SessionChatPillKind.Changes; +} + +/** One entry of the pill visibility context menu. */ +export interface ISessionChatPillMenuEntry { + readonly kind: SessionChatPillKind; + readonly label: string; + /** Whether the pill shows when it has data. */ + readonly checked: boolean; + /** Kinds without data cannot be toggled. */ + readonly enabled: boolean; +} + +/** + * The pill visibility context menu: an optional "Hide X" for the pill that was + * right-clicked, then the kinds the session has data for, then the rest. The + * caller renders a separator between the groups it shows. + */ +export interface ISessionChatPillMenu { + readonly hide?: { readonly kind: SessionChatPillKind; readonly label: string }; + readonly withData: readonly ISessionChatPillMenuEntry[]; + readonly withoutData: readonly ISessionChatPillMenuEntry[]; +} + +/** + * Builds the visibility menu. Every hideable kind is listed, checked while it is + * not hidden, and disabled while the session reports no data for it. + * + * @param targetKind The pill that was right-clicked, which gains a "Hide X" + * entry. Omitted when the click did not land on a pill. + */ +export function getSessionChatPillMenu( + kindsWithData: ReadonlySet, + hiddenKinds: ReadonlySet, + targetKind?: SessionChatPillKind, +): ISessionChatPillMenu { + const withData: ISessionChatPillMenuEntry[] = []; + const withoutData: ISessionChatPillMenuEntry[] = []; + for (const kind of SESSION_CHAT_PILL_KINDS) { + if (!isSessionChatPillHideable(kind)) { + continue; + } + const enabled = kindsWithData.has(kind); + (enabled ? withData : withoutData).push({ + kind, + label: getSessionChatPillLabel(kind), + checked: !hiddenKinds.has(kind), + enabled, + }); + } + + const hide = targetKind !== undefined && isSessionChatPillHideable(targetKind) + ? { kind: targetKind, label: localize('sessionChatPills.hide', "Hide {0}", getSessionChatPillLabel(targetKind)) } + : undefined; + + return { ...(hide ? { hide } : {}), withData, withoutData }; +} + +/** + * Pills hidden until the user turns them on: useful but noisy enough that they + * should not claim room in the row by default. + */ +const defaultHiddenKinds: readonly SessionChatPillKind[] = [ + SessionChatPillKind.Customizations, + SessionChatPillKind.Subagents, +]; + +const hiddenSessionChatPills = observableMemento({ + defaultValue: defaultHiddenKinds, + key: 'sessions.chatPills.hidden', + toStorage: kinds => JSON.stringify(kinds), + fromStorage: value => { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) ? parsed.filter((kind): kind is string => typeof kind === 'string') : []; + }, +}); + +/** The user's per-kind pill visibility choices, persisted across windows. */ +export class SessionChatPillVisibility extends Disposable { + + private readonly _hiddenKinds: ObservableMemento; + + constructor( + @IStorageService storageService: IStorageService, + ) { + super(); + this._hiddenKinds = this._register(hiddenSessionChatPills(StorageScope.APPLICATION, StorageTarget.USER, storageService)); + } + + readHiddenKinds(reader: IReader | undefined): ReadonlySet { + return new Set((this._hiddenKinds.read(reader) as readonly SessionChatPillKind[]).filter(isSessionChatPillHideable)); + } + + isVisible(kind: SessionChatPillKind, reader: IReader | undefined): boolean { + return !isSessionChatPillHideable(kind) || !this._hiddenKinds.read(reader).includes(kind); + } + + hide(kind: SessionChatPillKind): void { + if (isSessionChatPillHideable(kind) && !this._hiddenKinds.get().includes(kind)) { + this._hiddenKinds.set([...this._hiddenKinds.get(), kind], undefined); + } + } + + toggle(kind: SessionChatPillKind): void { + if (!isSessionChatPillHideable(kind)) { + return; + } + const hidden = this._hiddenKinds.get(); + this._hiddenKinds.set(hidden.includes(kind) ? hidden.filter(hiddenKind => hiddenKind !== kind) : [...hidden, kind], undefined); + } +} diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundActivitiesControl.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundActivitiesControl.test.ts index 1dcdbde878555..d7dfa6e28445c 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundActivitiesControl.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundActivitiesControl.test.ts @@ -4,13 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Codicon } from '../../../../../base/common/codicons.js'; import { constObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; -import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -21,21 +18,15 @@ interface IControlSpec { readonly subagents?: readonly string[]; readonly subagentStatus?: SessionStatus; readonly enabled?: boolean; + /** Whether the user keeps the subagents pill visible. */ + readonly visible?: boolean; } interface IControlHarness { readonly control: SessionBackgroundActivitiesControl; - readonly getPickerItems: () => readonly ICapturedPickerItem[]; readonly getOpenedChat: () => URI | undefined; } -interface ICapturedPickerItem { - readonly kind: ActionListItemKind; - readonly label: string; - readonly category: string; - readonly icon: string; -} - function createControl(spec: IControlSpec, store: ReturnType): IControlHarness { const mainChat = new class extends mock() { override readonly resource = URI.parse('chat:main'); @@ -53,20 +44,6 @@ function createControl(spec: IControlSpec, store: ReturnType() { - override get isVisible() { return false; } - override hide(): void { } - override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], _delegate: IActionListDelegate): void { - pickerItems = items.map(item => ({ - kind: item.kind, - label: item.label ?? '', - category: item.group?.title ?? '', - icon: item.group?.icon?.id ?? '', - })); - } - }(); - let openedChat: URI | undefined; const sessionsService = new class extends mock() { override async openChat(_session: ISession, chatUri: URI): Promise { @@ -78,32 +55,20 @@ function createControl(spec: IControlSpec, store: ReturnType pickerItems, - getOpenedChat: () => openedChat, - }; + return { control, getOpenedChat: () => openedChat }; } -function summarize(control: SessionBackgroundActivitiesControl): { readonly text: string; readonly ariaLabel: string | null; readonly icons: readonly string[] } { - const button = control.element.querySelector('.session-activity-pill-button')!; - const knownIcons = [Codicon.globe, Codicon.agent, Codicon.sessionInProgress, Codicon.chevronDown]; - return { - text: button.textContent ?? '', - ariaLabel: button.getAttribute('aria-label'), - icons: [...button.querySelectorAll('.codicon')] - .map(element => knownIcons.find(icon => element.classList.contains(`codicon-${icon.id}`))?.id ?? 'unknown'), - }; +/** The sections the control publishes, reduced to what the pill renders from. */ +function sections(control: SessionBackgroundActivitiesControl): readonly { readonly title: string; readonly entries: readonly { readonly label: string; readonly icon: string }[] }[] { + return control.sections.get().map(section => ({ + title: section.title, + entries: section.entries.map(entry => ({ label: entry.label, icon: entry.icon?.id ?? '' })), + })); } - -function click(control: SessionBackgroundActivitiesControl): void { - control.element.querySelector('.session-activity-pill-button')!.click(); -} - suite('SessionBackgroundActivitiesControl', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -139,36 +104,43 @@ suite('SessionBackgroundActivitiesControl', () => { }); }); - test('renders single and aggregate labels, icons, and subagent truncation', () => { + test('publishes running subagents as one section, truncating long labels', () => { const cases: IControlSpec[] = [ { subagents: ['Research'] }, { subagents: ['Investigate the authentication failure in production'] }, { subagents: ['Research', 'Review'] }, ]; - const disabled = createControl({ subagents: ['Research'], enabled: false }, store); assert.deepStrictEqual({ - enabled: cases.map(spec => summarize(createControl(spec, store).control)), - disabledVisible: disabled.control.isVisible.get(), + sections: cases.map(spec => sections(createControl(spec, store).control)), + disabled: sections(createControl({ subagents: ['Research'], enabled: false }, store).control), }, { - enabled: [ - { text: 'Research', ariaLabel: 'Open Research', icons: ['agent'] }, - { text: 'Investigate the authentication...', ariaLabel: 'Open Investigate the authentication...', icons: ['agent'] }, - { text: '2 Active Subagents', ariaLabel: 'Show 2 background activities', icons: ['agent', 'chevron-down'] }, + sections: [ + [{ title: 'Subagents', entries: [{ label: 'Research', icon: 'agent' }] }], + [{ title: 'Subagents', entries: [{ label: 'Investigate the authentication...', icon: 'agent' }] }], + [{ title: 'Subagents', entries: [{ label: 'Research', icon: 'agent' }, { label: 'Review', icon: 'agent' }] }], ], - disabledVisible: false, + disabled: [], }); }); - test('keeps subagents visible while they need input', () => { + test('keeps subagents listed while they need input', () => { const harness = createControl({ subagents: ['Waiting'], subagentStatus: SessionStatus.NeedsInput }, store); + assert.deepStrictEqual(sections(harness.control), [ + { title: 'Subagents', entries: [{ label: 'Waiting', icon: 'agent' }] }, + ]); + }); + + test('still reports data while the user hides the pill, so it can be shown again', () => { + const harness = createControl({ subagents: ['Research'], visible: false }, store); + assert.deepStrictEqual({ - visible: harness.control.isVisible.get(), - summary: summarize(harness.control), + sections: sections(harness.control), + hasData: harness.control.hasData.get(), }, { - visible: true, - summary: { text: 'Waiting', ariaLabel: 'Open Waiting', icons: ['agent'] }, + sections: [], + hasData: true, }); }); @@ -185,31 +157,19 @@ suite('SessionBackgroundActivitiesControl', () => { agentFeedback: 4, autoIncrementChanges: false, }); - const forced = summarize(harness.control); + const forced = sections(harness.control); harness.control.setDebugData(undefined); - assert.deepStrictEqual({ forced, visibleAfterClear: harness.control.isVisible.get() }, { - forced: { text: 'Debug Subagent', ariaLabel: 'Open Debug Subagent', icons: ['agent'] }, - visibleAfterClear: false, + assert.deepStrictEqual({ forced, afterClear: sections(harness.control) }, { + forced: [{ title: 'Subagents', entries: [{ label: 'Debug Subagent', icon: 'agent' }] }], + afterClear: [], }); }); - test('lists subagents in a picker under a category header', () => { - const harness = createControl({ subagents: ['Research', 'Review'] }, store); - - click(harness.control); - - assert.deepStrictEqual(harness.getPickerItems(), [ - { kind: ActionListItemKind.Header, label: 'Subagents', category: 'Subagents', icon: '' }, - { kind: ActionListItemKind.Action, label: 'Research', category: '', icon: Codicon.agent.id }, - { kind: ActionListItemKind.Action, label: 'Review', category: '', icon: Codicon.agent.id }, - ]); - }); - - test('opens a single subagent directly', () => { + test('opening an entry opens that subagent chat', () => { const harness = createControl({ subagents: ['Research'] }, store); - click(harness.control); + harness.control.sections.get()[0].entries[0].open(); assert.deepStrictEqual(harness.getOpenedChat()?.toString(), 'chat:subagent-0'); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts index dbc12585cfb30..b8b03fa95f07b 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts @@ -4,14 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Codicon } from '../../../../../base/common/codicons.js'; import { Event } from '../../../../../base/common/event.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; -import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; import { BrowserViewSharingState, IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../../workbench/contrib/browserView/common/browserView.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; @@ -27,27 +24,19 @@ interface IControlSpec { readonly sharingState?: BrowserViewSharingState; }[]; readonly enabled?: boolean; + /** Whether the user keeps the browsers pill visible. */ + readonly visible?: boolean; /** Start with only the main chat, so the subagent can be added later. */ readonly withoutSubagent?: boolean; } interface IControlHarness { readonly control: SessionBrowsersControl; - readonly getPickerItems: () => readonly ICapturedPickerItem[]; - readonly selectPickerItem: (label: string) => void; readonly getBrowserOpenCount: () => number; readonly getOpenedBrowserId: () => string | undefined; readonly addSubagent: () => void; } -interface ICapturedPickerItem { - readonly kind: ActionListItemKind; - readonly label: string; - readonly category: string; - readonly icon: string; - readonly select?: () => void; -} - function createControl(spec: IControlSpec, store: ReturnType): IControlHarness { const mainChat = new class extends mock() { override readonly resource = URI.parse('chat:main'); @@ -90,31 +79,6 @@ function createControl(spec: IControlSpec, store: ReturnType() { - override get isVisible() { return false; } - override hide(): void { } - override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate): void { - pickerItems = items.map(item => { - const value = item.item; - return { - kind: item.kind, - label: item.label ?? '', - category: item.group?.title ?? '', - icon: item.group?.icon?.id ?? '', - select: value === undefined ? undefined : () => delegate.onSelect(value), - }; - }); - } - }(); - const selectPickerItem = (label: string) => { - const item = pickerItems.find(item => item.label === label && item.select); - if (!item?.select) { - throw new Error(`Picker item '${label}' not found`); - } - item.select(); - }; - let browserOpenCount = 0; let openedBrowserId: string | undefined; const browserIds = new Map(inputs.map(input => [input, input.id])); @@ -131,58 +95,58 @@ function createControl(spec: IControlSpec, store: ReturnType pickerItems, - selectPickerItem, getBrowserOpenCount: () => browserOpenCount, getOpenedBrowserId: () => openedBrowserId, addSubagent: () => chats.set([mainChat, subagent], undefined), }; } -function summarize(control: SessionBrowsersControl): { readonly text: string; readonly ariaLabel: string | null; readonly icons: readonly string[] } { - const button = control.element.querySelector('.session-activity-pill-button')!; - const knownIcons = [Codicon.globe, Codicon.agent, Codicon.sessionInProgress, Codicon.chevronDown]; - return { - text: button.textContent ?? '', - ariaLabel: button.getAttribute('aria-label'), - icons: [...button.querySelectorAll('.codicon')] - .map(element => knownIcons.find(icon => element.classList.contains(`codicon-${icon.id}`))?.id ?? 'unknown'), - }; +/** The sections the control publishes, reduced to what the pill renders from. */ +function sections(control: SessionBrowsersControl): readonly { readonly title: string; readonly entries: readonly { readonly label: string; readonly icon: string }[] }[] { + return control.sections.get().map(section => ({ + title: section.title, + entries: section.entries.map(entry => ({ label: entry.label, icon: entry.icon?.id ?? '' })), + })); } -function click(control: SessionBrowsersControl): void { - control.element.querySelector('.session-activity-pill-button')!.click(); +/** Opens an entry, as the pill does on click or on selecting a dropdown row. */ +function openEntry(control: SessionBrowsersControl, label?: string): void { + const entries = control.sections.get().flatMap(section => section.entries); + const entry = label ? entries.find(candidate => candidate.label === label) : entries[0]; + if (!entry) { + throw new Error(`Browser entry '${label ?? ''}' not found`); + } + entry.open(); } suite('SessionBrowsersControl', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - test('renders single and aggregate labels, icons, and fallback', () => { + test('publishes browser entries with a fallback label', () => { const cases: IControlSpec[] = [ { browsers: [{ title: 'Visual Studio Code' }] }, { browsers: [{}] }, { browsers: [{ title: 'Docs' }, { title: 'Preview' }] }, ]; - const disabled = createControl({ browsers: [{ title: 'Hidden browser' }], enabled: false }, store); assert.deepStrictEqual({ - enabled: cases.map(spec => summarize(createControl(spec, store).control)), - disabledVisible: disabled.control.isVisible.get(), + enabled: cases.map(spec => sections(createControl(spec, store).control)), + disabled: sections(createControl({ browsers: [{ title: 'Hidden browser' }], enabled: false }, store).control), }, { enabled: [ - { text: 'Visual Studio Code', ariaLabel: 'Open Visual Studio Code', icons: ['globe'] }, - { text: 'Browser', ariaLabel: 'Open Browser', icons: ['globe'] }, - { text: '2 Active Browsers', ariaLabel: 'Show 2 browsers', icons: ['globe', 'chevron-down'] }, + [{ title: 'Browsers', entries: [{ label: 'Visual Studio Code', icon: 'globe' }] }], + [{ title: 'Browsers', entries: [{ label: 'Browser', icon: 'globe' }] }], + [{ title: 'Browsers', entries: [{ label: 'Docs', icon: 'globe' }, { label: 'Preview', icon: 'globe' }] }], ], - disabledVisible: false, + disabled: [], }); }); @@ -199,12 +163,12 @@ suite('SessionBrowsersControl', () => { agentFeedback: 4, autoIncrementChanges: false, }); - const forced = summarize(harness.control); + const forced = sections(harness.control); harness.control.setDebugData(undefined); - assert.deepStrictEqual({ forced, visibleAfterClear: harness.control.isVisible.get() }, { - forced: { text: 'Debug Browser', ariaLabel: 'Open Debug Browser', icons: ['globe'] }, - visibleAfterClear: false, + assert.deepStrictEqual({ forced, afterClear: sections(harness.control) }, { + forced: [{ title: 'Browsers', entries: [{ label: 'Debug Browser', icon: 'globe' }] }], + afterClear: [], }); }); @@ -217,37 +181,35 @@ suite('SessionBrowsersControl', () => { ], }, store); - click(harness.control); - harness.selectPickerItem('Subagent Preview'); + openEntry(harness.control, 'Subagent Preview'); await Promise.resolve(); assert.deepStrictEqual({ - items: harness.getPickerItems().map(({ select: _select, ...item }) => item), + sections: sections(harness.control), openedBrowser: harness.getOpenedBrowserId(), }, { - items: [ - { kind: ActionListItemKind.Header, label: 'Browsers', category: 'Browsers', icon: '' }, - { kind: ActionListItemKind.Action, label: 'Docs', category: '', icon: Codicon.globe.id }, - { kind: ActionListItemKind.Action, label: 'Subagent Preview', category: '', icon: Codicon.globe.id }, - ], + sections: [{ + title: 'Browsers', + entries: [{ label: 'Docs', icon: 'globe' }, { label: 'Subagent Preview', icon: 'globe' }], + }], openedBrowser: 'browser-1', }); }); test('shows a subagent browser registered before the subagent joins the session', () => { const harness = createControl({ browsers: [{ title: 'Subagent Preview', owner: 'subagent' }], withoutSubagent: true }, store); - const beforeJoin = harness.control.isVisible.get(); + const beforeJoin = sections(harness.control); harness.addSubagent(); - assert.deepStrictEqual({ beforeJoin, afterJoin: summarize(harness.control) }, { - beforeJoin: false, - afterJoin: { text: 'Subagent Preview', ariaLabel: 'Open Subagent Preview', icons: ['globe'] }, + assert.deepStrictEqual({ beforeJoin, afterJoin: sections(harness.control) }, { + beforeJoin: [], + afterJoin: [{ title: 'Browsers', entries: [{ label: 'Subagent Preview', icon: 'globe' }] }], }); }); test('opens a single browser directly', async () => { const harness = createControl({ browsers: [{ title: 'Preview' }] }, store); - click(harness.control); + openEntry(harness.control); await Promise.resolve(); assert.deepStrictEqual({ @@ -266,7 +228,7 @@ suite('SessionBrowsersControl', () => { { title: 'Shared Host', url: 'https://example.com/live', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, ], }, store); - click(sharedHost.control); + openEntry(sharedHost.control, 'Normal'); await Promise.resolve(); const sharedExact = createControl({ @@ -276,7 +238,7 @@ suite('SessionBrowsersControl', () => { { title: 'Shared Exact', url: 'https://example.com/start', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, ], }, store); - click(sharedExact.control); + openEntry(sharedExact.control, 'Normal'); await Promise.resolve(); const fallback = createControl({ @@ -285,7 +247,7 @@ suite('SessionBrowsersControl', () => { { title: 'Unrelated Shared', url: 'https://other.test/live', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, ], }, store); - click(fallback.control); + openEntry(fallback.control, 'Normal'); await Promise.resolve(); assert.deepStrictEqual({ diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts new file mode 100644 index 0000000000000..ccf9e8130b85d --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/sessionChatInputToolbar.test.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { CHAT_TURN_ARTIFACT_PILL_ID, CHAT_TURN_CHANGES_PILL_ID } from '../../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { VIEW_SESSION_CHANGES_COMMAND_ID } from '../../../changes/common/changes.js'; +import { OPEN_ISSUE_ACTION_ID, OPEN_PULL_REQUEST_ACTION_ID } from '../../../github/common/types.js'; +import { SessionChatPillKind } from '../../common/sessionChatPills.js'; +import { getSessionChatPillKindForAction, SESSION_BROWSERS_PILL_ID, SESSION_SUBAGENTS_PILL_ID, shouldShowSessionTurnPills } from '../../browser/sessionChatInputToolbar.js'; +import { SESSION_CUSTOMIZATIONS_PILL_ID } from '../../browser/sessionCustomizations.js'; + +suite('SessionChatInputToolbar', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('maps turn-status, contributed metadata and hosted pill actions onto togglable pill kinds', () => { + assert.deepStrictEqual([ + getSessionChatPillKindForAction(CHAT_TURN_CHANGES_PILL_ID), + getSessionChatPillKindForAction(VIEW_SESSION_CHANGES_COMMAND_ID), + getSessionChatPillKindForAction(CHAT_TURN_ARTIFACT_PILL_ID), + getSessionChatPillKindForAction(SESSION_CUSTOMIZATIONS_PILL_ID), + getSessionChatPillKindForAction(OPEN_PULL_REQUEST_ACTION_ID), + getSessionChatPillKindForAction(OPEN_ISSUE_ACTION_ID), + getSessionChatPillKindForAction(SESSION_BROWSERS_PILL_ID), + getSessionChatPillKindForAction(SESSION_SUBAGENTS_PILL_ID), + getSessionChatPillKindForAction('workbench.agentSessions.action.openFilesView'), + ], [ + SessionChatPillKind.Changes, + SessionChatPillKind.Changes, + SessionChatPillKind.Artifacts, + SessionChatPillKind.Customizations, + SessionChatPillKind.PullRequests, + SessionChatPillKind.Issues, + SessionChatPillKind.Browsers, + SessionChatPillKind.Subagents, + undefined, + ]); + }); + + test('keeps last-turn pills visible after completion only in metadata-input placement', () => { + assert.deepStrictEqual([ + shouldShowSessionTurnPills(false, false, false, true), + shouldShowSessionTurnPills(false, false, true, true), + shouldShowSessionTurnPills(false, true, false, true), + shouldShowSessionTurnPills(false, false, true, false), + ], [ + false, + true, + true, + false, + ]); + }); +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionCustomizations.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionCustomizations.test.ts new file mode 100644 index 0000000000000..30e3327e4f6b7 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/sessionCustomizations.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { buildSessionCustomizationSections } from '../../browser/sessionCustomizations.js'; +import { ISessionChatCustomization, SessionCustomizationKind } from '../../../../services/sessions/common/session.js'; + +suite('Session Customizations', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const customization = (id: string, kind: SessionCustomizationKind, name: string): ISessionChatCustomization => + ({ id, kind, name, uri: URI.file(`/repo/${id}.md`) }); + + test('groups into typed sections in a fixed order, keeping arrival order within a section', () => { + const sections = buildSessionCustomizationSections([ + customization('c1', SessionCustomizationKind.Hook, 'pre-commit'), + customization('c2', SessionCustomizationKind.Skill, 'sessions'), + customization('c3', SessionCustomizationKind.Instruction, 'writing-tests'), + customization('c4', SessionCustomizationKind.Skill, 'unit-tests'), + customization('c5', SessionCustomizationKind.Agent, 'rubber-duck'), + ], () => { }); + + assert.deepStrictEqual(sections.map(section => ({ title: section.title, entries: section.entries.map(entry => entry.label) })), [ + { title: 'Agents', entries: ['rubber-duck'] }, + { title: 'Skills', entries: ['sessions', 'unit-tests'] }, + { title: 'Instructions', entries: ['writing-tests'] }, + { title: 'Hooks', entries: ['pre-commit'] }, + ]); + }); + + test('activating an entry reveals its customization', () => { + const revealed: string[] = []; + const sections = buildSessionCustomizationSections( + [customization('c1', SessionCustomizationKind.Skill, 'sessions')], + target => revealed.push(target.id), + ); + sections[0].entries[0].open(); + + assert.deepStrictEqual(revealed, ['c1']); + }); + + test('no customizations yields no sections', () => { + assert.deepStrictEqual(buildSessionCustomizationSections([], () => { }), []); + }); +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsOpenerParticipant.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsOpenerParticipant.test.ts index 846250205862c..1a1f5b6ce1024 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsOpenerParticipant.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsOpenerParticipant.test.ts @@ -9,6 +9,7 @@ import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { openSessionByResource } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.js'; import { SessionsOpenerParticipantContribution } from '../../browser/sessionsOpenerParticipant.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; @@ -22,6 +23,7 @@ suite('SessionsOpenerParticipant', () => { test('opens a sessions-layer resource without a legacy agent session', async () => { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IAgentHostConnectionsService, upcastPartial({ ambientConnection: undefined })); const resource = URI.parse('agent-host-copilotcli://provider/session'); const session = upcastPartial({ resource }); instantiationService.stub(ISessionsManagementService, upcastPartial({ diff --git a/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts b/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts new file mode 100644 index 0000000000000..f3d44b8f17bc3 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/common/sessionChatPills.test.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; +import { getSessionChatPillMenu, SessionChatPillKind, SessionChatPillVisibility } from '../../common/sessionChatPills.js'; + +suite('SessionChatPills', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('groups kinds with data ahead of those without, and omits the never-hideable Changes pill', () => { + const menu = getSessionChatPillMenu( + new Set([SessionChatPillKind.Changes, SessionChatPillKind.PullRequests, SessionChatPillKind.Subagents]), + new Set([SessionChatPillKind.PullRequests]), + ); + + assert.deepStrictEqual(menu, { + withData: [ + { kind: SessionChatPillKind.PullRequests, label: 'Pull Requests', checked: false, enabled: true }, + { kind: SessionChatPillKind.Subagents, label: 'Subagents', checked: true, enabled: true }, + ], + withoutData: [ + { kind: SessionChatPillKind.Artifacts, label: 'Artifacts', checked: true, enabled: false }, + { kind: SessionChatPillKind.Customizations, label: 'Customizations', checked: true, enabled: false }, + { kind: SessionChatPillKind.Issues, label: 'Issues', checked: true, enabled: false }, + { kind: SessionChatPillKind.Browsers, label: 'Browsers', checked: true, enabled: false }, + ], + }); + }); + + test('offers Hide for the right-clicked pill, but never for Changes', () => { + const kindsWithData = new Set([SessionChatPillKind.Changes, SessionChatPillKind.Issues]); + + assert.deepStrictEqual({ + issues: getSessionChatPillMenu(kindsWithData, new Set(), SessionChatPillKind.Issues).hide, + changes: getSessionChatPillMenu(kindsWithData, new Set(), SessionChatPillKind.Changes).hide, + noTarget: getSessionChatPillMenu(kindsWithData, new Set()).hide, + }, { + issues: { kind: SessionChatPillKind.Issues, label: 'Hide Issues' }, + changes: undefined, + noTarget: undefined, + }); + }); + + test('hides customizations and subagents by default, and always shows changes', () => { + const visibility = disposables.add(new SessionChatPillVisibility(disposables.add(new TestStorageService()))); + + assert.deepStrictEqual({ + customizations: visibility.isVisible(SessionChatPillKind.Customizations, undefined), + subagents: visibility.isVisible(SessionChatPillKind.Subagents, undefined), + artifacts: visibility.isVisible(SessionChatPillKind.Artifacts, undefined), + changes: visibility.isVisible(SessionChatPillKind.Changes, undefined), + }, { + customizations: false, + subagents: false, + artifacts: true, + changes: true, + }); + }); + + test('changes cannot be hidden or toggled off', () => { + const visibility = disposables.add(new SessionChatPillVisibility(disposables.add(new TestStorageService()))); + visibility.hide(SessionChatPillKind.Changes); + visibility.toggle(SessionChatPillKind.Changes); + + assert.deepStrictEqual({ + visible: visibility.isVisible(SessionChatPillKind.Changes, undefined), + hiddenKinds: [...visibility.readHiddenKinds(undefined)], + }, { + visible: true, + hiddenKinds: [SessionChatPillKind.Customizations, SessionChatPillKind.Subagents], + }); + }); + + test('hides a pill, then toggles it off and on again, persisting the choice', () => { + const storageService = disposables.add(new TestStorageService()); + const visibility = disposables.add(new SessionChatPillVisibility(storageService)); + + const initiallyVisible = visibility.isVisible(SessionChatPillKind.PullRequests, undefined); + visibility.hide(SessionChatPillKind.PullRequests); + const afterHide = { + pullRequests: visibility.isVisible(SessionChatPillKind.PullRequests, undefined), + issues: visibility.isVisible(SessionChatPillKind.Issues, undefined), + restored: disposables.add(new SessionChatPillVisibility(storageService)).isVisible(SessionChatPillKind.PullRequests, undefined), + }; + // Hiding an already-hidden pill is a no-op, so one toggle brings it back. + visibility.hide(SessionChatPillKind.PullRequests); + visibility.toggle(SessionChatPillKind.PullRequests); + + assert.deepStrictEqual({ + initiallyVisible, + afterHide, + afterShow: visibility.isVisible(SessionChatPillKind.PullRequests, undefined), + }, { + initiallyVisible: true, + afterHide: { pullRequests: false, issues: true, restored: false }, + afterShow: true, + }); + }); +}); diff --git a/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts b/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts index 5b4f7a86b07df..5d7e4c4300666 100644 --- a/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts +++ b/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts @@ -23,12 +23,13 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; -import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; +import { getSessionWorkspaceDisplayInfo, ISessionWorkspaceDisplayInfo } from '../../../browser/sessionWorkspace.js'; +import { ChatPillActionViewItem } from '../../../../workbench/browser/chatPills.js'; import { SessionHasWorkspaceContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; import { NEW_FILE_TAB_COMMAND_ID } from '../../../common/sessionCommands.js'; +import { SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING } from '../../../common/sessionConfig.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { getSessionWorkspaceKind, SessionWorkspaceKind } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { SESSIONS_FILES_VIEW_ID } from './filesView.js'; @@ -50,7 +51,11 @@ export class OpenFilesViewAction extends Action2 { id: Menus.SessionHeaderMeta, group: 'navigation', order: -10, - when: ContextKeyExpr.and(SessionHasWorkspaceContext, IsQuickChatSessionContext.negate()) + when: ContextKeyExpr.and( + SessionHasWorkspaceContext, + IsQuickChatSessionContext.negate(), + ContextKeyExpr.notEquals(`config.${SHOW_SESSION_METADATA_IN_CHAT_INPUT_SETTING}`, true), + ) }, }); } @@ -80,24 +85,15 @@ registerAction2(OpenFilesViewAction); // --- Open Files view action view item (session header workspace folder pill) -interface IWorkspaceInfo { - readonly label: string; - readonly icon: ThemeIcon; - readonly workingDirectoryPath: string | undefined; - readonly branch: string | undefined; - /** The session's worktree does not exist yet, so path and branch are unknown. */ - readonly worktreePending: boolean; -} - /** * Renders the session's workspace folder as a `