From 21783d3a4612b527fcc1910603b122652fac48f1 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 5 Jun 2026 11:51:53 +0100 Subject: [PATCH 01/29] Refactor CSS to use `--vscode-shadow-lg` (#320071) refactor: replace `--vscode-shadow-hover` with `--vscode-shadow-lg` in multiple CSS files Co-authored-by: mrleemurray Co-authored-by: Copilot --- build/lib/stylelint/vscode-known-variables.json | 1 - src/vs/editor/contrib/hover/browser/hover.css | 2 +- src/vs/editor/contrib/peekView/browser/media/peekViewWidget.css | 2 +- src/vs/editor/contrib/rename/browser/renameWidget.css | 2 +- src/vs/platform/hover/browser/hover.css | 2 +- src/vs/workbench/browser/media/style.css | 1 - 6 files changed, 4 insertions(+), 6 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 602095e97f5c0..ae3a75e91d8af 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1020,7 +1020,6 @@ "--vscode-shadow-active-tab", "--vscode-shadow-depth-x", "--vscode-shadow-depth-y", - "--vscode-shadow-hover", "--vscode-shadow-lg", "--vscode-shadow-md", "--vscode-shadow-sm", diff --git a/src/vs/editor/contrib/hover/browser/hover.css b/src/vs/editor/contrib/hover/browser/hover.css index 269ee853c7ec2..e18e8f66b022f 100644 --- a/src/vs/editor/contrib/hover/browser/hover.css +++ b/src/vs/editor/contrib/hover/browser/hover.css @@ -25,7 +25,7 @@ border-radius: var(--vscode-cornerRadius-large); color: var(--vscode-editorHoverWidget-foreground); background-color: var(--vscode-editorHoverWidget-background); - box-shadow: var(--vscode-shadow-hover); + box-shadow: var(--vscode-shadow-lg); } .monaco-editor .monaco-hover a { diff --git a/src/vs/editor/contrib/peekView/browser/media/peekViewWidget.css b/src/vs/editor/contrib/peekView/browser/media/peekViewWidget.css index eb777d9ac7f42..6dcab18c0175c 100644 --- a/src/vs/editor/contrib/peekView/browser/media/peekViewWidget.css +++ b/src/vs/editor/contrib/peekView/browser/media/peekViewWidget.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ .monaco-editor .peekview-widget { - box-shadow: var(--vscode-shadow-hover); + box-shadow: var(--vscode-shadow-lg); } .monaco-editor .peekview-widget .head { diff --git a/src/vs/editor/contrib/rename/browser/renameWidget.css b/src/vs/editor/contrib/rename/browser/renameWidget.css index 730bf8895b8fc..9acce9a4b7154 100644 --- a/src/vs/editor/contrib/rename/browser/renameWidget.css +++ b/src/vs/editor/contrib/rename/browser/renameWidget.css @@ -7,7 +7,7 @@ z-index: 100; color: inherit; border-radius: 4px; - box-shadow: var(--vscode-shadow-hover); + box-shadow: var(--vscode-shadow-lg); } .monaco-editor .rename-box.preview { diff --git a/src/vs/platform/hover/browser/hover.css b/src/vs/platform/hover/browser/hover.css index 9a6b49d73fe90..26288189fc535 100644 --- a/src/vs/platform/hover/browser/hover.css +++ b/src/vs/platform/hover/browser/hover.css @@ -17,7 +17,7 @@ border: 1px solid var(--vscode-editorHoverWidget-border); border-radius: 5px; color: var(--vscode-editorHoverWidget-foreground); - box-shadow: var(--vscode-shadow-hover); + box-shadow: var(--vscode-shadow-lg); } .monaco-hover.workbench-hover.with-pointer { diff --git a/src/vs/workbench/browser/media/style.css b/src/vs/workbench/browser/media/style.css index 1f6e583f5e0be..b04b3c70daf61 100644 --- a/src/vs/workbench/browser/media/style.css +++ b/src/vs/workbench/browser/media/style.css @@ -59,7 +59,6 @@ body { --vscode-shadow-md: 0 0 6px rgba(0, 0, 0, 0.08); --vscode-shadow-lg: 0 0 12px rgba(0, 0, 0, 0.14); --vscode-shadow-xl: 0 0 20px rgba(0, 0, 0, 0.15); - --vscode-shadow-hover: 0 0 8px rgba(0, 0, 0, 0.12); --vscode-shadow-active-tab: 0 8px 12px rgba(0, 0, 0, 0.02); /* Panel depth shadows cast onto the editor surface */ From d97df0aaf5233e4ffa8c71f346059af62acbf83e Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 5 Jun 2026 12:11:15 +0100 Subject: [PATCH 02/29] Add spacing size variables to theme sizes (#320073) * Add spacing size variables to theme sizes for consistent layout Co-authored-by: Copilot * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: mrleemurray Co-authored-by: Copilot Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../lib/stylelint/vscode-known-variables.json | 14 +++++ .../platform/theme/common/sizes/baseSizes.ts | 62 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index ae3a75e91d8af..0fa08c6bb41dd 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1103,6 +1103,20 @@ "--vscode-cornerRadius-xLarge", "--vscode-cornerRadius-xSmall", "--vscode-keyboard-height", + "--vscode-spacing-sizeNone", + "--vscode-spacing-size20", + "--vscode-spacing-size40", + "--vscode-spacing-size60", + "--vscode-spacing-size80", + "--vscode-spacing-size100", + "--vscode-spacing-size120", + "--vscode-spacing-size160", + "--vscode-spacing-size200", + "--vscode-spacing-size240", + "--vscode-spacing-size280", + "--vscode-spacing-size320", + "--vscode-spacing-size360", + "--vscode-spacing-size400", "--vscode-strokeThickness" ] } diff --git a/src/vs/platform/theme/common/sizes/baseSizes.ts b/src/vs/platform/theme/common/sizes/baseSizes.ts index d6563dd846a2b..b783ac1d4aa41 100644 --- a/src/vs/platform/theme/common/sizes/baseSizes.ts +++ b/src/vs/platform/theme/common/sizes/baseSizes.ts @@ -59,3 +59,65 @@ export const cornerRadiusCircle = registerSize('cornerRadius.circle', export const strokeThickness = registerSize('strokeThickness', sizeForAllThemes(1, 'px'), nls.localize('strokeThickness', "Base stroke thickness for borders and outlines.")); + +// ------ Spacing ramp +// +// A fixed ramp of spacing tokens used for padding, margins and gaps. Numeric tokens +// encode the value in tenths of a pixel (e.g. `size200` is 20px). `sizeNone` +// represents 0px, matching the design system's spacing ramp. + +export const spacingNone = registerSize('spacing.sizeNone', + sizeForAllThemes(0, 'px'), + nls.localize('spacingNone', "No spacing (0px).")); + +export const spacingSize20 = registerSize('spacing.size20', + sizeForAllThemes(2, 'px'), + nls.localize('spacingSize20', "Spacing of 2px.")); + +export const spacingSize40 = registerSize('spacing.size40', + sizeForAllThemes(4, 'px'), + nls.localize('spacingSize40', "Spacing of 4px.")); + +export const spacingSize60 = registerSize('spacing.size60', + sizeForAllThemes(6, 'px'), + nls.localize('spacingSize60', "Spacing of 6px.")); + +export const spacingSize80 = registerSize('spacing.size80', + sizeForAllThemes(8, 'px'), + nls.localize('spacingSize80', "Spacing of 8px.")); + +export const spacingSize100 = registerSize('spacing.size100', + sizeForAllThemes(10, 'px'), + nls.localize('spacingSize100', "Spacing of 10px.")); + +export const spacingSize120 = registerSize('spacing.size120', + sizeForAllThemes(12, 'px'), + nls.localize('spacingSize120', "Spacing of 12px.")); + +export const spacingSize160 = registerSize('spacing.size160', + sizeForAllThemes(16, 'px'), + nls.localize('spacingSize160', "Spacing of 16px.")); + +export const spacingSize200 = registerSize('spacing.size200', + sizeForAllThemes(20, 'px'), + nls.localize('spacingSize200', "Spacing of 20px.")); + +export const spacingSize240 = registerSize('spacing.size240', + sizeForAllThemes(24, 'px'), + nls.localize('spacingSize240', "Spacing of 24px.")); + +export const spacingSize280 = registerSize('spacing.size280', + sizeForAllThemes(28, 'px'), + nls.localize('spacingSize280', "Spacing of 28px.")); + +export const spacingSize320 = registerSize('spacing.size320', + sizeForAllThemes(32, 'px'), + nls.localize('spacingSize320', "Spacing of 32px.")); + +export const spacingSize360 = registerSize('spacing.size360', + sizeForAllThemes(36, 'px'), + nls.localize('spacingSize360', "Spacing of 36px.")); + +export const spacingSize400 = registerSize('spacing.size400', + sizeForAllThemes(40, 'px'), + nls.localize('spacingSize400', "Spacing of 40px.")); From 231a1f025bf6533ecd7e1f20f2adcf0dd8d75ff9 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 5 Jun 2026 13:30:42 +0200 Subject: [PATCH 03/29] sessions: respect hidden secondary side bar on session switch (#320075) Restore the invariant that revealing the editor part also reveals the auxiliary bar (e.g. opening a file from chat shows the secondary side bar), but suppress that enforcement while restoring a session's editor working set on session switch. The working-set reveal is programmatic, so the session's saved auxiliary bar visibility now wins and a side bar the user hid for a session stays hidden when returning to it. Adds the LAYOUT_CONTROLLER.md spec documenting per-session layout state and links it from LAYOUT.md, README.md, and the sessions skill. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/sessions/SKILL.md | 8 + src/vs/sessions/LAYOUT.md | 4 +- src/vs/sessions/LAYOUT_CONTROLLER.md | 175 ++++++++++++++++++ src/vs/sessions/README.md | 1 + .../layout/browser/sessionLayoutController.ts | 29 ++- .../browser/sessionLayoutController.test.ts | 67 ++++++- 6 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 src/vs/sessions/LAYOUT_CONTROLLER.md diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index 1e3cdc7385a14..8dc6af7bd9b8d 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -18,6 +18,7 @@ Then read the relevant spec for the area you are changing (see table below). If |----------|------|-------------| | Layer rules | `src/vs/sessions/LAYERS.md` | Before adding any cross-module imports. Defines the internal layer hierarchy (`core` → `services` → `contrib` → `providers`) with ESLint-enforced import restrictions. Key rule: `contrib/*` must NOT import from `contrib/providers/*`. | | Layout spec | `src/vs/sessions/LAYOUT.md` | Before changing any part, grid structure, titlebar, or CSS. Documents the fixed grid layout (Sidebar \| ChatBar \| AuxiliaryBar), part positions, the modal editor system, per-session layout state persistence, and the titlebar's three-section design. | +| Layout controller spec | `src/vs/sessions/LAYOUT_CONTROLLER.md` | Before changing `LayoutController` or per-session layout state. Details how the auxiliary bar, panel, and editor working sets are captured/restored when switching sessions, multi-session suppression, the auto-reveal-on-changes flow, workspace-folder ordering, and storage/migration. | | Sessions spec | `src/vs/sessions/SESSIONS.md` | Before changing session/provider interfaces or data flow. Covers the pluggable provider model (`ISessionsProvider` → `ISessionsProvidersService` → `ISessionsManagementService`), `ISession`/`IChat` interfaces, observable state propagation, workspace/folder model, and session type system. | | Sessions list spec | `src/vs/sessions/SESSIONS_LIST.md` | Before changing the sessions sidebar list. Covers the tree widget (`WorkbenchObjectTree`), renderers, grouping (workspace/date), filtering (type/status/archived/read), pinning, read/unread state, workspace capping, mobile adaptations, storage keys, and registered actions. | | Mobile spec | `src/vs/sessions/MOBILE.md` | Before adding any phone-specific UI. Covers the mobile part subclass architecture, viewport classification (phone < 640px), `MobileTitlebarPart`, drawer-based sidebar, `MobilePickerSheet`, view/action gating with `IsPhoneLayoutContext`, and the desktop → mobile component mapping. | @@ -30,6 +31,13 @@ Then read the relevant spec for the area you are changing (see table below). If - **Importing from providers**: Non-provider `contrib/*` code must never import from `contrib/providers/*`. Extract shared interfaces to `services/` or `common/`. - **Missing entry point import**: New contribution files must be imported in the appropriate `sessions.*.main.ts` entry point to be loaded (for example `sessions.common.main.ts`, `sessions.desktop.main.ts`, `sessions.web.main.ts`, or `sessions.web.main.internal.ts`). - **Modifying workbench code**: Prefer extending/wrapping workbench classes in the sessions layer over modifying shared workbench components. +- **Timeouts as fixes**: Never use `setTimeout`/`disposableTimeout`/arbitrary delays to fix bugs or implement behaviour. They are race-prone guesses that mask the real ordering/state problem. Drive logic off deterministic signals instead — observables (`autorun`/`derived`), explicit events (`onDidChange*`), lifecycle phases, or awaiting the actual async operation. +- **Stashed state read back later (side-channels)**: Never stash a value on a service during one method call and read it back from a separate query later, assuming it is still valid (e.g. a `Set`/flag set in `openSession` and consumed by a `shouldX()` pull-API). This is fragile temporal coupling. Instead, make it reactive state that is set **atomically together with its source of truth** and consumed reactively. Example: per-activation intent like "open in background / preserve focus" is exposed as an `IObservable` set in the **same transaction** as `activeSession` (via a single internal setter so it can never go stale), and read with `.read(reader)` in the consumer's `autorun` — never via a consume-once getter. +- **Blocking on a "pending/waiting" state instead of creating + upgrading**: When an entity (e.g. a draft session) depends on something that registers asynchronously, don't withhold creation behind a pending/waiting state. Prefer creating immediately with the best available data, then **replace/upgrade** it once the awaited dependency arrives (driven by an `onDidChange*`/observable signal), cancelling the upgrade if the user changes the inputs meanwhile and giving up at a deterministic lifecycle milestone (e.g. `LifecyclePhase.Eventually`) rather than a timeout. This keeps the UI populated and avoids fragile "is everything ready yet?" gating. + +## Capturing Feedback (meta-rule) + +Whenever the user flags a wrong pattern, rejects an approach, or gives design/rules feedback, **automatically add it** as a concise pitfall/learning to this `Common Pitfalls` section (or the most relevant spec doc) in the same change — without being asked again. Keep each entry 1–3 sentences: the anti-pattern, why it is wrong, and the preferred pattern. ## Validating Changes diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 935670d8c3d60..47e25658d083f 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -153,7 +153,7 @@ When the editor part is shown in the grid (not as a modal), its title toolbar (` When the auxiliary bar is hidden the editor becomes the rightmost card and expands into the freed space; the workbench's 10px right gutter still applies, and a `.noauxiliarybar` rule in `browser/media/style.css` restores the editor's right border and right corner radii so it keeps its card appearance. -The auxiliary-bar invariant (§10) is only enforced when the editor part *becomes* visible, so this toggle can collapse the side part while the editor stays open. +The auxiliary-bar invariant (§10) is enforced when the editor part *becomes* visible — for example, opening a file from chat reveals the editor and also reveals the secondary side bar. The chevron toggle can still collapse the side part while the editor stays open. The one exception is **restoring a session's editor working set on session switch**: that reveal is programmatic and honors the session's saved auxiliary bar visibility, so a side bar the user hid for a session stays hidden when returning to it. The main editor part can be explicitly revealed for workflows that target it directly. @@ -212,7 +212,7 @@ All session-window contributions use `WindowVisibility.Sessions` to only appear ## 10. Per-Session Layout State -`LayoutController` (`contrib/layout/browser/sessionLayoutController.ts`) manages layout state as the user switches between sessions. All state is persisted to workspace storage so it survives restarts. +`LayoutController` (`contrib/layout/browser/sessionLayoutController.ts`) manages layout state as the user switches between sessions. All state is persisted to workspace storage so it survives restarts. This section is a summary — see **[LAYOUT_CONTROLLER.md](LAYOUT_CONTROLLER.md)** for the full specification (switch trigger, multi-session handling, auto-reveal, persistence, and invariants). ### Auxiliary Bar diff --git a/src/vs/sessions/LAYOUT_CONTROLLER.md b/src/vs/sessions/LAYOUT_CONTROLLER.md new file mode 100644 index 0000000000000..53f0728c53b4e --- /dev/null +++ b/src/vs/sessions/LAYOUT_CONTROLLER.md @@ -0,0 +1,175 @@ +# Layout Controller — Per-Session Layout State + +This document specifies the behaviour of `LayoutController` +([contrib/layout/browser/sessionLayoutController.ts](contrib/layout/browser/sessionLayoutController.ts)), +the contribution that manages workbench layout as the user switches between sessions. + +It is the detailed companion to [LAYOUT.md §10 Per-Session Layout State](LAYOUT.md#10-per-session-layout-state). + +--- + +## 1. Overview + +The Agents window keeps a single **active session** but lets the user move between many. +Each session "owns" a small amount of layout state — which side parts are visible and which +editors are open — so that returning to a session restores the working context the user left it in. + +`LayoutController` owns three independent pieces of per-session state, all keyed by session +resource (`URI`) and persisted to workspace storage: + +| State | Storage map | Scope | +|-------|-------------|-------| +| Auxiliary bar (secondary side bar) | `_viewStateBySession` | visibility + active view container | +| Panel (terminal / debug output) | `_panelVisibilityBySession` | visibility only | +| Editor working set | `_workingSets` | open editors in the grid editor part | + +All state flows from the `activeSession` **observable** (never events). The controller derives +`activeSessionResourceObs`, `activeSessionHasChangesObs`, `activeSessionIsUntitledObs`, +`activeSessionHasWorkspaceObs`, and `multipleSessionsVisibleObs`, then reacts with `autorun`. + +--- + +## 2. The Switch Trigger + +Each sync is an `autorun` that reads `activeSessionResourceObs`. The controller keeps a local +`previousSessionResource` so it can detect a **real switch** (`previous !== active`) versus an +initial load or an unrelated re-evaluation. + +### Multiple visible sessions + +When more than one session is visible at once (the Sessions Part grid shows several session views), +**all per-session sync is suppressed**: + +- The aux-bar / panel sync autoruns bail out early (`multipleSessionsVisibleObs`). +- A dedicated autorun **clears** `_viewStateBySession`, `_panelVisibilityBySession`, and + `_pendingTurnStateByResource` for every visible session. + +This guarantees that after collapsing back to a single session the **default visibility logic** +(§3.2) runs again instead of restoring stale single-session state. Editor working sets are *not* +cleared — they survive multi-session mode. + +--- + +## 3. Auxiliary Bar + +Skipped entirely on mobile web (`isWeb && isMobile`) to avoid disruptive auto-expand on narrow viewports. + +### 3.1 Switching away — capture + +`_captureViewState(previousSession)` records, for the **outgoing** session: + +- `auxiliaryBarVisible` — whether the aux bar is currently visible. +- `auxiliaryBarActiveViewContainerId` — the active aux-bar view container (Files vs Changes). + +### 3.2 Switching to — restore + +`_syncAuxiliaryBarVisibility(resource, hasWorkspace, isUntitled, hasChanges)` applies state in +strict priority order: + +1. **No resource / no workspace** → do nothing. +2. **Untitled session** → open the Files container (`SESSIONS_FILES_CONTAINER_ID`), leave visibility as is. +3. **Saved state exists**: + - was **hidden** → hide the aux bar and stop. + - was visible with an active container → reopen that container and stop. +4. **No saved state (first visit) — defaults**: + - session **has changes** → open the Changes view (`CHANGES_VIEW_ID`). + - otherwise → open the Files container. + +### 3.3 Auto-reveal on new changes + +A separate autorun watches for turn completion (also skipped on mobile web). When a chat request +is submitted (`onDidSubmitRequest`), the controller records `IPendingTurnState` +(`hadChangesBeforeSend`, `submittedAt`) for the session. When that session's `lastTurnEnd` +advances past `submittedAt`: + +- if there were **no** changes before the turn but there **are** changes now, the aux bar is + revealed (`setPartHidden(false, AUXILIARYBAR_PART)`) and the session's saved view state is + cleared so it stays visible on the next switch. + +This only applies to the single-visible-session case; the pending state is dropped when multiple +sessions are visible. + +### 3.4 Editor / aux-bar invariant + +The editor part must not be left visible without the auxiliary bar +(`_enforceAuxiliaryBarWhenEditorVisible`): when the editor part *becomes* visible the aux bar is +revealed. So opening a file from chat reveals the editor **and** the secondary side bar. + +The one exception is **working-set restoration on session switch** (§5): that editor reveal is +programmatic, so the invariant is suppressed (`_suppressAuxiliaryBarEnforcement`) and the +session's saved aux-bar visibility wins. A side bar the user hid for a session therefore stays +hidden when they return to it. The suppression is a synchronous re-entrancy guard around the +`setPartHidden(false, EDITOR_PART)` call — the part-visibility event fires synchronously, so the +guard reliably covers exactly that reveal. + +--- + +## 4. Panel + +`_syncPanelVisibility(resource)`: + +- No active session → hide the panel. +- Otherwise restore `_panelVisibilityBySession.get(resource)`, defaulting to **hidden** when there + is no record. + +The per-session record is updated whenever the user toggles the panel: an +`onDidChangePartVisibility` listener for `PANEL_PART` writes the new visibility for the active +session (suppressed while multiple sessions are visible). + +--- + +## 5. Editor Working Sets + +Active only when `workbench.editor.useModal` is **not** `'all'` (editors live in the grid editor +part rather than as modal overlays). Driven by `_useModalConfigObs`. + +### 5.1 Workspace-folder ordering + +The `activeSession` observable updates **before** the workbench's workspace folders update. To +avoid restoring editors into the wrong workspace, `activeSessionForWorkingSet` +(`derivedObservableWithCache`) holds back the new session until the workspace folders reflect its +working directory. + +### 5.2 Save / apply on switch + +Using `runOnChange(activeSessionForWorkingSet, ...)`: + +- **Outgoing session** (skip untitled): `_saveWorkingSet` snapshots the currently open editors as a + named working set (`session-working-set:`); sessions with no visible editors store nothing. +- **Incoming session**: `_applyWorkingSet` restores its saved working set (or `'empty'`). All + applies are serialized through a `Sequencer`. When not in modal mode and the working set is + non-empty, the editor part is revealed before/after applying via `_revealEditorPartForWorkingSet`, + which suppresses the editor→aux-bar invariant (§3.4) so the session's saved aux-bar visibility is + honored. + +On initial load (no previous session) the controller only applies a working set if one is already +saved for the incoming session — it never applies `'empty'`, to avoid closing editors being restored. + +### 5.3 Cleanup + +`onDidChangeSessions` removes working sets for **archived** or **deleted** sessions +(`_deleteWorkingSet`, which also drops the corresponding view state). + +--- + +## 6. Persistence + +- All state serializes to the workspace-scoped key `sessions.layoutState` on + `IStorageService.onWillSaveState` (`_saveState`), with a `StorageTarget.MACHINE` target. +- `_saveState` captures the active session's current view state and working set (skipping untitled / + multi-session cases) and writes one `ISessionLayoutEntry` per known session resource. +- `_loadState` reads `sessions.layoutState`; if absent it performs a one-time migration from the + legacy `sessions.workingSets` key and then removes it. Corrupted data is dropped defensively. + +--- + +## 7. Key Invariants + +- **Observables, not events**, drive all session-switch logic. +- **Multiple visible sessions** disable per-session view/panel sync and clear that state (working + sets preserved). +- **Default visibility** (§3.2 step 4) only applies when a session has no saved aux-bar state. +- The **editor part implies the auxiliary bar** when it *becomes* visible (e.g. opening a file from + chat), **except** during working-set restoration on session switch, where the session's saved + aux-bar visibility wins (so a hidden side bar is respected). +- Working-set save/apply waits for **workspace folders** to catch up with the active session. diff --git a/src/vs/sessions/README.md b/src/vs/sessions/README.md index d79335077991a..08018a3fa1b6b 100644 --- a/src/vs/sessions/README.md +++ b/src/vs/sessions/README.md @@ -20,6 +20,7 @@ The Agents Window (`Workbench`) provides a simplified, fixed-layout workbench ta | Document | Description | |----------|-------------| | [LAYOUT.md](LAYOUT.md) | Workbench layout specification — grid structure, parts, titlebar, per-session layout state | +| [LAYOUT_CONTROLLER.md](LAYOUT_CONTROLLER.md) | Per-session layout state — how the auxiliary bar, panel, and editor working sets are captured/restored on session switch | | [LAYERS.md](LAYERS.md) | Import layering rules — what each layer can and cannot import, ESLint enforcement | | [SESSIONS.md](SESSIONS.md) | Sessions architecture — layers, provider model, core interfaces, data flow, metadata contract | | [MOBILE.md](MOBILE.md) | Mobile layout specification | diff --git a/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts b/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts index e7a65885f9957..c4f3a19d2258c 100644 --- a/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/sessionLayoutController.ts @@ -65,6 +65,14 @@ export class LayoutController extends Disposable { private readonly _workingSetSequencer = new Sequencer(); private readonly _useModalConfigObs; + /** + * Set while a working set is being restored on session switch. The editor + * part is revealed programmatically in this case, so the "editor implies + * auxiliary bar" invariant is suppressed to honor the session's saved + * auxiliary bar visibility (e.g. the user hid it for this session). + */ + private _suppressAuxiliaryBarEnforcement = false; + constructor( @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, @ISessionsManagementService private readonly _sessionManagementService: ISessionsManagementService, @@ -306,6 +314,9 @@ export class LayoutController extends Disposable { // --- Auxiliary bar --- private _enforceAuxiliaryBarWhenEditorVisible(): void { + if (this._suppressAuxiliaryBarEnforcement) { + return; + } if ( this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) && !this._layoutService.isVisible(Parts.AUXILIARYBAR_PART) @@ -314,6 +325,20 @@ export class LayoutController extends Disposable { } } + /** + * Reveals the editor part without triggering the "editor implies auxiliary + * bar" invariant. Used when restoring a session's working set so the + * session's saved auxiliary bar visibility is respected. + */ + private _revealEditorPartForWorkingSet(): void { + this._suppressAuxiliaryBarEnforcement = true; + try { + this._layoutService.setPartHidden(false, Parts.EDITOR_PART); + } finally { + this._suppressAuxiliaryBarEnforcement = false; + } + } + private _captureViewState(sessionResource: URI): void { const auxiliaryBarVisible = this._layoutService.isVisible(Parts.AUXILIARYBAR_PART); const activeViewContainerId = this._paneCompositePartService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)?.getId(); @@ -468,12 +493,12 @@ export class LayoutController extends Disposable { } if (!isModal && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { - this._layoutService.setPartHidden(false, Parts.EDITOR_PART); + this._revealEditorPartForWorkingSet(); } const result = await this._editorGroupsService.applyWorkingSet(workingSet, { preserveFocus }); if (!isModal && result && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { - this._layoutService.setPartHidden(false, Parts.EDITOR_PART); + this._revealEditorPartForWorkingSet(); } }); } diff --git a/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts index 1e15d27279fbb..ffca9c2fb2629 100644 --- a/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/sessionLayoutController.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { constObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; @@ -112,14 +113,23 @@ suite('LayoutController', () => { let setPartHiddenCalls: { hidden: boolean; part: Parts }[]; let activePaneCompositeId: string | undefined; - function createLayoutController(): LayoutController { + interface ICreateOptions { + readonly useModal?: 'off' | 'some' | 'all'; + readonly workspaceFolders?: readonly { readonly uri: URI }[]; + readonly layoutState?: readonly object[]; + } + + function createLayoutController(options: ICreateOptions = {}): LayoutController { const instaService = store.add(new TestInstantiationService()); storageService = store.add(new TestStorageService()); + if (options.layoutState) { + storageService.store('sessions.layoutState', JSON.stringify(options.layoutState), StorageScope.WORKSPACE, 0); + } instaService.stub(IStorageService, storageService); const configService = new TestConfigurationService(); - configService.setUserConfiguration('workbench.editor.useModal', 'all'); + configService.setUserConfiguration('workbench.editor.useModal', options.useModal ?? 'all'); instaService.stub(IConfigurationService, configService); activeSessionObs = observableValue('activeSession', undefined); @@ -151,7 +161,12 @@ suite('LayoutController', () => { } override setPartHidden(hidden: boolean, part: Parts): void { setPartHiddenCalls.push({ hidden, part }); + const wasVisible = partVisibility.get(part) ?? true; partVisibility.set(part, !hidden); + // Mirror production: fire the visibility change synchronously when it actually changes + if (wasVisible === hidden) { + onDidChangePartVisibility.fire({ partId: part, visible: !hidden }); + } } override hasFocus(_part: Parts): boolean { return false; } override readonly onDidChangePartVisibility = onDidChangePartVisibility.event; @@ -193,7 +208,7 @@ suite('LayoutController', () => { instaService.stub(IWorkspaceContextService, new class extends mock() { override readonly onDidChangeWorkspaceFolders = Event.None; - override getWorkspace(): IWorkspace { return { id: 'test', folders: [] }; } + override getWorkspace(): IWorkspace { return { id: 'test', folders: (options.workspaceFolders ?? []) as IWorkspace['folders'] }; } }); return store.add(instaService.createInstance(LayoutController)); @@ -279,6 +294,52 @@ suite('LayoutController', () => { ); }); + // --- Editor / auxiliary bar invariant --- + + test('reveals auxiliary bar when the editor part becomes visible', () => { + createLayoutController(); + partVisibility.set(Parts.EDITOR_PART, true); + partVisibility.set(Parts.AUXILIARYBAR_PART, false); + setPartHiddenCalls = []; + + // Simulate the editor part becoming visible (e.g. opening a file from chat) + onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + + assert.ok( + setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), + 'auxiliary bar should be revealed when the editor becomes visible' + ); + }); + + test('does not force auxiliary bar visible when restoring editor working set on session switch', async () => { + const session = makeSession(URI.parse('session:1')); + createLayoutController({ + useModal: 'some', + workspaceFolders: [{ uri: URI.file('/repo') }], + layoutState: [{ + sessionResource: 'session:1', + editorWorkingSet: { id: 'ws-1', name: 'ws-1' }, + viewState: { auxiliaryBarVisible: false, auxiliaryBarActiveViewContainerId: undefined }, + }], + }); + partVisibility.set(Parts.EDITOR_PART, false); + partVisibility.set(Parts.AUXILIARYBAR_PART, false); + setPartHiddenCalls = []; + + activeSessionObs.set(session, undefined); + // Flush the working-set sequencer (queued microtasks) + await timeout(0); + + assert.ok( + setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + 'editor part should be revealed by the working set restore' + ); + assert.ok( + !setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), + 'auxiliary bar must not be forced visible during working set restore' + ); + }); + // --- Panel visibility --- test('hides panel by default when no record exists', () => { From ac0a0dd93650756c5fd4cc238f398e2606f9c340 Mon Sep 17 00:00:00 2001 From: Aiday Marlen Kyzy Date: Fri, 5 Jun 2026 13:31:02 +0200 Subject: [PATCH 04/29] adding prompt completions (#319939) * adding prompt completions * adding prompt completions --- extensions/copilot/package.json | 5 +++++ extensions/copilot/package.nls.json | 1 + .../completions-core/vscode-node/extension/src/config.ts | 4 ++++ .../platform/configuration/common/configurationService.ts | 1 + .../contrib/chat/browser/widget/input/chatInputPart.ts | 6 ++++-- 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 2e9c4e7b79fa0..a02798faa6e83 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -3231,6 +3231,11 @@ "markdownDescription": "%github.copilot.nextEditSuggestions.enabled%", "scope": "language-overridable" }, + "github.copilot.completions.chat.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.completions.chat.enabled%" + }, "github.copilot.nextEditSuggestions.extendedRange": { "type": "boolean", "default": true, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 50ba78583a193..fcffd077fcd3c 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -122,6 +122,7 @@ "github.copilot.config.edits.enabled": "Whether to enable the Copilot Edits feature.", "github.copilot.config.codesearch.enabled": "Whether to enable agentic codesearch when using `#codebase`.", "github.copilot.nextEditSuggestions.enabled": "Whether to enable next edit suggestions (NES).\n\nNES can propose a next edit based on your recent changes. [Learn more](https://aka.ms/vscode-nes) about next edit suggestions.", + "github.copilot.completions.chat.enabled": "Whether to enable inline completions in chat.", "github.copilot.nextEditSuggestions.extendedRange": "Whether to allow next edit suggestions (NES) to modify code farther away from the cursor position.", "github.copilot.nextEditSuggestions.fixes": "Whether to offer fixes for diagnostics via next edit suggestions (NES).", "github.copilot.nextEditSuggestions.allowWhitespaceOnlyChanges": "Whether to allow whitespace-only changes be proposed by next edit suggestions (NES).", diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/config.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/config.ts index 886a8b93ab077..cfd64b1eed6cd 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/config.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/config.ts @@ -20,6 +20,7 @@ import { import { CopilotConfigPrefix } from '../../lib/src/constants'; import { Logger } from '../../lib/src/logger'; import { transformEvent } from '../../lib/src/util/event'; +import { Schemas } from '../../../../../util/vs/base/common/network'; const logger = new Logger('extensionConfig'); @@ -155,6 +156,9 @@ export function isCompletionEnabled(accessor: ServicesAccessor): boolean | undef } export function isCompletionEnabledForDocument(accessor: ServicesAccessor, document: vscode.TextDocument): boolean { + if (document.uri.scheme === Schemas.vscodeChatInput) { + return vscode.workspace.getConfiguration(CopilotConfigPrefix).get('completions.chat.enabled', false); + } return getEnabledConfig(accessor, document.languageId); } diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 9ec26fbf84e4a..3c3ef5c07c8b6 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -1032,6 +1032,7 @@ export namespace ConfigKey { export const ClaudeAgentUseSdkExtension = defineSetting('chat.claudeAgent.useSdkExtension', ConfigType.ExperimentBased, false); export const ClaudeAgentSdkExtensionInstallTimeout = defineSetting('chat.claudeAgent.sdkExtensionInstallTimeout', ConfigType.Simple, 120_000); export const InlineEditsEnabled = defineSetting('nextEditSuggestions.enabled', ConfigType.ExperimentBased, true); + export const CompletionsInChatEnabled = defineSetting('completions.chat.enabled', ConfigType.Simple, false); export const InlineEditsEnableDiagnosticsProvider = defineSetting('nextEditSuggestions.fixes', ConfigType.ExperimentBased, true); export const InlineEditsAllowWhitespaceOnlyChanges = defineSetting('nextEditSuggestions.allowWhitespaceOnlyChanges', ConfigType.ExperimentBased, true); /** Because of migration the value returned may be `boolean | "onlyWithEdit" | "jump" | undefined` */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index cfbda9284e03a..2e7f2d8414aeb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -138,6 +138,7 @@ import { ChatContextUsageWidget } from '../../widgetHosts/viewPane/chatContextUs import { Target } from '../../../common/promptSyntax/promptTypes.js'; import { findLast } from '../../../../../../base/common/arraysFind.js'; import { ConfigureToolsAction } from '../../actions/chatToolActions.js'; +import { InlineCompletionsController } from '../../../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; const $ = dom.$; @@ -2496,6 +2497,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge options.autoClosingBrackets = this.configurationService.getValue('editor.autoClosingBrackets'); options.autoClosingQuotes = this.configurationService.getValue('editor.autoClosingQuotes'); options.autoSurround = this.configurationService.getValue('editor.autoSurround'); + options.quickSuggestions = false; options.suggest = { showIcons: true, showSnippets: false, @@ -2514,7 +2516,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._inputEditorElement = dom.append(editorContainer, $(chatInputEditorContainerSelector)); const editorOptions = getSimpleCodeEditorWidgetOptions(); - editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, DropIntoEditorController.ID, CopyPasteController.ID, LinkDetector.ID])); + editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, DropIntoEditorController.ID, CopyPasteController.ID, LinkDetector.ID, InlineCompletionsController.ID])); this._inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, this._inputEditorElement, options, editorOptions)); SuggestController.get(this._inputEditor)?.forceRenderingAbove(); @@ -2911,7 +2913,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge let inputModel = this.modelService.getModel(this.inputUri); if (!inputModel) { - inputModel = this._register(this.modelService.createModel('', null, this.inputUri, true)); + inputModel = this._register(this.modelService.createModel('', null, this.inputUri, false)); } this.textModelResolverService.createModelReference(this.inputUri).then(ref => { From 5fd902d5b883dfaafcc60ef5a94d55eb55594d0d Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 5 Jun 2026 21:40:34 +1000 Subject: [PATCH 05/29] feat: implement GitHub pull request operations in agent host (#318256) * feat: Implement GitHub Pull Request functionality in Agent Host - Refactor ProtocolServerHandler to delegate changeset operations to AgentService. - Introduce AgentHostOctoKitService for minimal GitHub REST client operations. - Add tests for AgentHostPullRequestOperationHandler to validate PR creation and handling. - Enhance session test helpers with additional Git service methods. - Create unit tests for AgentHostOctoKitService to ensure correct API interactions. - Update logging and error handling in various components related to GitHub operations. - Ensure proper integration of changeset operations in the logging agent connection. * fix: Remove duplicate import of IAgentService in agentHost files * feat: Add GITHUB_REPO_PROTECTED_RESOURCE for GitHub repository write operations * fix: Include changesetKind and changesetUri in PR operation tests for GitHub branches --- .../platform/agentHost/common/agentService.ts | 20 ++ ...gentHostChangesetOperationContributions.ts | 4 + .../agentHostPullRequestOperationHandler.ts | 234 +++++++++++++++ .../agentHostPullRequestOperationProvider.ts | 95 ++++++ .../platform/agentHost/node/agentService.ts | 3 + .../node/shared/agentHostOctoKitService.ts | 191 +++++++++++++ ...entHostPullRequestOperationHandler.test.ts | 270 ++++++++++++++++++ ...ntHostPullRequestOperationProvider.test.ts | 61 ++++ .../shared/agentHostOctoKitService.test.ts | 135 +++++++++ 9 files changed, 1013 insertions(+) create mode 100644 src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts create mode 100644 src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts create mode 100644 src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts create mode 100644 src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 3600cf82f0f5a..326461768d8c2 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -393,6 +393,26 @@ export const GITHUB_COPILOT_PROTECTED_RESOURCE: ProtectedResourceMetadata = { required: true, }; +/** + * Canonical {@link ProtectedResourceMetadata} for GitHub repository write + * operations (e.g. creating a pull request). Distinct from + * {@link GITHUB_COPILOT_PROTECTED_RESOURCE} so that the broader `repo` + * scope is only requested when a session actually needs it (e.g. when a + * changeset operation handler throws `AHP_AUTH_REQUIRED` with this + * resource), rather than at session create for every agent. + * + * `required: false` reflects that the resource is only needed on demand — + * agents do not have to advertise it eagerly. The workbench-side auth + * contributor resolves it lazily in response to operation invocations. + */ +export const GITHUB_REPO_PROTECTED_RESOURCE: ProtectedResourceMetadata = { + resource: 'https://api.github.com/repos', + resource_name: 'GitHub Repository', + authorization_servers: ['https://github.com/login/oauth'], + scopes_supported: ['repo'], + required: false, +}; + export interface IAgentCreateSessionConfig { readonly provider?: AgentProvider; readonly model?: ModelSelection; diff --git a/src/vs/platform/agentHost/node/agentHostChangesetOperationContributions.ts b/src/vs/platform/agentHost/node/agentHostChangesetOperationContributions.ts index c660164567cd7..c428751555198 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetOperationContributions.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetOperationContributions.ts @@ -7,6 +7,7 @@ import { DisposableStore, type IDisposable } from '../../../base/common/lifecycl import type { IInstantiationService } from '../../instantiation/common/instantiation.js'; import type { IChangesetOperationContributionService } from '../common/changesetOperation.js'; import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js'; +import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js'; import type { AgentHostStateManager } from './agentHostStateManager.js'; export function registerDefaultChangesetOperationContributions( @@ -15,6 +16,9 @@ export function registerDefaultChangesetOperationContributions( stateManager: AgentHostStateManager, ): IDisposable { const store = new DisposableStore(); + store.add(service.registerContribution( + instantiationService.createInstance(AgentHostPullRequestOperationContribution, stateManager) + )); store.add(service.registerContribution( instantiationService.createInstance(AgentHostCommitOperationContribution, stateManager) )); diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts new file mode 100644 index 0000000000000..9986e0da8e2e0 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { URI } from '../../../base/common/uri.js'; +import { localize } from '../../../nls.js'; +import { GITHUB_REPO_PROTECTED_RESOURCE, IAgentService } from '../common/agentService.js'; +import { parseChangesetUri } from '../common/changesetUri.js'; +import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; +import { readSessionGitState, type ChangesetOperationFollowUp, type SessionState } from '../common/state/sessionState.js'; +import { ILogService } from '../../log/common/log.js'; +import { IAgentHostGitService } from './agentHostGitService.js'; +import { type IChangesetOperationHandler } from '../common/changesetOperation.js'; +import { IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; +import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; + +export interface PullRequestCreatedEvent { + readonly sessionKey: string; + readonly branchName: string; +} + +/** + * Server-side handler for the `create-pr` and `create-draft-pr` changeset + * operations advertised on git-backed sessions whose working directory has + * a GitHub remote. Operation availability is recomputed by + * `AgentHostChangesetOperationContributionService.updateOperations`. + * + * The flow mirrors the Copilot CLI extension's `createPullRequest` helper + * (`extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts`): + * + * 1. Resolve session → working directory + current/base branch from + * {@link ISessionGitState}. + * 2. Commit any uncommitted working-tree changes. + * 3. Push the current branch to `origin` (with `--set-upstream` when missing). + * 4. Resolve `owner` / `repo` from {@link ISessionGitState.githubOwner} + * / {@link ISessionGitState.githubRepo} (populated by the git probe). + * 5. Reuse an existing PR for the branch, or POST `/repos/{owner}/{repo}/pulls` + * via {@link IAgentHostOctoKitService}. + * 6. Return the PR URL as an {@link InvokeChangesetOperationResult.followUp}. + */ +export class AgentHostPullRequestOperationHandler implements IChangesetOperationHandler { + + public static readonly OPERATION_CREATE_PR = 'create-pr'; + public static readonly OPERATION_CREATE_DRAFT_PR = 'create-draft-pr'; + + constructor( + private readonly _draft: boolean, + private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, + private readonly _onPullRequestCreated: (event: PullRequestCreatedEvent) => void, + @IAgentService private readonly _agentService: IAgentService, + @IAgentHostGitService private readonly _gitService: IAgentHostGitService, + @IAgentHostOctoKitService private readonly _octoKitService: IAgentHostOctoKitService, + @ILogService private readonly _logService: ILogService, + ) { } + + async invoke(params: InvokeChangesetOperationParams, token: CancellationToken): Promise { + const abortController = new AbortController(); + if (token.isCancellationRequested) { + abortController.abort(); + } + const cancellationListener = token.onCancellationRequested(() => abortController.abort()); + try { + return await this._invoke(params, token, abortController.signal); + } finally { + cancellationListener.dispose(); + } + } + + private async _invoke(params: InvokeChangesetOperationParams, token: CancellationToken, signal: AbortSignal): Promise { + const parsed = parseChangesetUri(params.channel); + if (!parsed) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not a changeset URI: ${params.channel}`); + } + this._throwIfCancelled(token); + const sessionUri = parsed.sessionUri; + + const sessionState = this._getSessionState(sessionUri); + if (!sessionState) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); + } + + const workingDirectoryStr = sessionState.summary.workingDirectory; + if (!workingDirectoryStr) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); + } + const workingDirectory = URI.parse(workingDirectoryStr); + + const gitState = readSessionGitState(sessionState._meta); + if (!gitState?.hasGitHubRemote || !gitState.githubOwner || !gitState.githubRepo) { + throw new ProtocolError( + JsonRpcErrorCodes.InternalError, + `Session's working directory is not a GitHub-backed git repo: ${sessionUri}`, + ); + } + + const branchName = gitState.branchName ?? await this._gitService.getCurrentBranch(workingDirectory); + if (!branchName) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine current branch for ${workingDirectory}`); + } + + const baseBranchName = gitState.baseBranchName ?? await this._gitService.getDefaultBranch(workingDirectory); + if (!baseBranchName) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine base branch for ${workingDirectory}`); + } + // `getDefaultBranch` may return `origin/` — `pulls` API wants the bare name. + const base = baseBranchName.startsWith('origin/') ? baseBranchName.substring('origin/'.length) : baseBranchName; + + const authToken = this._agentService.getAuthToken(GITHUB_REPO_PROTECTED_RESOURCE); + if (!authToken) { + throw new ProtocolError( + AHP_AUTH_REQUIRED, + localize('agentHost.changeset.pr.authRequired', "Sign in to GitHub with repository access to create a pull request."), + [GITHUB_REPO_PROTECTED_RESOURCE], + ); + } + + const hasUncommitted = await this._gitService.hasUncommittedChanges(workingDirectory); + if (hasUncommitted) { + this._throwIfCancelled(token); + this._logService.info(`[AgentHostPullRequestOperationHandler] Committing uncommitted changes for session ${sessionUri}`); + try { + await this._gitService.commitAll(workingDirectory, this._formatCommitMessage(branchName)); + } catch (err) { + this._throwIfCancelled(token); + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Failed to commit changes before creating a pull request: ${err instanceof Error ? err.message : String(err)}`); + } + } + this._throwIfCancelled(token); + + const branchChanges = await this._gitService.computeSessionFileDiffs(workingDirectory, { sessionUri, baseBranch: base }); + if (branchChanges === undefined) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.pr.computeChangesFailed', "Could not compute branch changes to create a pull request.")); + } + if (branchChanges !== undefined && branchChanges.length === 0) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.pr.noChanges', "There are no branch changes to create a pull request for.")); + } + this._throwIfCancelled(token); + + this._logService.info(`[AgentHostPullRequestOperationHandler] Pushing branch ${branchName} for session ${sessionUri}`); + const upstreamPresent = await this._gitService.hasUpstream(workingDirectory, branchName); + this._throwIfCancelled(token); + try { + await this._gitService.pushBranch(workingDirectory, branchName, !upstreamPresent); + } catch (err) { + this._throwIfCancelled(token); + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Failed to push branch '${branchName}': ${err instanceof Error ? err.message : String(err)}`); + } + this._throwIfCancelled(token); + + const title = this._formatTitle(branchName); + const body = this._formatBody(branchName, base); + + const existing = await this._octoKitService.findPullRequestByHeadBranch(gitState.githubOwner, gitState.githubRepo, branchName, authToken, signal); + if (existing) { + this._throwIfCancelled(token); + this._onPullRequestCreated({ sessionKey: sessionUri, branchName }); + return this._createResult(existing, localize('agentHost.changeset.pr.existing', "Pull request [#{0}]({1}) already exists.", existing.number, existing.url)); + } + this._throwIfCancelled(token); + + this._logService.info(`[AgentHostPullRequestOperationHandler] Creating ${this._draft ? 'draft ' : ''}PR ${gitState.githubOwner}/${gitState.githubRepo} ${branchName} -> ${base}`); + let created: { readonly url: string; readonly number: number }; + try { + created = await this._octoKitService.createPullRequest( + gitState.githubOwner, + gitState.githubRepo, + title, + body, + branchName, + base, + this._draft, + authToken, + signal, + ); + } catch (err) { + this._throwIfCancelled(token); + let foundAfterFailure: { readonly url: string; readonly number: number } | undefined; + try { + foundAfterFailure = await this._octoKitService.findPullRequestByHeadBranch(gitState.githubOwner, gitState.githubRepo, branchName, authToken, signal); + } catch { + this._throwIfCancelled(token); + throw err; + } + if (foundAfterFailure) { + this._throwIfCancelled(token); + this._onPullRequestCreated({ sessionKey: sessionUri, branchName }); + return this._createResult(foundAfterFailure, localize('agentHost.changeset.pr.existing', "Pull request [#{0}]({1}) already exists.", foundAfterFailure.number, foundAfterFailure.url)); + } + throw err; + } + this._throwIfCancelled(token); + const message = this._draft + ? localize('agentHost.changeset.pr.createdDraft', "Created draft pull request [#{0}]({1}).", created.number, created.url) + : localize('agentHost.changeset.pr.created', "Created pull request [#{0}]({1}).", created.number, created.url); + + this._onPullRequestCreated({ sessionKey: sessionUri, branchName }); + return this._createResult(created, message); + } + + private _throwIfCancelled(token: CancellationToken): void { + if (token.isCancellationRequested) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.pr.cancelled', "Pull request operation was cancelled.")); + } + } + + private _formatTitle(branchName: string): string { + // Beautify a branch name like `feat/foo-bar` into `feat: foo bar`. + const idx = branchName.indexOf('/'); + if (idx > 0 && idx < branchName.length - 1) { + const prefix = branchName.substring(0, idx); + const rest = branchName.substring(idx + 1).replace(/[-_]+/g, ' '); + return `${prefix}: ${rest}`; + } + return branchName.replace(/[-_]+/g, ' '); + } + + private _formatCommitMessage(branchName: string): string { + return localize('agentHost.changeset.pr.commitMessage', "Agent Host changes for {0}", branchName); + } + + private _formatBody(branchName: string, baseBranchName: string): string { + return localize('agentHost.changeset.pr.body', "Created from `{0}` targeting `{1}`.", branchName, baseBranchName); + } + + private _createResult(created: { readonly url: string; readonly number: number }, message: string): InvokeChangesetOperationResult { + const followUp: ChangesetOperationFollowUp = { + content: { uri: created.url, contentType: 'text/html' }, + external: true, + }; + return { message: { markdown: message }, followUp }; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts new file mode 100644 index 0000000000000..3410b8ef90970 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationProvider.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from '../../../base/common/async.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { localize } from '../../../nls.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/changesetOperation.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, type ChangesetOperation } from '../common/state/sessionState.js'; +import { AgentHostPullRequestOperationHandler, type PullRequestCreatedEvent } from './agentHostPullRequestOperationHandler.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; + +const OPTIMISTIC_PR_CREATED_CACHE_TTL = 30_000; + +/** + * Owns PR-specific changeset operation availability. + * + * The optimistic cache is intentionally in-memory only. It hides Create PR + * immediately after a successful create/reuse while the normal git/session + * refresh catches up; persisted PR metadata remains out of scope. + */ +export class AgentHostPullRequestOperationContribution extends Disposable implements IChangesetOperationContribution { + + private readonly _optimisticCreatedPullRequests = this._register(new DisposableMap()); + private _registry: IChangesetOperationRegistry | undefined; + + readonly onPullRequestCreated = (event: PullRequestCreatedEvent): void => { + const key = this._key(event.sessionKey, event.branchName); + this._optimisticCreatedPullRequests.set(key, disposableTimeout(() => { + this._optimisticCreatedPullRequests.deleteAndDispose(key); + this._registry?.onDidChangeOperations(event.sessionKey); + }, OPTIMISTIC_PR_CREATED_CACHE_TTL)); + + this._registry?.onDidChangeOperations(event.sessionKey); + this._registry?.refreshSessionGitState(event.sessionKey).finally(() => { + if (this._optimisticCreatedPullRequests.has(key)) { + this._optimisticCreatedPullRequests.deleteAndDispose(key); + this._registry?.onDidChangeOperations(event.sessionKey); + } + }); + }; + + constructor( + private readonly _stateManager: AgentHostStateManager, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + } + + registerHandlers(registry: IChangesetOperationRegistry): IDisposable { + this._registry = registry; + const store = new DisposableStore(); + const getSessionState = (sessionKey: string) => this._stateManager.getSessionState(sessionKey); + const createPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, false, getSessionState, this.onPullRequestCreated.bind(this)); + const createDraftPrHandler = this._instantiationService.createInstance(AgentHostPullRequestOperationHandler, true, getSessionState, this.onPullRequestCreated.bind(this)); + store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR, createPrHandler)); + store.add(registry.registerChangesetOperationHandler(AgentHostPullRequestOperationHandler.OPERATION_CREATE_DRAFT_PR, createDraftPrHandler)); + store.add({ dispose: () => { this._registry = undefined; } }); + return store; + } + + getOperations({ sessionKey, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { + if (gitState.branchName && this._optimisticCreatedPullRequests.has(this._key(sessionKey, gitState.branchName))) { + return undefined; + } + + const hasChanges = (gitState.outgoingChanges ?? 0) > 0 || (gitState.uncommittedChanges ?? 0) > 0; + if (!gitState.hasGitHubRemote || !hasChanges) { + return undefined; + } + + return [ + { + id: 'create-pr', + label: localize('agentHost.changeset.createPR', "Create Pull Request"), + scopes: [ChangesetOperationScope.Changeset], + icon: 'git-pull-request', + status: ChangesetOperationStatus.Idle, + }, + { + id: 'create-draft-pr', + label: localize('agentHost.changeset.createDraftPR', "Create Draft Pull Request"), + scopes: [ChangesetOperationScope.Changeset], + icon: 'git-pull-request-draft', + status: ChangesetOperationStatus.Idle, + }, + ]; + } + + private _key(sessionKey: string, branchName: string): string { + return `${sessionKey}\n${branchName}`; + } +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 0e082bb5c1993..d26d23e30e04d 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -56,6 +56,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; +import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -230,6 +231,8 @@ export class AgentService extends Disposable implements IAgentService { [ISessionDataService, this._sessionDataService], ); const instantiationService = this._register(new InstantiationService(services, /*strict*/ true)); + const agentHostOctoKitService = instantiationService.createInstance(AgentHostOctoKitService, undefined); + services.set(IAgentHostOctoKitService, agentHostOctoKitService); services.set(ICopilotApiService, instantiationService.createInstance(CopilotApiService, undefined)); this._sessionGitStateService = this._register(instantiationService.createInstance(AgentHostSessionGitStateService, this._stateManager)); this._changesetOperationContributionService = this._register(instantiationService.createInstance(AgentHostChangesetOperationContributionService, this._stateManager, this._sessionGitStateService)); diff --git a/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts b/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts new file mode 100644 index 0000000000000..61a535012217c --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../instantiation/common/instantiation.js'; +import { ILogService } from '../../../log/common/log.js'; + +export type FetchFunction = typeof globalThis.fetch; + +/** + * Successful result of {@link IAgentHostOctoKitService.createPullRequest}. + * + * Mirrors the `CreatedPullRequest` type returned by `OctoKitService` in + * `extensions/copilot/src/platform/github/common/githubService.ts` so the + * shapes line up if/when the two are ported together. + */ +export interface CreatedPullRequest { + readonly url: string; + readonly number: number; +} + +interface GitHubPullRequestResponseItem { + readonly html_url?: unknown; + readonly number?: unknown; +} + +/** + * Minimal GitHub REST client living in the agent-host process. + * + * The agent host runs headless and has no access to the workbench + * `IOctoKitService` / Octokit / VS Code auth providers. This service is a + * deliberately small re-implementation of the bits we need, modelled on + * `OctoKitService` from the Copilot extension so the API surface is + * familiar. Only operations the agent host actually needs are exposed — + * extend this interface as new changeset operations are added. + * + * The caller is responsible for supplying a GitHub OAuth token with the + * scopes required by the operation (e.g. `repo` for {@link createPullRequest}). + * Tokens are typically obtained from the agent host's + * `authenticate(resource, token)` token store, which the workbench pushes + * on session create via the same channel used for `ICopilotApiService`. + */ +export interface IAgentHostOctoKitService { + readonly _serviceBrand: undefined; + + /** + * Creates a pull request on github.com. + * + * Mirrors `OctoKitService.createPullRequest` from the Copilot extension. + * Throws on non-2xx responses or malformed payloads. + */ + createPullRequest( + owner: string, + repo: string, + title: string, + body: string, + head: string, + base: string, + draft: boolean, + token: string, + signal: AbortSignal, + ): Promise; + + /** Finds the most recently updated pull request for `owner:branch`, if any. */ + findPullRequestByHeadBranch(owner: string, repo: string, branch: string, token: string, signal: AbortSignal): Promise; +} + +export const IAgentHostOctoKitService = createDecorator('agentHostOctoKitService'); + +const GITHUB_API_HOST = 'https://api.github.com'; +const GITHUB_API_VERSION = '2022-11-28'; +const MAX_ERROR_RESPONSE_BODY_LENGTH = 500; + +export class AgentHostOctoKitService implements IAgentHostOctoKitService { + + declare readonly _serviceBrand: undefined; + + private readonly _fetch: FetchFunction; + + constructor( + fetchFn: FetchFunction | undefined, + @ILogService private readonly _logService: ILogService, + ) { + this._fetch = fetchFn ?? globalThis.fetch; + } + + async createPullRequest( + owner: string, + repo: string, + title: string, + body: string, + head: string, + base: string, + draft: boolean, + token: string, + signal: AbortSignal, + ): Promise { + const response = await this._makeGHAPIRequest( + `repos/${owner}/${repo}/pulls`, + 'POST', + token, + signal, + { title, body, head, base, draft }, + ); + + const html_url = (response as { html_url?: unknown } | undefined)?.html_url; + const number = (response as { number?: unknown } | undefined)?.number; + if (typeof html_url !== 'string' || typeof number !== 'number') { + throw new Error(`Failed to create pull request for ${owner}/${repo}`); + } + + return { url: html_url, number }; + } + + async findPullRequestByHeadBranch(owner: string, repo: string, branch: string, token: string, signal: AbortSignal): Promise { + const response = await this._makeGHAPIRequest( + `repos/${owner}/${repo}/pulls?head=${encodeURIComponent(`${owner}:${branch}`)}&state=all&sort=updated&direction=desc&per_page=1`, + 'GET', + token, + signal, + ); + if (!Array.isArray(response) || response.length === 0) { + return undefined; + } + const first = response[0] as GitHubPullRequestResponseItem | undefined; + const html_url = first?.html_url; + const number = first?.number; + return typeof html_url === 'string' && typeof number === 'number' + ? { url: html_url, number } + : undefined; + } + + private async _makeGHAPIRequest( + routeSlug: string, + method: 'GET' | 'POST', + token: string, + signal: AbortSignal, + body?: Record, + ): Promise { + const url = `${GITHUB_API_HOST}/${routeSlug}`; + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${token}`, + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + }; + if (body) { + headers['Content-Type'] = 'application/json'; + } + + let response: Response; + try { + response = await this._fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal, + }); + } catch (err) { + if (signal.aborted) { + throw err; + } + this._logService.error(`[AgentHostOctoKit] ${method} ${url} - Network error`, err); + throw err; + } + + if (!response.ok) { + const errorText = await response.text().catch(() => undefined); + const errorDetail = this._formatErrorResponseBody(errorText); + this._logService.error(`[AgentHostOctoKit] ${method} ${url} - Status: ${response.status}${errorDetail ? ` - ${errorDetail}` : ''}`); + throw new Error(`GitHub API request failed: ${method} ${routeSlug} - ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`); + } + + try { + return await response.json(); + } catch (err) { + this._logService.error(`[AgentHostOctoKit] ${method} ${url} - Failed to parse JSON`, err); + throw err; + } + } + + private _formatErrorResponseBody(errorText: string | undefined): string | undefined { + const normalized = errorText?.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return undefined; + } + return normalized.length > MAX_ERROR_RESPONSE_BODY_LENGTH + ? `${normalized.substring(0, MAX_ERROR_RESPONSE_BODY_LENGTH)}...` + : normalized; + } +} diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts new file mode 100644 index 0000000000000..82209a997ccc9 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -0,0 +1,270 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import type { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { GITHUB_REPO_PROTECTED_RESOURCE, type IAgentService } from '../../common/agentService.js'; +import { buildSessionChangesetUri } from '../../common/changesetUri.js'; +import { withSessionGitState, type ISessionFileDiff, SessionStatus } from '../../common/state/sessionState.js'; +import type { IAgentHostGitService } from '../../node/agentHostGitService.js'; +import { AgentHostPullRequestOperationHandler } from '../../node/agentHostPullRequestOperationHandler.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import type { CreatedPullRequest, IAgentHostOctoKitService } from '../../node/shared/agentHostOctoKitService.js'; + +class TestGitService implements IAgentHostGitService { + declare readonly _serviceBrand: undefined; + + readonly calls: string[] = []; + uncommitted = false; + upstream = false; + branchChanges: readonly ISessionFileDiff[] | undefined = [{ after: { uri: 'file:///repo/file.ts', content: { uri: 'file:///repo/file.ts' } } }]; + + async isInsideWorkTree(): Promise { return true; } + async getCurrentBranch(): Promise { return 'feature/test'; } + async getDefaultBranch(): Promise { return 'main'; } + async getBranches(): Promise { return []; } + async getRepositoryRoot(): Promise { return URI.file('/repo'); } + async getWorktreeRoots(): Promise { return []; } + async addWorktree(): Promise { } + async addExistingWorktree(): Promise { } + async removeWorktree(): Promise { } + async branchExists(): Promise { return false; } + async hasUncommittedChanges(): Promise { + this.calls.push('hasUncommittedChanges'); + return this.uncommitted; + } + async commitAll(_workingDirectory: URI, message: string): Promise { + this.calls.push(`commitAll:${message}`); + this.uncommitted = false; + } + async hasUpstream(): Promise { + this.calls.push('hasUpstream'); + return this.upstream; + } + async pushBranch(_workingDirectory: URI, branchName: string, setUpstream: boolean): Promise { + this.calls.push(`pushBranch:${branchName}:${setUpstream}`); + } + async getSessionGitState(): Promise { return undefined; } + async computeSessionFileDiffs(): Promise { + this.calls.push('computeSessionFileDiffs'); + return this.branchChanges; + } + async showBlob(): Promise { return undefined; } + async captureWorkingTreeAsTree(): Promise { return undefined; } + async commitTree(): Promise { return undefined; } + async updateRef(): Promise { } + async deleteRefs(): Promise { } + async revParse(): Promise { return undefined; } + async computeFileDiffsBetweenRefs(): Promise { return undefined; } +} + +class TestOctoKitService implements IAgentHostOctoKitService { + declare readonly _serviceBrand: undefined; + + readonly calls: string[] = []; + existing: CreatedPullRequest | undefined; + existingAfterCreateFailure: CreatedPullRequest | undefined; + createError: Error | undefined; + findAfterCreateError: Error | undefined; + created: CreatedPullRequest = { url: 'https://github.com/microsoft/vscode/pull/123', number: 123 }; + + async createPullRequest(_owner: string, _repo: string, _title: string, _body: string, _head: string, _base: string, draft: boolean, _token: string, _signal: AbortSignal): Promise { + this.calls.push(`createPullRequest:${draft}`); + if (this.createError) { + throw this.createError; + } + return this.created; + } + async findPullRequestByHeadBranch(_owner: string, _repo: string, branch: string, _token: string, _signal: AbortSignal): Promise { + this.calls.push(`findPullRequestByHeadBranch:${branch}`); + if (this.calls.some(call => call.startsWith('createPullRequest:'))) { + if (this.findAfterCreateError) { + throw this.findAfterCreateError; + } + return this.existingAfterCreateFailure; + } + return this.existing; + } +} + +function createAgentService(): IAgentService { + return { + getAuthToken: resource => resource.resource === GITHUB_REPO_PROTECTED_RESOURCE.resource ? 'gh-token' : undefined, + } as IAgentService; +} + +function setup(disposables: Pick, gitService: TestGitService, octoKitService: TestOctoKitService): { handler: AgentHostPullRequestOperationHandler; session: URI; createdEvents: string[] } { + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const session = URI.parse('agent:/session'); + const createdEvents: string[] = []; + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: 1, + modifiedAt: 1, + workingDirectory: URI.file('/repo').toString(), + }); + stateManager.setSessionMeta(session.toString(), withSessionGitState(undefined, { + hasGitHubRemote: true, + githubOwner: 'microsoft', + githubRepo: 'vscode', + branchName: 'feature/test', + baseBranchName: 'main', + })); + return { + handler: new AgentHostPullRequestOperationHandler(false, sessionKey => stateManager.getSessionState(sessionKey), event => createdEvents.push(`${event.sessionKey}:${event.branchName}`), createAgentService(), gitService, octoKitService, new NullLogService()), + session, + createdEvents, + }; +} + +suite('AgentHostPullRequestOperationHandler', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + // Matches the Copilot CLI Agent Window behavior: if the session has + // uncommitted work, Create PR first commits that work, then pushes the + // branch, then asks GitHub to create the PR. + test('commits uncommitted changes before pushing and creating a pull request', async () => { + const gitService = new TestGitService(); + gitService.uncommitted = true; + const octoKitService = new TestOctoKitService(); + const { handler, session, createdEvents } = setup(disposables, gitService, octoKitService); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None); + + assert.deepStrictEqual({ + message: result.message, + gitCalls: gitService.calls, + octoCalls: octoKitService.calls, + createdEvents, + }, { + message: { markdown: 'Created pull request [#123](https://github.com/microsoft/vscode/pull/123).' }, + gitCalls: [ + 'hasUncommittedChanges', + 'commitAll:Agent Host changes for feature/test', + 'computeSessionFileDiffs', + 'hasUpstream', + 'pushBranch:feature/test:true', + ], + octoCalls: [ + 'findPullRequestByHeadBranch:feature/test', + 'createPullRequest:false', + ], + createdEvents: ['agent:/session:feature/test'], + }); + }); + + // GitHub returns 422 when a PR already exists for the branch. The handler + // should preflight the branch and return/open the existing PR instead of + // trying to create a duplicate. + test('returns an existing pull request without creating a duplicate', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + octoKitService.existing = { url: 'https://github.com/microsoft/vscode/pull/7', number: 7 }; + const { handler, session, createdEvents } = setup(disposables, gitService, octoKitService); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None); + + assert.deepStrictEqual({ + message: result.message, + octoCalls: octoKitService.calls, + followUp: result.followUp, + createdEvents, + }, { + message: { markdown: 'Pull request [#7](https://github.com/microsoft/vscode/pull/7) already exists.' }, + octoCalls: ['findPullRequestByHeadBranch:feature/test'], + followUp: { content: { uri: 'https://github.com/microsoft/vscode/pull/7', contentType: 'text/html' }, external: true }, + createdEvents: ['agent:/session:feature/test'], + }); + }); + + // A visible PR button can race with refreshed git state. If the backend + // discovers that the branch has no file changes, it should stop before + // calling GitHub so the user gets a local, actionable failure. + test('does not call GitHub when there are no branch changes', async () => { + const gitService = new TestGitService(); + gitService.branchChanges = []; + const octoKitService = new TestOctoKitService(); + const { handler, session } = setup(disposables, gitService, octoKitService); + + await assert.rejects( + () => handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None), + /no branch changes/, + ); + assert.deepStrictEqual(octoKitService.calls, []); + }); + + test('does not push or call GitHub when branch changes cannot be computed', async () => { + const gitService = new TestGitService(); + gitService.branchChanges = undefined; + const octoKitService = new TestOctoKitService(); + const { handler, session } = setup(disposables, gitService, octoKitService); + + await assert.rejects( + () => handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None), + /Could not compute branch changes/, + ); + + assert.deepStrictEqual({ gitCalls: gitService.calls, octoCalls: octoKitService.calls }, { + gitCalls: ['hasUncommittedChanges', 'computeSessionFileDiffs'], + octoCalls: [], + }); + }); + + test('returns existing pull request found after create failure', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + octoKitService.createError = new Error('Validation Failed'); + octoKitService.existingAfterCreateFailure = { url: 'https://github.com/microsoft/vscode/pull/8', number: 8 }; + const { handler, session, createdEvents } = setup(disposables, gitService, octoKitService); + + const result = await handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None); + + assert.deepStrictEqual({ message: result.message, octoCalls: octoKitService.calls, createdEvents }, { + message: { markdown: 'Pull request [#8](https://github.com/microsoft/vscode/pull/8) already exists.' }, + octoCalls: ['findPullRequestByHeadBranch:feature/test', 'createPullRequest:false', 'findPullRequestByHeadBranch:feature/test'], + createdEvents: ['agent:/session:feature/test'], + }); + }); + + test('preserves create failure when existing pull request recovery fails', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + octoKitService.createError = new Error('create failed'); + octoKitService.findAfterCreateError = new Error('find failed'); + const { handler, session } = setup(disposables, gitService, octoKitService); + + await assert.rejects( + () => handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, CancellationToken.None), + /create failed/, + ); + }); + + test('honors cancellation before mutating the repository', async () => { + const gitService = new TestGitService(); + const octoKitService = new TestOctoKitService(); + const { handler, session, createdEvents } = setup(disposables, gitService, octoKitService); + const cts = new CancellationTokenSource(); + disposables.add(cts); + cts.cancel(); + + await assert.rejects( + () => handler.invoke({ channel: buildSessionChangesetUri(session.toString()), operationId: AgentHostPullRequestOperationHandler.OPERATION_CREATE_PR }, cts.token), + /Pull request operation was cancelled/, + ); + + assert.deepStrictEqual({ gitCalls: gitService.calls, octoCalls: octoKitService.calls, createdEvents }, { + gitCalls: [], + octoCalls: [], + createdEvents: [], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts new file mode 100644 index 0000000000000..3cf2303ad1029 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { AgentHostPullRequestOperationContribution } from '../../node/agentHostPullRequestOperationProvider.js'; +import type { ISessionGitState } from '../../common/state/sessionState.js'; +import { ChangesetKind } from '../../common/changesetUri.js'; + +const githubBranchWithUncommittedChanges: ISessionGitState = { + hasGitHubRemote: true, + branchName: 'feature/test', + uncommittedChanges: 1, + outgoingChanges: 0, +}; + +suite('AgentHostPullRequestOperationContribution', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createContribution(): AgentHostPullRequestOperationContribution { + return disposables.add(new AgentHostPullRequestOperationContribution( + disposables.add(new AgentHostStateManager(new NullLogService())), + disposables.add(new InstantiationService()), + )); + } + + test('advertises PR operations for GitHub branches with uncommitted changes', () => { + const provider = createContribution(); + + const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Session, changesetUri: '' }); + + assert.deepStrictEqual(operations?.map(op => op.id), ['create-pr', 'create-draft-pr']); + }); + + test('does not advertise PR operations without GitHub branch changes', () => { + const provider = createContribution(); + + const actual = [ + provider.getOperations({ sessionKey: 'agent:/session', gitState: { ...githubBranchWithUncommittedChanges, hasGitHubRemote: false }, changesetKind: ChangesetKind.Session, changesetUri: '' }), + provider.getOperations({ sessionKey: 'agent:/session', gitState: { ...githubBranchWithUncommittedChanges, uncommittedChanges: 0, outgoingChanges: 0 }, changesetKind: ChangesetKind.Session, changesetUri: '' }), + ]; + + assert.deepStrictEqual(actual, [undefined, undefined]); + }); + + test('hides PR operations immediately after handler reports PR creation', () => { + const provider = createContribution(); + + provider.onPullRequestCreated({ sessionKey: 'agent:/session', branchName: 'feature/test' }); + const operations = provider.getOperations({ sessionKey: 'agent:/session', gitState: githubBranchWithUncommittedChanges, changesetKind: ChangesetKind.Session, changesetUri: '' }); + + assert.deepStrictEqual({ operations }, { + operations: undefined, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts b/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts new file mode 100644 index 0000000000000..b060f3f895088 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { NullLogService } from '../../../../log/common/log.js'; +import { AgentHostOctoKitService, type FetchFunction } from '../../../node/shared/agentHostOctoKitService.js'; + +type Captured = { url: string; init: RequestInit | undefined }; + +function getUrl(input: string | URL | Request): string { + if (typeof input === 'string') { + return input; + } + return input instanceof URL ? input.href : input.url; +} + +function makeService(fetchImpl: FetchFunction): AgentHostOctoKitService { + return new AgentHostOctoKitService(fetchImpl, new NullLogService()); +} + +function signal(): AbortSignal { + return new AbortController().signal; +} + +function capturingFetch(response: Response): { fetch: FetchFunction; captured: () => Captured } { + let lastCapture: Captured = { url: '', init: undefined }; + const impl: FetchFunction = async (input, init) => { + lastCapture = { url: getUrl(input), init }; + return response; + }; + return { fetch: impl, captured: () => lastCapture }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +suite('AgentHostOctoKitService', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('createPullRequest posts the expected request and parses the response', async () => { + const { fetch, captured } = capturingFetch(jsonResponse({ html_url: 'https://github.com/o/r/pull/42', number: 42 })); + const service = makeService(fetch); + + const result = await service.createPullRequest('o', 'r', 'My PR', 'Body', 'feature', 'main', false, 'gh-token', signal()); + + assert.deepStrictEqual(result, { url: 'https://github.com/o/r/pull/42', number: 42 }); + + const cap = captured(); + assert.strictEqual(cap.url, 'https://api.github.com/repos/o/r/pulls'); + assert.strictEqual(cap.init?.method, 'POST'); + const headers = cap.init?.headers as Record; + assert.strictEqual(headers['Authorization'], 'Bearer gh-token'); + assert.strictEqual(headers['Accept'], 'application/vnd.github+json'); + assert.strictEqual(headers['X-GitHub-Api-Version'], '2022-11-28'); + assert.strictEqual(headers['Content-Type'], 'application/json'); + assert.deepStrictEqual(JSON.parse(cap.init?.body as string), { + title: 'My PR', + body: 'Body', + head: 'feature', + base: 'main', + draft: false, + }); + }); + + test('createPullRequest forwards the draft flag', async () => { + const { fetch, captured } = capturingFetch(jsonResponse({ html_url: 'https://github.com/o/r/pull/7', number: 7 })); + const service = makeService(fetch); + + await service.createPullRequest('o', 'r', 't', 'b', 'h', 'b', true, 'tok', signal()); + + const sent = JSON.parse(captured().init?.body as string) as { draft: boolean }; + assert.strictEqual(sent.draft, true); + }); + + test('createPullRequest forwards the abort signal', async () => { + const { fetch, captured } = capturingFetch(jsonResponse({ html_url: 'https://github.com/o/r/pull/7', number: 7 })); + const service = makeService(fetch); + const controller = new AbortController(); + + await service.createPullRequest('o', 'r', 't', 'b', 'h', 'b', true, 'tok', controller.signal); + + assert.strictEqual(captured().init?.signal, controller.signal); + }); + + test('findPullRequestByHeadBranch fetches the latest matching pull request', async () => { + const { fetch, captured } = capturingFetch(jsonResponse([{ html_url: 'https://github.com/o/r/pull/9', number: 9 }])); + const service = makeService(fetch); + + const result = await service.findPullRequestByHeadBranch('o', 'r', 'feature/test', 'tok', signal()); + + assert.deepStrictEqual({ + result, + url: captured().url, + method: captured().init?.method, + }, { + result: { url: 'https://github.com/o/r/pull/9', number: 9 }, + url: 'https://api.github.com/repos/o/r/pulls?head=o%3Afeature%2Ftest&state=all&sort=updated&direction=desc&per_page=1', + method: 'GET', + }); + }); + + test('createPullRequest throws on non-OK response', async () => { + const service = makeService(capturingFetch(new Response('{"message":"Validation Failed"}', { status: 422, statusText: 'Unprocessable Entity' })).fetch); + + await assert.rejects( + () => service.createPullRequest('o', 'r', 't', 'b', 'h', 'b', false, 'tok', signal()), + /422 Unprocessable Entity - {"message":"Validation Failed"}/, + ); + }); + + test('createPullRequest truncates long non-OK response bodies', async () => { + const service = makeService(capturingFetch(new Response(`prefix\n${'x'.repeat(600)}`, { status: 500, statusText: 'Server Error' })).fetch); + + await assert.rejects( + () => service.createPullRequest('o', 'r', 't', 'b', 'h', 'b', false, 'tok', signal()), + err => err instanceof Error && err.message.includes(`prefix ${'x'.repeat(493)}...`) && !err.message.includes('x'.repeat(600)), + ); + }); + + test('createPullRequest throws when response is missing html_url or number', async () => { + const service = makeService(capturingFetch(jsonResponse({ html_url: 'https://github.com/o/r/pull/1' /* missing number */ })).fetch); + + await assert.rejects( + () => service.createPullRequest('o', 'r', 't', 'b', 'h', 'b', false, 'tok', signal()), + /Failed to create pull request for o\/r/, + ); + }); +}); From 0b867a71c17e9929387447b233b898a3763c63f3 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 5 Jun 2026 12:43:22 +0100 Subject: [PATCH 06/29] Improve border visibility for key bindings in light/dark themes (#320076) fix(quick-input): improve border visibility for key bindings in light/dark themes Co-authored-by: mrleemurray --- src/vs/platform/quickinput/browser/media/quickInput.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index 2bee6c39bf80c..d6403f12e688a 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -380,6 +380,14 @@ border-color: var(--vscode-widget-shadow); } +/* In light/dark themes the widget shadow can be transparent, leaving the key borders invisible + against the focus/hover background. Derive the border from the foreground instead. High contrast + themes are excluded so their stronger borders are preserved. */ +:is(.vs, .vs-dark) .quick-input-list .monaco-list-row.focused .monaco-keybinding-key, +:is(.vs, .vs-dark) .quick-input-list .monaco-list-row:hover .monaco-keybinding-key { + border-color: color-mix(in srgb, currentColor 30%, transparent); +} + .quick-input-list .quick-input-list-separator-as-item { padding: 4px 6px; font-size: 12px; From 95b4defad481a4ee943d9ddcd1898ef4002df1c6 Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Fri, 5 Jun 2026 16:52:08 +0500 Subject: [PATCH 07/29] Fix suggest model triggering on disposed inline completions model (#320077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix suggest model triggering on disposed inline completions model The wait inside \`_waitForInlineCompletionsAndTrigger\` snapshotted \`inlineController.model.get()\` and then subscribed long-term to the snapshot's \`state\`/\`status\` derived observables. Those derived are owned only for debug — they are not registered to the model's \`_store\`, so after the controller swaps or disposes the model (text model replaced, readOnly toggled, controller disposed) they keep observing live external dependencies and continue to fire. That could cause the 750ms timeout to call \`stop()\` on a disposed model and the autorun to trigger quick suggest spuriously. Read \`inlineController.model\` as the first dependency of a single autorun; if it differs from the snapshot, bail out before touching \`state\`/\`status\`. A single autorun avoids the nested-autorun ordering hazard (no defined run order between independent autoruns that share a dependency). --- .../contrib/suggest/browser/suggestModel.ts | 46 ++++---- .../suggest/test/browser/suggestModel.test.ts | 100 ++++++++++++------ 2 files changed, 97 insertions(+), 49 deletions(-) diff --git a/src/vs/editor/contrib/suggest/browser/suggestModel.ts b/src/vs/editor/contrib/suggest/browser/suggestModel.ts index 595b7ef51c9f3..2b93dc5bf877f 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestModel.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestModel.ts @@ -3,37 +3,37 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { TimeoutTimer, disposableTimeout } from '../../../../base/common/async.js'; +import { disposableTimeout, TimeoutTimer } from '../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; +import { FuzzyScoreOptions } from '../../../../base/common/filters.js'; import { DisposableStore, dispose, IDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; import { getLeadingWhitespace, isHighSurrogate, isLowSurrogate } from '../../../../base/common/strings.js'; -import { ICodeEditor } from '../../../browser/editorBrowser.js'; -import { EditorOption } from '../../../common/config/editorOptions.js'; -import { CursorChangeReason, ICursorSelectionChangedEvent } from '../../../common/cursorEvents.js'; -import { IPosition, Position } from '../../../common/core/position.js'; -import { Selection } from '../../../common/core/selection.js'; -import { ITextModel } from '../../../common/model.js'; -import { CompletionContext, CompletionItemKind, CompletionItemProvider, CompletionTriggerKind } from '../../../common/languages.js'; -import { IEditorWorkerService } from '../../../common/services/editorWorker.js'; -import { WordDistance } from './wordDistance.js'; +import { assertType } from '../../../../base/common/types.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { CompletionModel } from './completionModel.js'; -import { CompletionDurations, CompletionItem, CompletionOptions, getSnippetSuggestSupport, provideSuggestionItems, QuickSuggestionsOptions, SnippetSortOrder } from './suggest.js'; +import { ICodeEditor } from '../../../browser/editorBrowser.js'; +import { EditorOption } from '../../../common/config/editorOptions.js'; +import { IPosition, Position } from '../../../common/core/position.js'; +import { Selection } from '../../../common/core/selection.js'; import { IWordAtPosition } from '../../../common/core/wordHelper.js'; +import { CursorChangeReason, ICursorSelectionChangedEvent } from '../../../common/cursorEvents.js'; +import { CompletionContext, CompletionItemKind, CompletionItemProvider, CompletionTriggerKind } from '../../../common/languages.js'; +import { ITextModel } from '../../../common/model.js'; +import { IEditorWorkerService } from '../../../common/services/editorWorker.js'; import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js'; -import { FuzzyScoreOptions } from '../../../../base/common/filters.js'; -import { assertType } from '../../../../base/common/types.js'; -import { InlineCompletionContextKeys } from '../../inlineCompletions/browser/controller/inlineCompletionContextKeys.js'; import { getInlineCompletionsController } from '../../inlineCompletions/browser/controller/common.js'; +import { InlineCompletionContextKeys } from '../../inlineCompletions/browser/controller/inlineCompletionContextKeys.js'; import { SnippetController2 } from '../../snippet/browser/snippetController2.js'; -import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { CompletionModel } from './completionModel.js'; +import { CompletionDurations, CompletionItem, CompletionOptions, getSnippetSuggestSupport, provideSuggestionItems, QuickSuggestionsOptions, SnippetSortOrder } from './suggest.js'; +import { WordDistance } from './wordDistance.js'; export interface ICancelEvent { readonly retrigger: boolean; @@ -459,7 +459,7 @@ export class SuggestModel implements IDisposable { const initialModelVersion = initialModel.getVersionId(); const inlineController = getInlineCompletionsController(this._editor); const inlineModel = inlineController?.model.get(); - if (!inlineModel) { + if (!inlineController || !inlineModel) { this.trigger({ auto: true }); return; } @@ -495,13 +495,21 @@ export class SuggestModel implements IDisposable { } }; - // Race: observe inline completions state vs 750ms timeout + // Reading `inlineController.model` first in a single autorun binds the + // wait to the model's lifetime: nested autoruns would have no defined + // run order, so an inner state-watcher could fire on a disposed model + // before the outer model-watcher cleaned it up. disposableTimeout(() => { triggerAndCleanUp(true); inlineModel.stop('automatic'); }, 750, store); store.add(autorun(reader => { + const currentInlineModel = inlineController.model.read(reader); + if (currentInlineModel !== inlineModel) { + triggerAndCleanUp(false); + return; + } const status = inlineModel.status.read(reader); const currentState = inlineModel.state.read(reader); if (!currentState && status === 'loading') { diff --git a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts index d99d63f7c9836..b68533c32397b 100644 --- a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts +++ b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts @@ -3,54 +3,54 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { ModifierKeyEmitter } from '../../../../../base/browser/dom.js'; +import { timeout } from '../../../../../base/common/async.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { IMenu, IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { MockKeybindingService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { CoreEditingCommands } from '../../../../browser/coreCommands.js'; import { EditOperation } from '../../../../common/core/editOperation.js'; import { Position } from '../../../../common/core/position.js'; import { Range } from '../../../../common/core/range.js'; import { Selection } from '../../../../common/core/selection.js'; import { Handler } from '../../../../common/editorCommon.js'; -import { ITextModel } from '../../../../common/model.js'; -import { TextModel } from '../../../../common/model/textModel.js'; -import { CompletionItemKind, CompletionItemProvider, CompletionList, CompletionTriggerKind, EncodedTokenizationResult, InlineCompletionsProvider, IState, TokenizationRegistry } from '../../../../common/languages.js'; import { MetadataConsts } from '../../../../common/encodedTokenAttributes.js'; +import { CompletionItemKind, CompletionItemProvider, CompletionList, CompletionTriggerKind, EncodedTokenizationResult, InlineCompletionsProvider, IState, TokenizationRegistry } from '../../../../common/languages.js'; +import { ILanguageService } from '../../../../common/languages/language.js'; import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js'; import { NullState } from '../../../../common/languages/nullTokenize.js'; -import { ILanguageService } from '../../../../common/languages/language.js'; +import { ITextModel } from '../../../../common/model.js'; +import { TextModel } from '../../../../common/model/textModel.js'; +import { IEditorWorkerService } from '../../../../common/services/editorWorker.js'; +import { ILanguageFeaturesService } from '../../../../common/services/languageFeatures.js'; +import { LanguageFeaturesService } from '../../../../common/services/languageFeaturesService.js'; +import { createTestCodeEditor, ITestCodeEditor, withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; +import { createModelServices, createTextModel, instantiateTextModel } from '../../../../test/common/testTextModel.js'; +import { InlineCompletionsController } from '../../../inlineCompletions/browser/controller/inlineCompletionsController.js'; +import { InlineSuggestionsView } from '../../../inlineCompletions/browser/view/inlineSuggestionsView.js'; import { SnippetController2 } from '../../../snippet/browser/snippetController2.js'; +import { getSnippetSuggestSupport, setSnippetSuggestSupport } from '../../browser/suggest.js'; import { SuggestController } from '../../browser/suggestController.js'; import { ISuggestMemoryService } from '../../browser/suggestMemory.js'; import { LineContext, SuggestModel } from '../../browser/suggestModel.js'; import { ISelectedSuggestion } from '../../browser/suggestWidget.js'; -import { createTestCodeEditor, ITestCodeEditor, withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; -import { createModelServices, createTextModel, instantiateTextModel } from '../../../../test/common/testTextModel.js'; -import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; -import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; -import { MockKeybindingService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -import { LanguageFeaturesService } from '../../../../common/services/languageFeaturesService.js'; -import { ILanguageFeaturesService } from '../../../../common/services/languageFeatures.js'; -import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { getSnippetSuggestSupport, setSnippetSuggestSupport } from '../../browser/suggest.js'; -import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; -import { timeout } from '../../../../../base/common/async.js'; -import { InlineCompletionsController } from '../../../inlineCompletions/browser/controller/inlineCompletionsController.js'; -import { InlineSuggestionsView } from '../../../inlineCompletions/browser/view/inlineSuggestionsView.js'; -import { IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; -import { IMenuService, IMenu } from '../../../../../platform/actions/common/actions.js'; -import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; -import { IEditorWorkerService } from '../../../../common/services/editorWorker.js'; -import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; -import { ModifierKeyEmitter } from '../../../../../base/browser/dom.js'; function createMockEditor(model: TextModel, languageFeaturesService: ILanguageFeaturesService): ITestCodeEditor { @@ -1546,4 +1546,44 @@ suite('SuggestModel - offWhenInlineCompletions with InlineCompletionsController' assert.strictEqual(didSuggest, true, 'Quick suggestions should have been triggered when inlineSuggest is disabled'); }); }); + + test('does not trigger after the inline model is disposed mid-wait (e.g., readonly toggled)', async function () { + // Provider that only resolves when its cancellation token fires. This keeps the + // wait in the loading state until either the inline model is disposed + // (cancelling the token) or the 750ms timeout fires. + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: (_model, _pos, _ctx, token) => new Promise(resolve => { + const d = token.onCancellationRequested(() => { + d.dispose(); + resolve({ items: [] }); + }); + }), + disposeInlineCompletions: () => { } + }; + + await withSuggestModelAndInlineCompletions('abc def', inlineProvider, async (suggestModel, editor) => { + let didSuggest = false; + const sub = suggestModel.onDidSuggest(() => { didSuggest = true; }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + // Let _waitForInlineCompletionsAndTrigger be scheduled and set up. + await timeout(50); + + // Toggling readonly causes the controller's `model` derivedDisposable to + // recompute and dispose the InlineCompletionsModel. SuggestModel does NOT + // cancel on configuration change, so without binding the wait to the + // model's lifetime, the 750ms timeout would still fire and call + // `this.trigger({ auto: true })` (and `stop()` on the disposed model). + editor.updateOptions({ readOnly: true }); + + // Advance past the 750ms timeout window. + await timeout(1000); + + sub.dispose(); + assert.strictEqual(didSuggest, false, + 'Quick suggest should not fire after the inline model is disposed mid-wait'); + }); + }); }); From ff61118bc0bb6ba3b02f0109f658c512a4e5ca6d Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Fri, 5 Jun 2026 13:52:11 +0200 Subject: [PATCH 08/29] fix: support token auth for CLI SDK mock server to enable auto-model in smoke tests (#320072) * fix: support token auth for CLI SDK mock server to enable auto-model in smoke tests - Add `advanced.debug.overrideAuthType` setting to control HMAC vs token auth when overrideProxyUrl is set (default: HMAC for dev, token for tests) - Update mock server model definitions to match real CAPI response shape (family, vendor, version, supported_endpoints, billing, etc.) - Add `selected_model` to mock `/models/session` response (required by SDK auto-mode resolution) - Add Responses API SSE handler for gpt-5.3-codex which uses `/responses` instead of `/chat/completions` - DRY up mock model definitions with shared `ALL_MODELS` array * fix: add inspectConfig to test mock for copilotCliAuth and skip the other CLI smoke tests for now * Don't run in PRs for now --- .../copilotcli/node/copilotCli.ts | 30 +- .../node/copilotcliSessionService.ts | 22 +- .../node/test/copilotCliAuth.spec.ts | 3 + .../common/configurationService.ts | 2 + .../chat-simulation/common/mock-llm-server.js | 401 ++++++++++++++---- .../areas/agentsWindow/agentsWindow.test.ts | 5 +- 6 files changed, 345 insertions(+), 118 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 24ce58bec2929..de8e228f3e688 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -631,22 +631,24 @@ export class CopilotCLISDK implements ICopilotCLISDK { const overrideProxyUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl); if (overrideProxyUrl) { - this.logService.info('[CopilotCLISession] Proxy URL configured, skipping client-side token validation'); - return { - type: 'hmac', - hmac: 'empty', - host: 'https://github.com', - copilotUser: { - endpoints: { - api: overrideProxyUrl, - // `proxy` must also point at the mock server so that SDK - // calls to /copilot_internal/v2/token and /models/session - // are routed to the mock instead of the real GitHub API - // (which would reject the fake HMAC with a 401). - proxy: overrideProxyUrl, - } + // Only respect this from user (global) settings — a malicious workspace + // setting could downgrade auth from HMAC to token. + const authTypeInspect = this.configurationService.inspectConfig(ConfigKey.Shared.DebugOverrideAuthType); + const authType = authTypeInspect?.globalValue ?? 'hmac'; + this.logService.info(`[CopilotCLISession] Proxy URL configured (authType=${authType}), skipping client-side token validation`); + const copilotUser = { + endpoints: { + api: overrideProxyUrl, + // `proxy` must also point at the mock server so that SDK + // calls to /copilot_internal/v2/token and /models/session + // are routed to the mock instead of the real GitHub API. + proxy: overrideProxyUrl, } }; + if (authType === 'token') { + return { type: 'token', token: 'mock-token', host: 'https://github.com', copilotUser }; + } + return { type: 'hmac', hmac: 'empty', host: 'https://github.com', copilotUser }; } const { resolveAuthInfoFromToken } = await this.getPackage(); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 50b1a6b70962b..8cb85b41ee725 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -783,18 +783,16 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS await Promise.all(promises); if (sessionOptions.copilotUrl) { - sdkSession.updateOptions({ - authInfo: { - type: 'hmac', - hmac: 'empty', - host: 'https://github.com', - copilotUser: { - endpoints: { - api: sessionOptions.copilotUrl - } - } - } - }); + // Only respect this from user (global) settings — a malicious workspace + // setting could downgrade auth from HMAC to token. + const authTypeInspect = this.configurationService.inspectConfig(ConfigKey.Shared.DebugOverrideAuthType); + const authType = authTypeInspect?.globalValue ?? 'hmac'; + const copilotUser = { endpoints: { api: sessionOptions.copilotUrl } }; + const host = 'https://github.com' as const; + const authInfo = authType === 'token' + ? { type: 'token' as const, token: 'mock-token', host, copilotUser } + : { type: 'hmac' as const, hmac: 'empty', host, copilotUser }; + sdkSession.updateOptions({ authInfo }); } this.logService.trace(`[CopilotCLISession] Created new CopilotCLI session ${sdkSession.sessionId}.`); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliAuth.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliAuth.spec.ts index 04c960efc779e..ecd49dc6eb1d8 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliAuth.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliAuth.spec.ts @@ -80,6 +80,9 @@ describe('CopilotCLISDK Authentication', () => { return 'https://proxy.example.com'; } return undefined; + }, + inspectConfig() { + return undefined; } } as unknown as IConfigurationService; diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 3c3ef5c07c8b6..96cf10650a406 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -581,6 +581,8 @@ export namespace ConfigKey { export namespace Shared { /** Allows for overriding the base domain we use for making requests to the CAPI. This helps CAPI devs develop against a local instance. */ export const DebugOverrideProxyUrl = defineSetting('advanced.debug.overrideProxyUrl', ConfigType.Simple, undefined); + /** Auth type to use when overrideProxyUrl or overrideCapiUrl is set. 'hmac' (default) for internal dev builds, 'token' for smoke tests / mock servers that don't support HMAC. */ + export const DebugOverrideAuthType = defineSetting<'hmac' | 'token'>('advanced.debug.overrideAuthType', ConfigType.Simple, 'hmac'); export const DebugOverrideCAPIUrl = defineSetting('advanced.debug.overrideCapiUrl', ConfigType.Simple, undefined); export const DebugUseNodeFetchFetcher = defineSetting('advanced.debug.useNodeFetchFetcher', ConfigType.Simple, true); export const DebugUseNodeFetcher = defineSetting('advanced.debug.useNodeFetcher', ConfigType.Simple, false); diff --git a/scripts/chat-simulation/common/mock-llm-server.js b/scripts/chat-simulation/common/mock-llm-server.js index 73c2e32a56423..ccc76b8721f54 100644 --- a/scripts/chat-simulation/common/mock-llm-server.js +++ b/scripts/chat-simulation/common/mock-llm-server.js @@ -215,49 +215,116 @@ const MODEL = 'gpt-4o-2024-08-06'; * /models list, otherwise the SDK fails with "No model available". */ const EXTRA_MODELS = [ + // gpt-5.3-codex — the Copilot CLI SDK's default model. + // Shape matches real CAPI /models response exactly. { id: 'gpt-5.3-codex', - name: 'GPT-5.3 Codex (Mock)', - version: '2025-01-01', - vendor: 'copilot', - model_picker_enabled: false, - is_chat_default: false, + name: 'GPT-5.3-Codex (Mock)', + object: 'model', + version: 'gpt-5.3-codex', + vendor: 'OpenAI', + model_picker_enabled: true, + model_picker_category: 'powerful', + model_picker_price_category: 'medium', + is_chat_default: true, is_chat_fallback: false, - billing: { is_premium: false, multiplier: 0 }, + preview: false, + billing: { restricted_to: ['pro', 'edu', 'pro_plus', 'individual_trial', 'business', 'enterprise', 'max'], token_prices: { batch_size: 1000000, default: { cache_price: 17, context_max: 272000, input_price: 175, output_price: 1400 } } }, capabilities: { type: 'chat', - family: 'gpt-4o', + family: 'gpt-5.3-codex', tokenizer: 'o200k_base', - limits: { max_prompt_tokens: 10000000, max_output_tokens: 131072, max_context_window_tokens: 10000000 }, - supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: false }, + object: 'model_capabilities', + limits: { max_prompt_tokens: 272000, max_output_tokens: 128000, max_context_window_tokens: 400000, vision: { max_prompt_image_size: 3145728, max_prompt_images: 1, supported_media_types: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] } }, + supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: true, structured_outputs: true, reasoning_effort: ['low', 'medium', 'high', 'xhigh'] }, }, - supported_endpoints: ['/chat/completions'], + supported_endpoints: ['/responses'], }, - // Anthropic Claude model — required by the Claude Code session type, which - // filters endpoints for `modelProvider: 'Anthropic'`, `apiType: 'messages'`, - // `supportsToolCalls: true`, and `showInModelPicker: true` - // (see `ClaudeCodeModels._fetchAvailableEndpoints`). Routes to the - // `/v1/messages` mock handler which emits Anthropic-format SSE. + // Anthropic Claude model — required by the Claude Code session type. { id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5 (Mock)', - version: '2025-01-01', + object: 'model', + version: 'claude-sonnet-4.5', vendor: 'Anthropic', model_picker_enabled: true, + model_picker_category: 'versatile', + model_picker_price_category: 'medium', is_chat_default: false, is_chat_fallback: false, - billing: { is_premium: false, multiplier: 0 }, + preview: false, + billing: { restricted_to: ['pro', 'pro_plus', 'max', 'business', 'enterprise'], token_prices: { batch_size: 1000000, default: { cache_price: 30, input_price: 300, output_price: 1500 } } }, capabilities: { type: 'chat', family: 'claude-sonnet-4.5', tokenizer: 'o200k_base', - limits: { max_prompt_tokens: 200000, max_output_tokens: 8192, max_context_window_tokens: 200000 }, - supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: true }, + object: 'model_capabilities', + limits: { max_prompt_tokens: 168000, max_output_tokens: 32000, max_context_window_tokens: 200000, max_non_streaming_output_tokens: 16000, vision: { max_prompt_image_size: 3145728, max_prompt_images: 5, supported_media_types: ['image/jpeg', 'image/png', 'image/webp'] } }, + supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: true, max_thinking_budget: 32000, min_thinking_budget: 1024 }, }, - supported_endpoints: ['/v1/messages'], + supported_endpoints: ['/chat/completions', '/v1/messages'], }, ]; +/** + * Complete model list used by both GET /models and GET /models/{id}. + * Kept in a single array so the two handlers always return consistent data. + */ +const ALL_MODELS = [ + { + id: MODEL, + name: 'GPT-4o (Mock)', + object: 'model', + version: 'gpt-4o-2024-08-06', + vendor: 'Azure OpenAI', + model_picker_enabled: false, + model_picker_price_category: 'medium', + is_chat_default: false, + is_chat_fallback: true, + preview: false, + billing: { token_prices: { batch_size: 1000000, default: { cache_price: 125, input_price: 250, output_price: 1000 } } }, + capabilities: { + type: 'chat', + family: 'gpt-4o', + tokenizer: 'o200k_base', + object: 'model_capabilities', + limits: { + // Use a very large token limit so the Responses API compaction + // threshold (90% of max_prompt_tokens) is never reached during + // perf benchmarks. + max_prompt_tokens: 10000000, + max_output_tokens: 131072, + max_context_window_tokens: 10000000, + }, + supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: false }, + }, + supported_endpoints: ['/chat/completions'], + }, + { + id: 'gpt-4o-mini', + name: 'GPT-4o mini (Mock)', + object: 'model', + version: 'gpt-4o-mini-2024-07-18', + vendor: 'Azure OpenAI', + model_picker_enabled: false, + model_picker_price_category: 'low', + is_chat_default: false, + is_chat_fallback: false, + preview: false, + billing: { token_prices: { batch_size: 1000000, default: { cache_price: 15, input_price: 30, output_price: 120 } } }, + capabilities: { + type: 'chat', + family: 'gpt-4o-mini', + tokenizer: 'o200k_base', + object: 'model_capabilities', + limits: { max_prompt_tokens: 12288, max_output_tokens: 4096, max_context_window_tokens: 128000 }, + supports: { streaming: true, tool_calls: true, parallel_tool_calls: true }, + }, + supported_endpoints: ['/chat/completions'], + }, + ...EXTRA_MODELS, +]; + /** * @param {string} content * @param {number} index @@ -502,6 +569,7 @@ function handleRequest(req, res) { readBody().then(() => { json(200, { available_models: [MODEL, 'gpt-4o-mini', ...EXTRA_MODELS.map(m => m.id)], + selected_model: 'gpt-5.3-codex', session_token: 'perf-session-token-' + Date.now(), expires_at: Math.floor(Date.now() / 1000) + 3600, discounted_costs: {}, @@ -512,68 +580,7 @@ function handleRequest(req, res) { // -- Models (DomainService.capiModelsURL = /models) -------------- if (path === '/models' && req.method === 'GET') { - json(200, { - data: [ - { - id: MODEL, - name: 'GPT-4o (Mock)', - version: '2024-05-13', - vendor: 'copilot', - model_picker_enabled: true, - is_chat_default: true, - is_chat_fallback: true, - billing: { is_premium: false, multiplier: 0 }, - capabilities: { - type: 'chat', - family: 'gpt-4o', - tokenizer: 'o200k_base', - limits: { - // Use a very large token limit so the Responses API compaction - // threshold (90% of max_prompt_tokens) is never reached during - // perf benchmarks. - max_prompt_tokens: 10000000, - max_output_tokens: 131072, - max_context_window_tokens: 10000000, - }, - supports: { - streaming: true, - tool_calls: true, - parallel_tool_calls: true, - vision: false, - }, - }, - supported_endpoints: ['/chat/completions'], - }, - { - id: 'gpt-4o-mini', - name: 'GPT-4o mini (Mock)', - version: '2024-07-18', - vendor: 'copilot', - model_picker_enabled: false, - is_chat_default: false, - is_chat_fallback: false, - billing: { is_premium: false, multiplier: 0 }, - capabilities: { - type: 'chat', - family: 'gpt-4o-mini', - tokenizer: 'o200k_base', - limits: { - max_prompt_tokens: 10000000, - max_output_tokens: 131072, - max_context_window_tokens: 10000000, - }, - supports: { - streaming: true, - tool_calls: true, - parallel_tool_calls: true, - vision: false, - }, - }, - supported_endpoints: ['/chat/completions'], - }, - ...EXTRA_MODELS, - ], - }); + json(200, { data: ALL_MODELS }); return; } @@ -584,22 +591,30 @@ function handleRequest(req, res) { json(200, { state: 'accepted', terms: '' }); return; } - json(200, { + const knownModel = ALL_MODELS.find(m => m.id === modelId); + // TODO: give a 404 for unknown models instead of a fallback response. This requires + const result = knownModel || { id: modelId || MODEL, - name: 'GPT-4o (Mock)', + name: `${modelId} (Mock)`, version: '2024-05-13', vendor: 'copilot', - model_picker_enabled: true, - is_chat_default: true, - is_chat_fallback: true, + model_picker_enabled: false, + is_chat_default: false, + is_chat_fallback: false, + billing: { is_premium: false, multiplier: 0 }, capabilities: { type: 'chat', - family: 'gpt-4o', + family: modelId || 'gpt-4o', tokenizer: 'o200k_base', - limits: { max_prompt_tokens: 10000000, max_output_tokens: 131072, max_context_window_tokens: 10000000 }, + object: 'model_capabilities', + limits: { max_prompt_tokens: 272000, max_output_tokens: 128000, max_context_window_tokens: 400000 }, supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: false }, }, - }); + supported_endpoints: ['/chat/completions'], + }; + const ts = new Date().toISOString().slice(11, -1); + _log(`[mock-llm] ${ts} GET /models/${modelId} → ${knownModel ? 'known' : 'fallback'}, family=${result.capabilities?.family}, endpoints=${JSON.stringify(result.supported_endpoints)}`); + json(200, result); return; } @@ -641,8 +656,11 @@ function handleRequest(req, res) { } // -- Responses API (DomainService.capiResponsesURL = /responses) -- + // The Responses API uses a different SSE event format than Chat Completions. + // The SDK expects events like response.created, response.output_item.added, + // response.output_text.delta, response.output_item.done, response.completed. if (path === '/responses' && req.method === 'POST') { - readBody().then((/** @type {string} */ body) => handleChatCompletions(body, res)); + readBody().then((/** @type {string} */ body) => handleResponsesApi(body, res)); return; } @@ -909,6 +927,207 @@ async function streamContent(res, chunks, isScenarioRequest) { } } +// ----- Responses API (OpenAI) --------------------------------------------------- + +/** + * Handle a Responses API request. The Responses API uses a different SSE event + * format than Chat Completions — the SDK expects `response.created`, + * `response.output_item.added`, `response.output_text.delta`, + * `response.output_item.done`, and `response.completed` events. + * + * The request body uses `input` (array of items) instead of `messages`. + * + * @param {string} body + * @param {http.ServerResponse} res + */ +async function handleResponsesApi(body, res) { + if (_verbose) { + _log(`[mock-llm] /responses request body:`); + try { + _log(_indentVerbose(_formatVerbose(JSON.parse(body)))); + } catch { + _log(_indentVerbose(_formatVerbose(body))); + } + } + + let scenarioId = DEFAULT_SCENARIO; + let isScenarioRequest = false; + /** @type {string[]} */ + let requestToolNames = []; + try { + const parsed = JSON.parse(body); + // Responses API uses `input` array and `tools` array + const input = parsed.input || []; + const tools = parsed.tools || []; + requestToolNames = tools.map((/** @type {any} */ t) => t.name).filter(Boolean); + + // Search input items for scenario tags (input items have role + content) + for (let i = input.length - 1; i >= 0; i--) { + const item = input[i]; + if (item.role !== 'user') { continue; } + const content = typeof item.content === 'string' + ? item.content + : Array.isArray(item.content) + ? item.content.map((/** @type {any} */ c) => c.text || '').join('') + : ''; + const match = content.match(/\[scenario:([^\]]+)\]/); + if (match && SCENARIOS[match[1]]) { + scenarioId = match[1]; + isScenarioRequest = true; + break; + } + } + + const ts = new Date().toISOString().slice(11, -1); + _log(`[mock-llm] ${ts} → responses-api: ${input.length} input items, ${requestToolNames.length} tools, scenario=${scenarioId}`); + } catch { } + + const scenario = SCENARIOS[scenarioId] || SCENARIOS[DEFAULT_SCENARIO]; + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Request-Id': 'perf-benchmark-' + Date.now(), + }); + + // For multi-turn tool-call scenarios, convert to Responses API tool_use format + if (isMultiTurnScenario(scenario) && requestToolNames.length > 0) { + // For now, fall back to content-only for Responses API + // (tool calls would need response.output_item with type: 'function_call') + } + + // Resolve content chunks + const chunks = isMultiTurnScenario(scenario) + ? getFirstContentTurn(scenario) + : /** @type {StreamChunk[]} */ (scenario); + + await streamResponsesContent(res, chunks, isScenarioRequest); +} + +/** + * Stream content as Responses API SSE events. + * @param {http.ServerResponse} res + * @param {StreamChunk[]} chunks + * @param {boolean} isScenarioRequest + */ +async function streamResponsesContent(res, chunks, isScenarioRequest) { + const responseId = `resp_mock_${Date.now()}`; + const outputItemId = `msg_mock_${Date.now()}`; + const model = 'gpt-5.3-codex'; + + // 1. response.created + res.write(`data: ${JSON.stringify({ + type: 'response.created', + response: { + id: responseId, + object: 'response', + created_at: Math.floor(Date.now() / 1000), + model, + status: 'in_progress', + output: [], + usage: null, + }, + })}\n\n`); + + // 2. response.output_item.added — add a message output item + res.write(`data: ${JSON.stringify({ + type: 'response.output_item.added', + output_index: 0, + item: { + id: outputItemId, + type: 'message', + role: 'assistant', + status: 'in_progress', + content: [], + }, + })}\n\n`); + + // 3. response.content_part.added — add a text content part + res.write(`data: ${JSON.stringify({ + type: 'response.content_part.added', + output_index: 0, + content_index: 0, + part: { type: 'output_text', text: '' }, + })}\n\n`); + + // 4. Stream text deltas + let fullText = ''; + for (const chunk of chunks) { + if (chunk.delayMs > 0) { await sleep(chunk.delayMs); } + fullText += chunk.content; + res.write(`data: ${JSON.stringify({ + type: 'response.output_text.delta', + output_index: 0, + content_index: 0, + delta: chunk.content, + })}\n\n`); + } + + // 5. response.output_text.done + res.write(`data: ${JSON.stringify({ + type: 'response.output_text.done', + output_index: 0, + content_index: 0, + text: fullText, + })}\n\n`); + + // 6. response.content_part.done + res.write(`data: ${JSON.stringify({ + type: 'response.content_part.done', + output_index: 0, + content_index: 0, + part: { type: 'output_text', text: fullText }, + })}\n\n`); + + // 7. response.output_item.done + res.write(`data: ${JSON.stringify({ + type: 'response.output_item.done', + output_index: 0, + item: { + id: outputItemId, + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: fullText }], + }, + })}\n\n`); + + // 8. response.completed — the terminal event the SDK waits for + res.write(`data: ${JSON.stringify({ + type: 'response.completed', + response: { + id: responseId, + object: 'response', + created_at: Math.floor(Date.now() / 1000), + model, + status: 'completed', + output: [ + { + id: outputItemId, + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: fullText }], + }, + ], + usage: { + input_tokens: 100, + output_tokens: Math.max(1, Math.ceil(fullText.length / 4)), + total_tokens: 100 + Math.max(1, Math.ceil(fullText.length / 4)), + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + })}\n\n`); + + res.end(); + + if (isScenarioRequest) { + serverEvents.emit('scenarioCompletion'); + } +} + // ----- Anthropic Messages API ------------------------------------------------- /** diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index 346acf721654c..703cacef1f18c 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -96,6 +96,9 @@ export function setup(logger: Logger) { // sessions.chat.localAgent.enabled exposes the "Local" session type. await app.workbench.settingsEditor.addUserSettings([ ['github.copilot.advanced.debug.overrideProxyUrl', JSON.stringify(mockServer.url)], + // Use token auth (not HMAC) so the SDK can call /models and + // /models/session against the mock server without HMAC validation. + ['github.copilot.advanced.debug.overrideAuthType', '"token"'], ['chat.allowAnonymousAccess', 'true'], ['github.copilot.chat.githubMcpServer.enabled', 'false'], ['sessions.chat.localAgent.enabled', 'true'], @@ -133,7 +136,7 @@ export function setup(logger: Logger) { ); }); - it('Test Copilot CLI session (sandbox)', async function () { + it.skip('Test Copilot CLI session (sandbox)', async function () { // Sandbox-backed shell tool currently only runs cleanly on macOS // in CI. On Linux the bubblewrap policy fails to start bash inside // the sandbox; on Windows AppContainer cold-start usually exceeds From d0f054372415adad7d6b5842376a3b79cecd89ac Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 5 Jun 2026 13:27:47 +0100 Subject: [PATCH 09/29] Update checkbox border colors for 2026 themes (#320082) fix: update checkbox border colors for 2026 dark and light themes Co-authored-by: mrleemurray Co-authored-by: Copilot --- extensions/theme-defaults/themes/2026-dark.json | 2 +- extensions/theme-defaults/themes/2026-light.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index f1fc997eb150f..738540a5ce828 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -25,7 +25,7 @@ "button.secondaryHoverBackground": "#FFFFFF10", "button.secondaryBorder": "#333536", "checkbox.background": "#242526", - "checkbox.border": "#333536", + "checkbox.border": "#707070", "checkbox.foreground": "#8C8C8C", "dropdown.background": "#191A1B", "dropdown.border": "#333536", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index a094e69c99a47..46c622a72ee06 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -27,7 +27,7 @@ "button.secondaryHoverBackground": "#F2F3F4", "button.secondaryBorder": "#EAEAEA", "checkbox.background": "#EAEAEA", - "checkbox.border": "#D8D8D8", + "checkbox.border": "#868686", "checkbox.foreground": "#606060", "dropdown.background": "#FFFFFF", "dropdown.border": "#D8D8D8", From 02629fd4b7fae4de28ca0839a8ad106a90717a35 Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Fri, 5 Jun 2026 17:51:10 +0500 Subject: [PATCH 10/29] nes-datagen: stream multi-GB inputs/outputs and switch output to JSON Lines (#320089) * nes-datagen: stream large JSON array inputs to avoid 2 GiB readFile limit Reading the whole input via fs.readFile fails for files larger than 2 GiB (and exceeds V8's max string length). Add a streaming JSON-array parser and use it in both the sequential and parallel pipeline paths so multi-GB recordings can be processed with bounded memory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * nes-datagen: also accept JSON Lines (NDJSON) input Auto-detect the input format from the first non-whitespace character: a leading '[' is parsed as a single JSON array, otherwise the file is parsed as JSON Lines (one JSON object per line). Both formats are streamed so multi-GB inputs work regardless of shape. Rename streamJsonArray -> streamJsonRecords to reflect the broader purpose. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * nes-datagen: infer JSON vs JSON Lines input format from file extension Use the file extension (.jsonl/.ndjson -> JSON Lines, otherwise JSON array) to select the streaming parser instead of sniffing the content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * nes-datagen: validate streamed JSON arrays for truncation and malformed input The new streaming parser previously accepted any prefix of a JSON array silently: a truncated file (no closing ']'), a missing element between commas, a trailing comma or trailing data after the array all produced zero or fewer records rather than an error. That is especially dangerous for the multi-GB inputs this parser was introduced for, because the underlying file is much more likely to be incomplete. Tighten the state machine to surface these as errors, matching what the old whole-file JSON.parse would have done, and add tests for each case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * nes-datagen: stream worker-result merging and final output write For multi-GB inputs the parent process was still hitting V8's ~512 MiB max-string-length limit in two places after the input-side fix: 1. Merging worker result files used fs.promises.readFile + JSON.parse on each result, but with a 5+ GB input split N ways each per-worker file is hundreds of MB of similar-shaped data and easily exceeds the string limit. 2. writeSamples serialized the entire validSamples array via a single JSON.stringify(arr, null, 2) before writing, which has the same problem on output. Switch both to stream over individual records: - A new shared openWriteStream(filePath) helper wraps fs.createWriteStream, attaches an 'error' listener immediately (so async write failures don't surface as uncaughtException and skip cleanup), awaits backpressure via the per-write callback, and exposes an idempotent close(). - writeChunkFiles uses the helper inside a try/finally so any mid-stream ENOSPC/EIO bubbles up cleanly and the tmp dir is still removed. - The merge step now uses streamJsonRecords(resultPath), so the parent never materializes a single worker's output as one string. - writeSamples emits the output JSON array incrementally: per-sample JSON.stringify(..., null, 2) (indented two spaces to match the previous layout) joined with ',\n'. Byte size is accumulated for the existing IWriteResult.fileSize. Also documents that single-process loadAndParseInput still buffers the full row set in memory and that --parallelism is required for very large inputs (workers each only load their slice). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * nes-datagen: switch output to JSON Lines Both the final user-facing output and the per-worker intermediate result files now use JSON Lines (one record per line) instead of a pretty-printed JSON array. JSONL is dramatically simpler to write and read incrementally: no surrounding brackets/commas to track, no multi-line per-element indentation, just JSON.stringify + '\n' per record on the write side and split-on-newline + JSON.parse per non-empty line on the read side (this is what streamJsonRecords already does when it detects the .jsonl extension). Changes: - writeSamples emits one JSON.stringify(sample) + '\n' per validated sample via no array wrapper, no pretty-printing.openWriteStream - resolveOutputPath defaults the implicit output to _output.jsonl (was _output.json). - Per-worker result files in runInputPipelineParallel are now result_${w}.jsonl, so the merge step's streamJsonRecords auto-picks the JSONL parser from the extension. - E2e tests updated to read JSONL (split on newline, JSON.parse per line) and to use .jsonl output paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * nes-datagen: surface worker-result parse errors and update --out help text Two review follow-ups: 1. The merge step that streams each worker's result file used to wrap the iteration in a try/catch that downgraded any parse error to a console.error warning. With the new streaming reader that is unsafe: streamJsonRecords yields N valid records first and then throws on a malformed/truncated tail, leaving those N partial records already in allSamples. The pipeline would then quietly emit a truncated training-data output. Drop the swallowing try/catch so a corrupt worker result aborts the run non-zero. 2. The --out help text in simulationOptions.ts still advertised the old JSON-array default (_output.json). Update it to reflect the JSONL output, and also note in --input that the format is inferred from the .jsonl/.ndjson extension. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../copilot/test/base/simulationOptions.ts | 5 +- extensions/copilot/test/pipeline/output.ts | 37 +++- .../copilot/test/pipeline/parseInput.ts | 107 ++++----- extensions/copilot/test/pipeline/pipeline.ts | 116 ++++++++-- .../test/pipeline/streamJsonRecords.ts | 209 ++++++++++++++++++ .../test/pipeline/test/pipeline.e2e.spec.ts | 19 +- .../test/pipeline/test/pipeline.spec.ts | 2 +- .../pipeline/test/streamJsonRecords.spec.ts | 152 +++++++++++++ .../test/pipeline/test/writeStream.spec.ts | 60 +++++ .../copilot/test/pipeline/writeStream.ts | 62 ++++++ 10 files changed, 675 insertions(+), 94 deletions(-) create mode 100644 extensions/copilot/test/pipeline/streamJsonRecords.ts create mode 100644 extensions/copilot/test/pipeline/test/streamJsonRecords.spec.ts create mode 100644 extensions/copilot/test/pipeline/test/writeStream.spec.ts create mode 100644 extensions/copilot/test/pipeline/writeStream.ts diff --git a/extensions/copilot/test/base/simulationOptions.ts b/extensions/copilot/test/base/simulationOptions.ts index ea25d1192cbc2..57b278a958e09 100644 --- a/extensions/copilot/test/base/simulationOptions.ts +++ b/extensions/copilot/test/base/simulationOptions.ts @@ -249,8 +249,9 @@ export class SimulationOptions { `The prompting strategy is read from the model configuration in --config-file.`, ``, `Options:`, - ` --input Path to a JSON file with training data recordings (required)`, - ` --out Output path for JSON file. Default: _output.json`, + ` --input Path to a JSON or JSON Lines file with training data recordings (required)`, + ` Format is inferred from the extension: .jsonl/.ndjson → JSON Lines, otherwise JSON array`, + ` --out Output path for the JSON Lines file. Default: _output.jsonl`, ``, `Global options (placed before 'nes-datagen'):`, ` --config-file Path to a JSON config file (required for nes-datagen)`, diff --git a/extensions/copilot/test/pipeline/output.ts b/extensions/copilot/test/pipeline/output.ts index 6f932e3dd5b4b..b862f731db59f 100644 --- a/extensions/copilot/test/pipeline/output.ts +++ b/extensions/copilot/test/pipeline/output.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import { IGeneratedPrompt } from './promptStep'; import { IProcessedRow } from './replayRecording'; import { IGeneratedResponse } from './responseStep'; +import { openWriteStream } from './writeStream'; export interface IMessage { readonly role: 'system' | 'user' | 'assistant'; @@ -113,12 +114,17 @@ export function resolveOutputPath(inputPath: string, explicitPath: string | unde return path.resolve(explicitPath); } const parsed = path.parse(inputPath); - return path.join(parsed.dir, `${parsed.name}_output.json`); + return path.join(parsed.dir, `${parsed.name}_output.jsonl`); } /** - * Write validated samples to a JSON file. + * Write validated samples to a JSON Lines file (one JSON object per line). * Samples are sorted by rowIndex for deterministic output. + * + * JSONL was chosen over a pretty-printed JSON array specifically so the writer + * (and any reader) can operate one record at a time, with no surrounding + * brackets/commas to track. This keeps memory bounded for multi-GB outputs and + * avoids hitting V8's ~512 MiB max-string-length limit. */ export async function writeSamples( outputPath: string, @@ -141,17 +147,28 @@ export async function writeSamples( validSamples.sort((a, b) => a.metadata.rowIndex - b.metadata.rowIndex); - const output = validSamples.map(sample => ({ - messages: sample.messages.map(m => ({ role: m.role, content: m.content })), - metadata: sample.metadata, - })); - const content = JSON.stringify(output, null, 2); - const resolvedPath = path.resolve(outputPath); await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); - await fs.writeFile(resolvedPath, content, 'utf-8'); - const fileSize = Buffer.byteLength(content, 'utf-8'); + const writer = openWriteStream(resolvedPath); + let fileSize = 0; + + try { + for (const sample of validSamples) { + const out = { + messages: sample.messages.map(m => ({ role: m.role, content: m.content })), + metadata: sample.metadata, + }; + const line = JSON.stringify(out) + '\n'; + await writer.write(line); + fileSize += Buffer.byteLength(line, 'utf-8'); + } + await writer.close(); + } catch (err) { + try { await writer.close(); } catch { /* swallow secondary errors */ } + throw err; + } + const languageCounts = new Map(); for (const sample of validSamples) { const lang = sample.metadata.language || 'unknown'; diff --git a/extensions/copilot/test/pipeline/parseInput.ts b/extensions/copilot/test/pipeline/parseInput.ts index ebd0b5fbc8cb4..4cd101ccc151a 100644 --- a/extensions/copilot/test/pipeline/parseInput.ts +++ b/extensions/copilot/test/pipeline/parseInput.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as fs from 'fs/promises'; import { IAlternativeAction } from '../../src/extension/inlineEdits/node/nextEditProviderTelemetry'; +import { streamJsonRecords } from './streamJsonRecords'; /** * A single row from the JSON input. @@ -32,70 +32,75 @@ const requiredKeys = [ ] as const; /** - * Parse a JSON array of input entries into structured rows. + * Parse a single JSON input record into a structured row. */ -function parseInputJson(jsonContents: string): { +function parseInputRecord(record: Record, rowIndex: number): IInputRow { + for (const key of requiredKeys) { + if (!(key in record)) { + throw new Error(`Missing key: ${key}`); + } + } + + const alternativeAction = JSON.parse(record['action']) as IAlternativeAction; + const prompt = JSON.parse(record['input']) as unknown[]; + const postProcessingOutcome = JSON.parse(record['outcome']) as { + suggestedEdit: string; + isInlineCompletion: boolean; + }; + + if (!alternativeAction.recording) { + throw new Error('action.recording is missing'); + } + if (!alternativeAction.recording.entries || alternativeAction.recording.entries.length === 0) { + throw new Error('action.recording.entries is empty'); + } + if (!postProcessingOutcome.suggestedEdit) { + throw new Error('outcome.suggestedEdit is missing'); + } + + return { + originalRowIndex: rowIndex, + suggestionStatus: record['status'], + alternativeAction, + prompt, + modelResponse: record['response'], + postProcessingOutcome, + activeDocumentLanguageId: record['language'], + }; +} + +/** + * Stream-parse the input file (JSON array or JSON Lines) and validate each + * record into an `IInputRow`. Records that fail validation are reported in + * `errors` (with their row index) rather than aborting the load. + * + * Note: this still accumulates the fully parsed rows into memory. For very + * large inputs (multi-GB), use `runInputPipelineParallel` so each worker only + * loads its assigned slice — the parent process never holds the whole dataset. + */ +export async function loadAndParseInput(inputPath: string, verbose = false): Promise<{ rows: IInputRow[]; errors: { rowIndex: number; error: string }[]; -} { - const records = JSON.parse(jsonContents) as Record[]; - +}> { const rows: IInputRow[] = []; const errors: { rowIndex: number; error: string }[] = []; - for (let i = 0; i < records.length; i++) { - const record = records[i]; + let i = 0; + for await (const record of streamJsonRecords>(inputPath)) { + const rowIndex = i++; try { - for (const key of requiredKeys) { - if (!(key in record)) { - throw new Error(`Missing key: ${key}`); - } - } - - const alternativeAction = JSON.parse(record['action']) as IAlternativeAction; - const prompt = JSON.parse(record['input']) as unknown[]; - const postProcessingOutcome = JSON.parse(record['outcome']) as { - suggestedEdit: string; - isInlineCompletion: boolean; - }; - - if (!alternativeAction.recording) { - throw new Error('action.recording is missing'); - } - if (!alternativeAction.recording.entries || alternativeAction.recording.entries.length === 0) { - throw new Error('action.recording.entries is empty'); - } - if (!postProcessingOutcome.suggestedEdit) { - throw new Error('outcome.suggestedEdit is missing'); - } - - rows.push({ - originalRowIndex: i, - suggestionStatus: record['status'], - alternativeAction, - prompt, - modelResponse: record['response'], - postProcessingOutcome, - activeDocumentLanguageId: record['language'], - }); + rows.push(parseInputRecord(record, rowIndex)); } catch (e) { errors.push({ - rowIndex: i, + rowIndex, error: e instanceof Error ? e.message : String(e), }); } } - return { rows, errors }; -} - -export async function loadAndParseInput(inputPath: string, verbose = false): Promise<{ - rows: IInputRow[]; - errors: { rowIndex: number; error: string }[]; -}> { - const contents = await fs.readFile(inputPath, 'utf8'); if (verbose) { - console.log(`Read ${contents.length} chars from ${inputPath}`); + console.log(`Read ${i} records from ${inputPath}`); } - return parseInputJson(contents); + + return { rows, errors }; } diff --git a/extensions/copilot/test/pipeline/pipeline.ts b/extensions/copilot/test/pipeline/pipeline.ts index 3cd8992dbf733..950179f5e1934 100644 --- a/extensions/copilot/test/pipeline/pipeline.ts +++ b/extensions/copilot/test/pipeline/pipeline.ts @@ -18,6 +18,8 @@ import { loadAndParseInput } from './parseInput'; import { generatePromptFromRecording, IGeneratedPrompt } from './promptStep'; import { parseSuggestedEdit, processAllRows } from './replayRecording'; import { generateAllResponses, generateResponse, IResponseGenerationInput } from './responseStep'; +import { streamJsonRecords } from './streamJsonRecords'; +import { openWriteStream } from './writeStream'; function logErrors(errors: readonly { error: string }[], verbose: boolean, log: (...ps: any[]) => void): void { if (errors.length > 0 && verbose) { @@ -187,6 +189,52 @@ export async function runInputPipeline(opts: RunPipelineOptions, log = console.l } } +/** + * Splits the records of the JSON array at `inputPath` into contiguous per-worker + * chunk files, each written incrementally as a JSON array. Streaming keeps memory + * usage bounded to a single record regardless of total input size. + */ +async function writeChunkFiles(inputPath: string, chunkPaths: string[], chunkSize: number): Promise { + let currentChunk = -1; + let writer: ReturnType | undefined; + let countInChunk = 0; + let index = 0; + + try { + for await (const record of streamJsonRecords(inputPath)) { + const w = Math.min(Math.floor(index / chunkSize), chunkPaths.length - 1); + if (w !== currentChunk) { + if (writer) { + await writer.write(']'); + await writer.close(); + } + writer = openWriteStream(chunkPaths[w]); + await writer.write('['); + currentChunk = w; + countInChunk = 0; + } + if (countInChunk > 0) { + await writer!.write(','); + } + await writer!.write(JSON.stringify(record)); + countInChunk++; + index++; + } + + if (writer) { + await writer.write(']'); + await writer.close(); + writer = undefined; + } + } finally { + // Best-effort close on the error path so the file descriptor is released + // before the caller proceeds to delete the tmp directory. + if (writer) { + try { await writer.close(); } catch { /* swallow secondary errors */ } + } + } +} + /** * Run the pipeline in parallel by splitting input across N child processes. * Each child runs the single-process pipeline on its chunk independently. @@ -196,37 +244,53 @@ export async function runInputPipelineParallel(opts: SimulationOptions): Promise const inputPath = nesDatagenOpts.input; const verbose = !!opts.verbose; - const contents = await fs.promises.readFile(inputPath, 'utf8'); - const records = JSON.parse(contents) as unknown[]; - const numWorkers = Math.max(1, Math.min(os.cpus().length, opts.parallelism, Math.ceil(records.length / 25))); + // Stream the input once to count records without loading the whole file into + // memory. Node's readFile rejects files larger than 2 GiB and V8 strings have a + // maximum length of ~512 MiB, so large inputs cannot be read as a single string. + let totalRecords = 0; + for await (const _record of streamJsonRecords(inputPath)) { + totalRecords++; + } + + const numWorkers = Math.max(1, Math.min(os.cpus().length, opts.parallelism, Math.ceil(totalRecords / 25))); console.log(`\n=== Pipeline (parallel: ${numWorkers} workers) ===`); - console.log(` Input: ${inputPath} (${records.length} rows)\n`); + console.log(` Input: ${inputPath} (${totalRecords} rows)\n`); - if (records.length === 0) { + if (totalRecords === 0) { console.log(` No records to process.`); return; } - const chunkSize = Math.ceil(records.length / numWorkers); + const chunkSize = Math.ceil(totalRecords / numWorkers); const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nes-pipeline-')); try { - const workerPromises: Promise[] = []; - const resultPaths: string[] = []; - + const workers: { chunkPath: string; resultPath: string; start: number; count: number }[] = []; for (let w = 0; w < numWorkers; w++) { const start = w * chunkSize; - const chunk = records.slice(start, start + chunkSize); - if (chunk.length === 0) { - continue; + if (start >= totalRecords) { + break; } + const end = Math.min(start + chunkSize, totalRecords); + workers.push({ + chunkPath: path.join(tmpDir, `chunk_${w}.json`), + resultPath: path.join(tmpDir, `result_${w}.jsonl`), + start, + count: end - start, + }); + } - const chunkPath = path.join(tmpDir, `chunk_${w}.json`); - const resultPath = path.join(tmpDir, `result_${w}.json`); - resultPaths.push(resultPath); + // Distribute records into contiguous per-worker chunk files by streaming the + // input a second time, writing each chunk incrementally as a JSON array. + await writeChunkFiles(inputPath, workers.map(w => w.chunkPath), chunkSize); - await fs.promises.writeFile(chunkPath, JSON.stringify(chunk)); + const workerPromises: Promise[] = []; + const resultPaths: string[] = []; + + for (let w = 0; w < workers.length; w++) { + const { chunkPath, resultPath, start, count } = workers[w]; + resultPaths.push(resultPath); const args = [ 'nes-datagen', @@ -261,7 +325,7 @@ export async function runInputPipelineParallel(opts: SimulationOptions): Promise child.on('exit', (code) => { if (code === 0) { - console.log(` Worker ${workerIdx + 1}/${numWorkers} completed (${chunk.length} rows)`); + console.log(` Worker ${workerIdx + 1}/${numWorkers} completed (${count} rows)`); resolve(); } else { reject(new Error(`Worker ${workerIdx} exited with code ${code}`)); @@ -276,15 +340,19 @@ export async function runInputPipelineParallel(opts: SimulationOptions): Promise const elapsed = formatElapsed(startTime); console.log(`\n All ${numWorkers} workers completed in ${elapsed}`); - // Merge results + // Merge results. Stream each worker's result file so a single large file + // (e.g. > 2 GiB / > V8 max-string-length) can be consumed without doing + // a whole-file readFile. + // + // A parse error mid-stream is fatal: by that point we have already + // pushed an unknown number of valid records from the failing file into + // `allSamples`, so swallowing the error would produce a silently + // truncated training-data file. Re-throw so the run exits non-zero and + // the user knows the output is incomplete. const allSamples: ISample[] = []; for (const resultPath of resultPaths) { - try { - const content = await fs.promises.readFile(resultPath, 'utf8'); - const samples = JSON.parse(content) as ISample[]; - allSamples.push(...samples); - } catch { - console.error(` Warning: could not read result file ${resultPath}`); + for await (const sample of streamJsonRecords(resultPath)) { + allSamples.push(sample); } } diff --git a/extensions/copilot/test/pipeline/streamJsonRecords.ts b/extensions/copilot/test/pipeline/streamJsonRecords.ts new file mode 100644 index 0000000000000..e9f56ba10dcb6 --- /dev/null +++ b/extensions/copilot/test/pipeline/streamJsonRecords.ts @@ -0,0 +1,209 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; +import * as path from 'path'; + +export type JsonRecordFormat = 'array' | 'jsonl'; + +/** + * Infers the record format of an input file from its extension: + * - `.jsonl` / `.ndjson` → JSON Lines (one JSON value per line). + * - anything else (e.g. `.json`) → a single JSON array. + */ +export function inferJsonRecordFormat(inputPath: string): JsonRecordFormat { + const ext = path.extname(inputPath).toLowerCase(); + return ext === '.jsonl' || ext === '.ndjson' ? 'jsonl' : 'array'; +} + +/** + * Streams the top-level records of a file without ever loading the whole file + * into memory. The format is inferred from the file extension (see + * {@link inferJsonRecordFormat}): + * + * - A single JSON array (`[ {...}, {...} ]`) — each top-level array element is + * yielded in order. + * - JSON Lines / NDJSON (one JSON value per line) — each non-empty line is parsed + * and yielded in order. + * + * Node's `fs.readFile`/`fs.promises.readFile` reject files larger than 2 GiB and + * V8 strings have a maximum length of ~512 MiB, so large inputs (e.g. multi-GB + * recordings) cannot be read into a single string and parsed with `JSON.parse`. + * Instead we read the file as a stream and parse each record individually. This + * assumes each individual record is small enough to fit into a single string, + * which holds for the row records used by the nes-datagen pipeline. + * + * @param inputPath path to a JSON-array or JSON-Lines file. + * @yields each parsed top-level record in order. + */ +export async function* streamJsonRecords(inputPath: string): AsyncGenerator { + const format = inferJsonRecordFormat(inputPath); + const stream = fs.createReadStream(inputPath, { encoding: 'utf8' }); + + try { + if (format === 'jsonl') { + yield* streamJsonLines(stream); + } else { + yield* streamJsonArray(stream); + } + } finally { + stream.destroy(); + } +} + +function isWhitespace(ch: string): boolean { + return ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t'; +} + +async function* streamJsonLines(stream: NodeJS.ReadableStream): AsyncGenerator { + let line = ''; + for await (const chunk of stream) { + const text = chunk as string; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '\n' || ch === '\r') { + const trimmed = line.trim(); + line = ''; + if (trimmed.length > 0) { + yield JSON.parse(trimmed) as T; + } + } else { + line += ch; + } + } + } + const trimmed = line.trim(); + if (trimmed.length > 0) { + yield JSON.parse(trimmed) as T; + } +} + +async function* streamJsonArray(stream: NodeJS.ReadableStream): AsyncGenerator { + let arrayStarted = false; + let arrayEnded = false; + let depth = 0; + let inString = false; + let escaped = false; + let current = ''; + let hasContent = false; + let elementsYielded = 0; + let pendingElement = false; + let trailingChar = ''; + + for await (const chunk of stream) { + const text = chunk as string; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + + if (!arrayStarted) { + if (isWhitespace(ch)) { + continue; + } + if (ch === '[') { + arrayStarted = true; + pendingElement = true; + continue; + } + throw new Error(`Expected '[' at start of JSON array input, got '${ch}'`); + } + + if (arrayEnded) { + if (!isWhitespace(ch)) { + trailingChar = ch; + break; + } + continue; + } + + if (inString) { + current += ch; + if (escaped) { + escaped = false; + } else if (ch === '\\') { + escaped = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + + if (ch === '"') { + inString = true; + current += ch; + hasContent = true; + continue; + } + + if (ch === '{' || ch === '[') { + depth++; + current += ch; + hasContent = true; + continue; + } + + if (ch === '}' || ch === ']') { + if (ch === ']' && depth === 0) { + // Closing the outer array. + const trimmed = current.trim(); + current = ''; + hasContent = false; + if (trimmed.length > 0) { + yield JSON.parse(trimmed) as T; + elementsYielded++; + pendingElement = false; + } else if (pendingElement && elementsYielded > 0) { + throw new Error('Unexpected \']\' after trailing comma in JSON array'); + } + arrayEnded = true; + continue; + } + depth--; + current += ch; + hasContent = true; + continue; + } + + if (ch === ',' && depth === 0) { + const trimmed = current.trim(); + current = ''; + hasContent = false; + if (trimmed.length === 0) { + throw new Error('Unexpected \',\' (missing element) in JSON array'); + } + yield JSON.parse(trimmed) as T; + elementsYielded++; + pendingElement = true; + continue; + } + + if (!hasContent && isWhitespace(ch)) { + // Skip whitespace between elements so it doesn't accumulate. + continue; + } + + current += ch; + if (!isWhitespace(ch)) { + hasContent = true; + } + } + + if (arrayEnded && trailingChar) { + break; + } + } + + if (!arrayStarted) { + // Empty or whitespace-only file: no records. + return; + } + + if (!arrayEnded) { + throw new Error('Unexpected end of input: JSON array was not closed'); + } + + if (trailingChar) { + throw new Error(`Unexpected '${trailingChar}' after end of JSON array`); + } +} diff --git a/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts b/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts index 769d5eed8c934..ab8dfd5305f3e 100644 --- a/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts +++ b/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts @@ -30,10 +30,17 @@ let tmpDir: string; let inputPath: string; let outputPath: string; +function parseJsonl(contents: string): T[] { + return contents + .split('\n') + .filter(line => line.length > 0) + .map(line => JSON.parse(line) as T); +} + beforeAll(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nes-datagen-e2e-')); inputPath = path.join(tmpDir, 'input.json'); - outputPath = path.join(tmpDir, 'output.json'); + outputPath = path.join(tmpDir, 'output.jsonl'); await fs.writeFile(inputPath, JSON.stringify(allRecords, null, 2)); }); @@ -88,7 +95,7 @@ async function runPipeline(opts?: Partial): Promise<{ await runInputPipeline(pipelineOpts, log); const outputContents = await fs.readFile(outputPath, 'utf-8'); - const samples = JSON.parse(outputContents) as OutputSample[]; + const samples = parseJsonl(outputContents); return { samples, logs }; } @@ -243,7 +250,7 @@ describe('nes-datagen pipeline e2e', () => { beforeAll(async () => { invalidInputPath = path.join(tmpDir, 'invalid-input.json'); - invalidOutputPath = path.join(tmpDir, 'invalid-output.json'); + invalidOutputPath = path.join(tmpDir, 'invalid-output.jsonl'); await fs.writeFile(invalidInputPath, JSON.stringify([fixtures.invalid.record])); }); @@ -265,7 +272,7 @@ describe('nes-datagen pipeline e2e', () => { ); const content = await fs.readFile(invalidOutputPath, 'utf-8'); - const samples = JSON.parse(content) as OutputSample[]; + const samples = parseJsonl(content); expect(samples).toEqual([]); // Should report the parse error @@ -294,7 +301,7 @@ describe('nes-datagen pipeline e2e', () => { describe('row offset', () => { test('applies rowOffset to sample rowIndex in metadata', async () => { - const offsetOutputPath = path.join(tmpDir, 'offset-output.json'); + const offsetOutputPath = path.join(tmpDir, 'offset-output.jsonl'); await runInputPipeline( { nesDatagen: { @@ -311,7 +318,7 @@ describe('nes-datagen pipeline e2e', () => { ); const content = await fs.readFile(offsetOutputPath, 'utf-8'); - const samples = JSON.parse(content) as OutputSample[]; + const samples = parseJsonl(content); expect(samples.length).toBe(2); // Row indices should be shifted by the offset for (const sample of samples) { diff --git a/extensions/copilot/test/pipeline/test/pipeline.spec.ts b/extensions/copilot/test/pipeline/test/pipeline.spec.ts index 3f229d29e80b6..80e9fb54141cb 100644 --- a/extensions/copilot/test/pipeline/test/pipeline.spec.ts +++ b/extensions/copilot/test/pipeline/test/pipeline.spec.ts @@ -105,7 +105,7 @@ suite.skip('from csv to input rows to pipeline', () => { await runInputPipeline({ nesDatagen: { input: inputRowsFilePath, - output: path.join(fixtures, 'output.json'), + output: path.join(fixtures, 'output.jsonl'), rowOffset: 0, workerMode: false }, diff --git a/extensions/copilot/test/pipeline/test/streamJsonRecords.spec.ts b/extensions/copilot/test/pipeline/test/streamJsonRecords.spec.ts new file mode 100644 index 0000000000000..aa3ea1b7e7221 --- /dev/null +++ b/extensions/copilot/test/pipeline/test/streamJsonRecords.spec.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { inferJsonRecordFormat, streamJsonRecords } from '../streamJsonRecords'; + +describe('streamJsonRecords', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stream-json-records-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + async function collect(contents: string, ext = '.json'): Promise { + const inputPath = path.join(tmpDir, `input${ext}`); + await fs.writeFile(inputPath, contents); + const result: T[] = []; + for await (const element of streamJsonRecords(inputPath)) { + result.push(element); + } + return result; + } + + test('infers format from the file extension', () => { + expect(inferJsonRecordFormat('/a/b/input.json')).toBe('array'); + expect(inferJsonRecordFormat('/a/b/input.JSON')).toBe('array'); + expect(inferJsonRecordFormat('/a/b/input.txt')).toBe('array'); + expect(inferJsonRecordFormat('/a/b/input.jsonl')).toBe('jsonl'); + expect(inferJsonRecordFormat('/a/b/input.JSONL')).toBe('jsonl'); + expect(inferJsonRecordFormat('/a/b/input.ndjson')).toBe('jsonl'); + }); + + describe('JSON array (.json)', () => { + test('parses an array of objects, primitives and nested structures', async () => { + const value = [ + { a: 1, b: 'two', c: [1, 2, { d: true }] }, + 42, + 'a string with ] , { } chars', + null, + [1, [2, [3]]], + { nested: { deep: { value: 'x] [, {}' } } }, + ]; + const result = await collect(JSON.stringify(value, null, 2)); + expect(result).toEqual(value); + }); + + test('handles escaped quotes and special characters inside strings', async () => { + const value = [ + { s: 'quote: \" bracket: ] comma: , brace: }' }, + 'line\nbreak\ttab\\backslash', + ]; + const result = await collect(JSON.stringify(value)); + expect(result).toEqual(value); + }); + + test('returns nothing for an empty array', async () => { + expect(await collect('[]')).toEqual([]); + expect(await collect(' [ ] ')).toEqual([]); + }); + + test('throws when a .json file does not start with an array', async () => { + await expect(collect('{"a":1}')).rejects.toThrow(); + }); + + test('throws on truncated input (no closing bracket)', async () => { + await expect(collect('[1, 2, 3')).rejects.toThrow(/not closed|Unexpected end/i); + }); + + test('throws on truncated input mid-element', async () => { + await expect(collect('[1, 2, {"a":1')).rejects.toThrow(/not closed|Unexpected end/i); + }); + + test('throws on truncated input with unclosed string', async () => { + await expect(collect('["abc]')).rejects.toThrow(/not closed|Unexpected end/i); + }); + + test('throws on trailing data after the array', async () => { + await expect(collect('[1, 2, 3]garbage')).rejects.toThrow(/after end of JSON array/i); + }); + + test('throws on extra top-level values', async () => { + await expect(collect('[1][2]')).rejects.toThrow(/after end of JSON array/i); + }); + + test('throws on missing element between commas', async () => { + await expect(collect('[1,,2]')).rejects.toThrow(/missing element/i); + }); + + test('throws on trailing comma', async () => { + await expect(collect('[1, 2,]')).rejects.toThrow(/trailing comma/i); + }); + + test('accepts trailing whitespace after the array', async () => { + expect(await collect('[1, 2]\n \t\n')).toEqual([1, 2]); + }); + }); + + describe('JSON Lines (.jsonl)', () => { + test('parses one object per line', async () => { + const value = [ + { a: 1, b: 'two' }, + { a: 2, b: 'three', nested: { x: [1, 2] } }, + { a: 3 }, + ]; + const result = await collect(value.map(v => JSON.stringify(v)).join('\n'), '.jsonl'); + expect(result).toEqual(value); + }); + + test('handles a trailing newline and blank lines', async () => { + const value = [{ a: 1 }, { a: 2 }]; + const contents = `${JSON.stringify(value[0])}\n\n${JSON.stringify(value[1])}\n`; + const result = await collect(contents, '.jsonl'); + expect(result).toEqual(value); + }); + + test('handles CRLF line endings and leading whitespace', async () => { + const value = [{ a: 1 }, { a: 2 }]; + const contents = ` ${JSON.stringify(value[0])}\r\n${JSON.stringify(value[1])}\r\n`; + const result = await collect(contents, '.jsonl'); + expect(result).toEqual(value); + }); + + test('parses a single object without a trailing newline', async () => { + expect(await collect('{"a":1}', '.jsonl')).toEqual([{ a: 1 }]); + }); + + test('handles brackets and commas inside string values', async () => { + const value = [{ s: 'a [ ] , { } b' }, { s: 'second' }]; + const result = await collect(value.map(v => JSON.stringify(v)).join('\n'), '.ndjson'); + expect(result).toEqual(value); + }); + + test('throws when the last line is truncated mid-object', async () => { + await expect(collect('{"a":1}\n{"b":2', '.jsonl')).rejects.toThrow(); + }); + }); + + test('returns nothing for an empty or whitespace-only file', async () => { + expect(await collect('')).toEqual([]); + expect(await collect(' \n \t ')).toEqual([]); + expect(await collect('', '.jsonl')).toEqual([]); + expect(await collect(' \n \t ', '.jsonl')).toEqual([]); + }); +}); diff --git a/extensions/copilot/test/pipeline/test/writeStream.spec.ts b/extensions/copilot/test/pipeline/test/writeStream.spec.ts new file mode 100644 index 0000000000000..145226a3b1b6d --- /dev/null +++ b/extensions/copilot/test/pipeline/test/writeStream.spec.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { openWriteStream } from '../writeStream'; + +describe('openWriteStream', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'write-stream-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + test('writes data incrementally and produces the concatenated content', async () => { + const filePath = path.join(tmpDir, 'out.txt'); + const writer = openWriteStream(filePath); + await writer.write('hello '); + await writer.write('world'); + await writer.write('!'); + await writer.close(); + + expect(await fs.readFile(filePath, 'utf8')).toBe('hello world!'); + }); + + test('close is idempotent', async () => { + const filePath = path.join(tmpDir, 'idempotent.txt'); + const writer = openWriteStream(filePath); + await writer.write('ok'); + await writer.close(); + await writer.close(); + expect(await fs.readFile(filePath, 'utf8')).toBe('ok'); + }); + + test('does not raise uncaughtException when the file system rejects the write', async () => { + // Writing to a directory path that does not exist surfaces as an async + // `'error'` event on the stream. Without our `'error'` listener this would + // terminate the process; we expect the write or close to reject instead. + const missingDir = path.join(tmpDir, 'does-not-exist', 'out.txt'); + const writer = openWriteStream(missingDir); + + let caught: Error | undefined; + try { + await writer.write('data'); + await writer.close(); + } catch (err) { + caught = err as Error; + } + + expect(caught).toBeDefined(); + expect(caught!.message).toMatch(/ENOENT|no such file/i); + }); +}); diff --git a/extensions/copilot/test/pipeline/writeStream.ts b/extensions/copilot/test/pipeline/writeStream.ts new file mode 100644 index 0000000000000..a75a82ab9a747 --- /dev/null +++ b/extensions/copilot/test/pipeline/writeStream.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs'; + +/** + * A small wrapper around `fs.WriteStream` that: + * - attaches an `'error'` listener immediately so async write failures (ENOSPC, + * EIO, …) never bubble up as `uncaughtException` and abort the process; + * - awaits backpressure via the per-write callback; + * - surfaces stream errors through the next `write`/`close` rejection. + * + * Always `close()` the returned writer (including on error paths) to release + * the underlying file descriptor. + */ +export function openWriteStream(filePath: string): { + write: (data: string) => Promise; + close: () => Promise; +} { + const stream = fs.createWriteStream(filePath); + let fatalError: Error | undefined; + let closed = false; + stream.on('error', err => { + fatalError = err; + }); + + return { + write(data: string): Promise { + if (fatalError) { + return Promise.reject(fatalError); + } + return new Promise((resolve, reject) => { + stream.write(data, err => { + if (err) { + reject(err); + } else if (fatalError) { + reject(fatalError); + } else { + resolve(); + } + }); + }); + }, + close(): Promise { + if (closed) { + return Promise.resolve(); + } + closed = true; + return new Promise((resolve, reject) => { + stream.end(() => { + if (fatalError) { + reject(fatalError); + } else { + resolve(); + } + }); + }); + }, + }; +} From 53f69434d9335c5b12e21e182bf6c07bb0fddccf Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Fri, 5 Jun 2026 17:52:22 +0500 Subject: [PATCH 11/29] sessions: inherit active session workspace for new session view (#320091) * sessions: inherit active session workspace for new session view When pressing Ctrl/Cmd+N (or using the New button, mobile titlebar '+' button, or the sessions quick picker's New Session item) in the Agents window while a session is already open, the new session view now inherits the currently open session's workspace instead of defaulting to the workspace of the last composed new session. openNewSessionView accepts an optional inheritWorkspaceFromActiveSession flag; when set and an established session is active, a fresh pending new session is created for that session's workspace folder. An in-progress draft for the same workspace is preserved (skipped via the isEqual guard), and the change falls back to the prior behavior when the workspace cannot be resolved. Internal callers (restore fallback, archive, background reseed) keep the previous behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Copilot review: clarify no-op docs, document close-session fallback, add draft-preservation test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/SESSIONS.md | 13 ++ src/vs/sessions/browser/workbench.ts | 2 +- .../contrib/chat/browser/chat.contribution.ts | 2 +- .../sessions/browser/sessionsActions.ts | 2 +- .../sessions/browser/views/sessionsView.ts | 2 +- .../browser/sessionsManagementService.ts | 30 +++- .../sessions/common/sessionsManagement.ts | 11 +- .../browser/sessionsManagementService.test.ts | 136 ++++++++++++++++++ 8 files changed, 189 insertions(+), 9 deletions(-) diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index cc40d1a12a03c..46f59ace8a3a8 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -157,6 +157,19 @@ Follow-up messages to an existing chat go through `SessionsManagementService.sendRequest(session, chat, options)`. This always makes the sent chat the active chat. +Explicit user-initiated "new session" gestures (Ctrl/Cmd+N, the **New** button, +the mobile titlebar "+" button, and the sessions quick picker's "New Session" +item) call `openNewSessionView({ inheritWorkspaceFromActiveSession: true })`. +When a session is already active, the new session view inherits that session's +workspace — a fresh pending new session is created via `createNewSession` for +the active session's workspace folder — instead of defaulting to the workspace +of the last composed new session. Inheritance is skipped when the active +session's workspace already matches the current pending new session (so an +in-progress draft for that same workspace is preserved) and falls back to the +default behavior if the workspace cannot be resolved. Internal callers (restore +fallback, archive, background reseed, and the close-session fallback) invoke +`openNewSessionView()` without the flag and keep the prior behavior. + `sendNewChatRequest(session, options)` accepts a `background` flag: a background new-session send returns the agents window to a fresh new-session view (via `openNewSessionView`) **before** creating and sending the session, and skips the diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index b55e0a6600808..d2a702ef5b573 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -785,7 +785,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // so the new session view becomes visible. createMobileTitlebar() is // only invoked in phone layout, so closing the drawer here is safe. this.mobileTopBarDisposables.add(mobileTitlebar.onDidClickNewSession(() => { - this.sessionsManagementService.openNewSessionView(); + this.sessionsManagementService.openNewSessionView({ inheritWorkspaceFromActiveSession: true }); this.closeMobileSidebarDrawer(); this.sessionsPartService.focusSession(this.sessionsManagementService.activeSession.get()); })); diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index ba7a9d8f20c4c..5231831dfef30 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -66,7 +66,7 @@ class NewChatInSessionsWindowAction extends Action2 { override run(accessor: ServicesAccessor): void { const sessionsManagementService = accessor.get(ISessionsManagementService); const sessionsPartService = accessor.get(ISessionsPartService); - sessionsManagementService.openNewSessionView(); + sessionsManagementService.openNewSessionView({ inheritWorkspaceFromActiveSession: true }); sessionsPartService.focusSession(sessionsManagementService.activeSession.get()); } } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index c725e536c4748..b5fbc37ae4238 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -145,7 +145,7 @@ registerAction2(class ShowSessionsPickerAction extends Action2 { const openSelected = (selected: ISessionPickItem, inBackground: boolean, toSide: boolean): void => { if (!selected.session) { - sessionsManagementService.openNewSessionView(); + sessionsManagementService.openNewSessionView({ inheritWorkspaceFromActiveSession: true }); sessionsPartService.focusSession(sessionsManagementService.activeSession.get()); return; } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index fd6ff164903f4..d39e839cde669 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -338,7 +338,7 @@ export class SessionsView extends ViewPane { newSessionButton.element.classList.add('agent-sessions-compact-new-button'); this._register(newSessionButton.onDidClick(() => { logSessionsInteraction(this.telemetryService, 'newSession'); - this.sessionsManagementService.openNewSessionView(); + this.sessionsManagementService.openNewSessionView({ inheritWorkspaceFromActiveSession: true }); this.sessionsPartService.focusSession(this.sessionsManagementService.activeSession.get()); })); diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 6fe0539ddc43a..82f71d087f4b4 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -830,12 +830,36 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this._onDidSendRequest.fire({ session: updatedSession, chat, isNewSession: false, isNewChat: true, options }); } - openNewSessionView(): void { - // No-op if the current session is already a new session - if (this._visibility.activeSession.get() === undefined) { + openNewSessionView(options?: { inheritWorkspaceFromActiveSession?: boolean }): void { + const current = this._visibility.activeSession.get(); + // No-op when no session is active (empty new-session placeholder showing). + if (current === undefined) { return; } this._startOpenSession(); + + // When explicitly requested, inherit the workspace of the session being + // switched away from so the new session view opens in the same workspace + // rather than defaulting to the last composed new session's workspace. + // Only inherit from an established session (not the pending new session) + // and only when its workspace differs from any pending new session, so + // an existing in-progress draft for that same workspace is preserved. + if (options?.inheritWorkspaceFromActiveSession && current.sessionId !== this._pendingNewSession?.sessionId) { + const inheritUri = current.workspace.get()?.folders[0]?.root; + const pendingUri = this._pendingNewSession?.workspace.get()?.folders[0]?.root; + if (inheritUri && !this.uriIdentityService.extUri.isEqual(inheritUri, pendingUri)) { + try { + // Creates a fresh pending new session for the inherited + // workspace and makes it active, disposing the previous one. + this.createNewSession(inheritUri); + this._onDidOpenNewSessionView.fire(); + return; + } catch (e) { + this.logService.warn(`[SessionsManagement] openNewSessionView: failed to inherit workspace '${inheritUri.toString()}', falling back to default`, e); + } + } + } + // Restore the pending new session if one exists, so pickers // re-derive their state from the still-alive session object. // Otherwise clear active session (first time / after send). diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index bd488927507ec..b15678cf7dd96 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -281,9 +281,16 @@ export interface ISessionsManagementService { /** * Switch to the new-session view. - * No-op if the current session is already a new session. + * No-op when no session is active (the empty new-session placeholder is + * already showing). + * + * When `options.inheritWorkspaceFromActiveSession` is set, the new session + * view inherits the workspace of the session that is currently active + * (the one being switched away from) instead of defaulting to the + * workspace of the last composed new session. Use this for explicit + * user-initiated "new session" gestures (e.g. Ctrl/Cmd+N, the New button). */ - openNewSessionView(): void; + openNewSessionView(options?: { inheritWorkspaceFromActiveSession?: boolean }): void; /** * Create a new session for the given folder. diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 8907cfacc96d8..7949916af6c42 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -424,6 +424,142 @@ suite('SessionsManagementService', () => { }); }); + test('openNewSessionView inherits the active session workspace when requested', async () => { + const makeWorkspace = (uri: URI): ISessionWorkspace => ({ + uri, + label: 'ws', + icon: Codicon.vm, + folders: [{ root: uri, workingDirectory: uri, name: 'ws', description: undefined }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }); + + const workspaceB = URI.parse('file:///workspaceB'); + const openSession = stubSession({ sessionId: 'open', providerId: 'test', workspace: constObservable(makeWorkspace(workspaceB)) }); + + let createdFolderUri: URI | undefined; + const provider = new class extends TestSessionsProvider { + constructor() { super(openSession); } + override getSessions(): ISession[] { return [openSession]; } + override resolveWorkspace(folderUri?: URI): ISessionWorkspace { return makeWorkspace(folderUri!); } + override createNewSession(folderUri?: URI): ISession { + createdFolderUri = folderUri; + return stubSession({ sessionId: 'inherited', providerId: 'test', workspace: constObservable(makeWorkspace(folderUri!)) }); + } + }; + + const { service } = createSessionsManagementService(openSession, disposables, provider); + + // Make the established session active. + await service.openSession(openSession.resource); + assert.strictEqual(service.activeSession.get()?.sessionId, 'open'); + + // Opening a new session view inherits the active session's workspace. + service.openNewSessionView({ inheritWorkspaceFromActiveSession: true }); + + assert.deepStrictEqual({ + createdFor: createdFolderUri?.toString() ?? null, + activeSession: service.activeSession.get()?.sessionId ?? null, + activeWorkspace: service.activeSession.get()?.workspace.get()?.folders[0]?.root.toString() ?? null, + }, { + createdFor: workspaceB.toString(), + activeSession: 'inherited', + activeWorkspace: workspaceB.toString(), + }); + }); + + test('openNewSessionView does not inherit the active session workspace by default', async () => { + const workspaceB = URI.parse('file:///workspaceB'); + const openSession = stubSession({ + sessionId: 'open', + providerId: 'test', + workspace: constObservable({ + uri: workspaceB, + label: 'ws', + icon: Codicon.vm, + folders: [{ root: workspaceB, workingDirectory: workspaceB, name: 'ws', description: undefined }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + } satisfies ISessionWorkspace), + }); + + let createNewSessionCalled = false; + const provider = new class extends TestSessionsProvider { + constructor() { super(openSession); } + override getSessions(): ISession[] { return [openSession]; } + override createNewSession(): ISession { + createNewSessionCalled = true; + return openSession; + } + }; + + const { service } = createSessionsManagementService(openSession, disposables, provider); + + await service.openSession(openSession.resource); + assert.strictEqual(service.activeSession.get()?.sessionId, 'open'); + + // Without the inherit option, no new session is created from the active + // session's workspace; the empty new-session view is shown instead. + service.openNewSessionView(); + + assert.deepStrictEqual({ + createNewSessionCalled, + activeSession: service.activeSession.get()?.sessionId ?? null, + }, { + createNewSessionCalled: false, + activeSession: null, + }); + }); + + test('openNewSessionView preserves an in-progress draft when the active session shares its workspace', async () => { + const makeWorkspace = (uri: URI): ISessionWorkspace => ({ + uri, + label: 'ws', + icon: Codicon.vm, + folders: [{ root: uri, workingDirectory: uri, name: 'ws', description: undefined }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }); + + const workspaceA = URI.parse('file:///workspaceA'); + const openSession = stubSession({ sessionId: 'open', providerId: 'test', workspace: constObservable(makeWorkspace(workspaceA)) }); + const pendingSession = stubSession({ sessionId: 'pending', providerId: 'test', workspace: constObservable(makeWorkspace(workspaceA)) }); + + let createNewSessionCount = 0; + const provider = new class extends TestSessionsProvider { + constructor() { super(openSession); } + override getSessions(): ISession[] { return [openSession]; } + override resolveWorkspace(folderUri?: URI): ISessionWorkspace { return makeWorkspace(folderUri!); } + override createNewSession(): ISession { + createNewSessionCount++; + return pendingSession; + } + }; + + const { service } = createSessionsManagementService(openSession, disposables, provider); + + // Compose an in-progress new session (pending draft) for workspace A. + service.createNewSession(workspaceA); + assert.strictEqual(service.activeSession.get()?.sessionId, 'pending'); + + // Navigate to the established session, which shares workspace A. + await service.openSession(openSession.resource); + assert.strictEqual(service.activeSession.get()?.sessionId, 'open'); + + // Opening a new session view inherits workspace A, which matches the + // pending draft's workspace, so the existing draft is restored rather + // than recreated (createNewSession is not called a second time). + service.openNewSessionView({ inheritWorkspaceFromActiveSession: true }); + + assert.deepStrictEqual({ + createNewSessionCount, + activeSession: service.activeSession.get()?.sessionId ?? null, + }, { + createNewSessionCount: 1, + activeSession: 'pending', + }); + }); + test('restoreVisibleSessions restores the grid order, sticky and active state', async () => { const sessionA = stubSession({ sessionId: 'a', providerId: 'test' }); const sessionB = stubSession({ sessionId: 'b', providerId: 'test' }); From 8903c3b9776be11e6f88cf4a133b13667749f17c Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 5 Jun 2026 13:53:41 +0100 Subject: [PATCH 12/29] Remove unused import and update high contrast button colors (#320085) * fix: update high contrast button border colors for extension actions * fix: remove unused buttonSecondaryBorder import from extensionsActions --------- Co-authored-by: mrleemurray --- .../contrib/extensions/browser/extensionsActions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index 7d1b64a003740..5c76cd31e9500 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -29,7 +29,7 @@ import { CommandsRegistry, ICommandService } from '../../../../platform/commands import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { registerThemingParticipant, IColorTheme, ICssStyleCollector } from '../../../../platform/theme/common/themeService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { buttonBackground, buttonForeground, buttonHoverBackground, buttonSecondaryBackground, buttonSecondaryForeground, buttonSecondaryHoverBackground, registerColor, editorWarningForeground, editorInfoForeground, editorErrorForeground, buttonSeparator, contrastBorder, buttonSecondaryBorder } from '../../../../platform/theme/common/colorRegistry.js'; +import { buttonBackground, buttonForeground, buttonHoverBackground, buttonSecondaryBackground, buttonSecondaryForeground, buttonSecondaryHoverBackground, registerColor, editorWarningForeground, editorInfoForeground, editorErrorForeground, buttonSeparator, buttonSecondaryBorder } from '../../../../platform/theme/common/colorRegistry.js'; import { IJSONEditingService } from '../../../services/configuration/common/jsonEditing.js'; import { ITextEditorSelection } from '../../../../platform/editor/common/editor.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; @@ -3443,8 +3443,8 @@ registerColor('extensionButton.hoverBackground', { registerColor('extensionButton.border', { dark: buttonSecondaryBorder, light: buttonSecondaryBorder, - hcDark: contrastBorder, - hcLight: contrastBorder + hcDark: buttonSecondaryBorder, + hcLight: buttonSecondaryBorder }, localize('extensionButtonBorder', "Button border color for extension actions.")); registerColor('extensionButton.separator', buttonSeparator, localize('extensionButtonSeparator', "Button separator color for extension actions")); From 0035c783eccf8a2efbdb972dfe62becb51f11fc6 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Fri, 5 Jun 2026 15:12:36 +0200 Subject: [PATCH 13/29] Skip for now CLI smoke tests (#320097) * Skip for now CLI smoke tests * skip the correct one * fix typo --- test/smoke/src/areas/chat/chatSessions.test.ts | 2 +- test/smoke/src/areas/chat/copilotCli.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/smoke/src/areas/chat/chatSessions.test.ts b/test/smoke/src/areas/chat/chatSessions.test.ts index 84584369baa21..8c6030a529193 100644 --- a/test/smoke/src/areas/chat/chatSessions.test.ts +++ b/test/smoke/src/areas/chat/chatSessions.test.ts @@ -81,7 +81,7 @@ export function setup(logger: Logger) { await mockServer?.close(); }); - it('Test Copilot CLI session', async function () { + it.skip('Test Copilot CLI session', async function () { const app = this.app as Application; const requestsBefore = mockServer.requestCount(); diff --git a/test/smoke/src/areas/chat/copilotCli.test.ts b/test/smoke/src/areas/chat/copilotCli.test.ts index be1b1d6ce0c73..89b41ff356ec5 100644 --- a/test/smoke/src/areas/chat/copilotCli.test.ts +++ b/test/smoke/src/areas/chat/copilotCli.test.ts @@ -63,7 +63,7 @@ export function setup(logger: Logger) { await mockServer?.close(); }); - it('opens a Copilot CLI session and receives a response', async function () { + it.skip('opens a Copilot CLI session and receives a response', async function () { const app = this.app as Application; const requestsBefore = mockServer.requestCount(); From 6774021f8855313255f49fb402d804aec73f5083 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Fri, 5 Jun 2026 14:18:11 +0100 Subject: [PATCH 14/29] ChatModes: throttle update requests, only fire on change (#320088) * ChatModes: throttle update requests, only fire on change * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * update * update --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../agentPlugins/common/pluginParsers.ts | 23 +++- .../test/common/pluginParsers.test.ts | 48 ++++++++ .../contrib/chat/common/chatModes.ts | 116 ++++++++++++++---- .../common/customizationHarnessService.ts | 2 +- .../chat/common/promptSyntax/hookSchema.ts | 26 ++++ .../promptSyntax/service/promptsService.ts | 19 +++ .../chat/test/common/chatModeService.test.ts | 61 +++++++-- .../common/promptSyntax/hookSchema.test.ts | 26 +++- .../service/promptsService.test.ts | 27 +++- 9 files changed, 309 insertions(+), 39 deletions(-) diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index 0ac02639c2636..12901b2f2ef30 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { parse as parseJSONC } from '../../../base/common/json.js'; -import { cloneAndChange } from '../../../base/common/objects.js'; +import { cloneAndChange, equals as objectEquals } from '../../../base/common/objects.js'; import { isAbsolute } from '../../../base/common/path.js'; import { untildify } from '../../../base/common/labels.js'; -import { basename, extname, isEqualOrParent, joinPath, normalizePath } from '../../../base/common/resources.js'; +import { basename, extname, isEqualOrParent, joinPath, normalizePath, isEqual as isURLEquals } from '../../../base/common/resources.js'; import { escapeRegExpCharacters } from '../../../base/common/strings.js'; import { hasKey, Mutable } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; @@ -41,6 +41,25 @@ export interface IParsedHookCommand { readonly sourceUri?: URI; } +export namespace IParsedHookCommand { + export function isEquals(a: IParsedHookCommand | undefined, b: IParsedHookCommand | undefined): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + return a.command === b.command + && a.windows === b.windows + && a.linux === b.linux + && a.osx === b.osx + && isURLEquals(a.cwd, b.cwd) + && objectEquals(a.env, b.env) + && a.timeout === b.timeout + && isURLEquals(a.sourceUri, b.sourceUri); + } +} + /** A group of hooks for a single lifecycle event. */ export interface IParsedHookGroup { /** Canonical hook type identifier (e.g. `'SessionStart'`, `'PreToolUse'`). */ diff --git a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts index 7d7eda096448b..cb6728a193332 100644 --- a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts +++ b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts @@ -13,6 +13,7 @@ function stubMcpCustomization(): McpServerCustomization { return { type: CustomizationType.McpServer, id: 'stub', uri: 'file:///plugin', name: 'test', enabled: true, state: { kind: McpServerStatus.Starting } }; } import { + IParsedHookCommand, parseComponentPathConfig, resolveComponentDirs, normalizeMcpServerConfiguration, @@ -282,4 +283,51 @@ suite('pluginParsers', () => { assert.strictEqual((result.configuration as { command: string }).command, '${lowercase}'); }); }); + + suite('IParsedHookCommand.isEquals', () => { + + test('returns true for structurally equivalent commands', () => { + const left: IParsedHookCommand = { + command: 'echo hi', + windows: 'Write-Host hi', + linux: 'echo hi', + osx: 'echo hi', + cwd: URI.file('/workspace'), + env: { A: '1' }, + timeout: 10, + sourceUri: URI.file('/workspace/.github/hooks.yml') + }; + const right: IParsedHookCommand = { + command: 'echo hi', + windows: 'Write-Host hi', + linux: 'echo hi', + osx: 'echo hi', + cwd: URI.file('/workspace'), + env: { A: '1' }, + timeout: 10, + sourceUri: URI.file('/workspace/.github/hooks.yml') + }; + + assert.strictEqual(IParsedHookCommand.isEquals(left, right), true); + }); + + test('returns false when any field differs', () => { + const left: IParsedHookCommand = { + command: 'echo hi', + cwd: URI.file('/workspace'), + env: { A: '1' }, + timeout: 10, + sourceUri: URI.file('/workspace/.github/hooks.yml') + }; + const right: IParsedHookCommand = { + command: 'echo bye', + cwd: URI.file('/workspace/other'), + env: { A: '2' }, + timeout: 20, + sourceUri: URI.file('/workspace/.github/other-hooks.yml') + }; + + assert.strictEqual(IParsedHookCommand.isEquals(left, right), false); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index 52b55182f338f..0c4f5abd4ef50 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { constObservable, IObservable, ISettableObservable, observableValue, transaction } from '../../../../base/common/observable.js'; @@ -29,6 +29,11 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { hash } from '../../../../base/common/hash.js'; import { isString } from '../../../../base/common/types.js'; import { isTarget } from './promptSyntax/languageProviders/promptFileAttributes.js'; +import { equals as arraysEqual } from '../../../../base/common/arrays.js'; +import { isEqual as isURLEquals } from '../../../../base/common/resources.js'; +import { equals as objectEquals } from '../../../../base/common/objects.js'; +import { Delayer } from '../../../../base/common/async.js'; + export const IChatModeService = createDecorator('chatModeService'); export interface IChatModeService { @@ -80,6 +85,9 @@ class ChatModes extends Disposable implements IChatModes { /** Tracks the most recent refresh of custom prompt modes. */ private _pendingRefresh: Promise = Promise.resolve(); + private _refreshCancellationSource: CancellationTokenSource | undefined; + private readonly _refreshThrottler = this._register(new Delayer(100)); + constructor( private readonly sessionResource: URI, @IChatAgentService private readonly chatAgentService: IChatAgentService, @@ -99,11 +107,11 @@ class ChatModes extends Disposable implements IChatModes { // Load cached modes from storage first this.loadCachedModes(); - this._pendingRefresh = this.refreshCustomPromptModes(true); + this._pendingRefresh = this.triggerRefresh(); // When the harness service is the source, also react to its change events for our session type. this._register(this.customizationHarnessService.onDidChangeCustomAgents(e => { if (e.sessionType === sessionType) { - this._pendingRefresh = this.refreshCustomPromptModes(true); + this._pendingRefresh = this.triggerRefresh(); } })); this._register(this.storageService.onWillSaveState(() => this.saveCachedModes())); @@ -205,13 +213,42 @@ class ChatModes extends Disposable implements IChatModes { } } - private async refreshCustomPromptModes(fireChangeEvent?: boolean): Promise { + private triggerRefresh(): Promise { + this._refreshCancellationSource?.cancel(); + this._refreshCancellationSource?.dispose(); + const refreshCancellationSource = this._refreshCancellationSource = new CancellationTokenSource(); + return this._refreshThrottler.trigger(async () => { + try { + await this.refreshCustomPromptModes(refreshCancellationSource.token); + } finally { + if (this._refreshCancellationSource === refreshCancellationSource) { + this._refreshCancellationSource = undefined; + } + refreshCancellationSource.dispose(); + } + }); + } + + override dispose(): void { + this._refreshCancellationSource?.cancel(); + this._refreshCancellationSource?.dispose(); + this._refreshCancellationSource = undefined; + super.dispose(); + } + + private async refreshCustomPromptModes(token: CancellationToken): Promise { + let hasChanges = false; try { - const customModes = await this.customizationHarnessService.getCustomAgents(this.sessionResource, CancellationToken.None); + if (token.isCancellationRequested) { + return; + } + const customModes = await this.customizationHarnessService.getCustomAgents(this.sessionResource, token); + if (token.isCancellationRequested) { + return; + } // Create a new set of mode instances, reusing existing ones where possible const seenUris = new Set(); - for (const customMode of customModes) { if (!customMode.visibility.userInvocable || !customMode.enabled) { continue; @@ -223,11 +260,14 @@ class ChatModes extends Disposable implements IChatModes { let modeInstance = this._customModeInstances.get(uriString); if (modeInstance) { // Update existing instance with new data - modeInstance.updateData(customMode); + if (modeInstance.updateData(customMode)) { + hasChanges = true; + } } else { // Create new instance modeInstance = new CustomChatMode(customMode); this._customModeInstances.set(uriString, modeInstance); + hasChanges = true; } } @@ -235,6 +275,7 @@ class ChatModes extends Disposable implements IChatModes { for (const [uriString] of this._customModeInstances.entries()) { if (!seenUris.has(uriString)) { this._customModeInstances.delete(uriString); + hasChanges = true; } } @@ -243,8 +284,9 @@ class ChatModes extends Disposable implements IChatModes { this.logService.error(error, 'Failed to load custom agents'); this._customModeInstances.clear(); this.hasCustomModes.set(false); + hasChanges = true; } - if (fireChangeEvent) { + if (hasChanges) { this._onDidChange.fire(); } } @@ -377,6 +419,21 @@ export interface IChatModeInstructions { readonly metadata?: Record; } +export namespace IChatModeInstructions { + export function isEquals(a: IChatModeInstructions | undefined, b: IChatModeInstructions | undefined): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + return a.content === b.content && + objectEquals(a.toolReferences, b.toolReferences) && + objectEquals(a.metadata, b.metadata); + } + +} + function isCachedChatModeData(data: unknown): data is IChatModeData { if (typeof data !== 'object' || data === null) { return false; @@ -505,22 +562,37 @@ export class CustomChatMode implements IChatMode { /** * Updates the underlying data and triggers observable changes */ - updateData(newData: ICustomAgent): void { + updateData(newData: ICustomAgent): boolean { + let hasChanges = false; + transaction(tx => { - this._nameObservable.set(newData.name, tx); - this._descriptionObservable.set(newData.description, tx); - this._customToolsObservable.set(newData.tools, tx); - this._modelObservable.set(newData.model, tx); - this._argumentHintObservable.set(newData.argumentHint, tx); - this._handoffsObservable.set(newData.handOffs, tx); - this._targetObservable.set(newData.target, tx); - this._visibilityObservable.set(newData.visibility, tx); - this._agentsObservable.set(newData.agents, tx); - this._modeInstructions.set(newData.agentInstructions, tx); - this._uriObservable.set(newData.uri, tx); - this._source = newData.source; - this._sessionTypes = newData.sessionTypes; + const update = (observable: ISettableObservable, newValue: T | undefined, equals: (a: T | undefined, b: T | undefined) => boolean = (a, b) => a === b) => { + if (!equals(observable.get(), newValue)) { + observable.set(newValue, tx); + hasChanges = true; + } + }; + update(this._nameObservable, newData.name); + update(this._descriptionObservable, newData.description); + update(this._customToolsObservable, newData.tools, arraysEqual); + update(this._modelObservable, newData.model, arraysEqual); + update(this._argumentHintObservable, newData.argumentHint); + update(this._modeInstructions, newData.agentInstructions, IChatModeInstructions.isEquals); + update(this._uriObservable, newData.uri, isURLEquals); + update(this._handoffsObservable, newData.handOffs, objectEquals); + update(this._targetObservable, newData.target); + update(this._visibilityObservable, newData.visibility, objectEquals); + update(this._agentsObservable, newData.agents, arraysEqual); + if (!IAgentSource.isEquals(this._source, newData.source)) { + this._source = newData.source; + hasChanges = true; + } + if (!arraysEqual(this._sessionTypes, newData.sessionTypes)) { + this._sessionTypes = newData.sessionTypes; + hasChanges = true; + } }); + return hasChanges; } toJSON(): IChatModeData { diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 7ef64bee0d95d..f680278a3f0bf 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -722,7 +722,7 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer const items = await harness.itemProvider.provideChatSessionCustomizations(sessionResource, token); - if (!items) { + if (!items || token.isCancellationRequested) { return []; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts index 3ff2713228962..b9d9e2cba3a83 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -38,6 +38,32 @@ export type ChatRequestHooks = { readonly [K in HookType]?: readonly IParsedHookCommand[]; }; +export namespace ChatRequestHooks { + export function isEquals(a: ChatRequestHooks | undefined, b: ChatRequestHooks | undefined): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + for (const hookType of Object.values(HookType)) { + const aArr = a[hookType]; + const bArr = b[hookType]; + if (aArr?.length !== bArr?.length) { + return false; + } + if (aArr && bArr) { + for (let i = 0; i < aArr.length; i++) { + if (!IParsedHookCommand.isEquals(aArr[i], bArr[i])) { + return false; + } + } + } + } + return true; + } +} + /** * Merges two sets of hooks by concatenating the command arrays for each hook type. * Additional hooks are appended after the base hooks. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index f2f330db8ca5b..4f226f728604d 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -16,6 +16,7 @@ import { IHandOff, ParsedPromptFile } from '../promptFileParser.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; import { IResolvedPromptSourceFolder } from '../config/promptFileLocations.js'; import { ChatRequestHooks } from '../hookSchema.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; /** * A single structured debug detail entry from the instructions context computer. @@ -212,6 +213,24 @@ export namespace IAgentSource { return { storage: promptPath.storage }; } } + + export function isEquals(a: IAgentSource | undefined, b: IAgentSource | undefined): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + if (a.storage !== b.storage) { + return false; + } + if (a.storage === PromptsStorage.extension && b.storage === PromptsStorage.extension) { + return ExtensionIdentifier.equals(a.extensionId, b.extensionId); + } else if (a.storage === PromptsStorage.plugin && b.storage === PromptsStorage.plugin) { + return isEqual(a.pluginUri, b.pluginUri); + } + return true; + } } /** diff --git a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts index d7c62475b0f65..c00fd8cde3a11 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { timeout } from '../../../../../base/common/async.js'; import { Emitter } from '../../../../../base/common/event.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -77,6 +76,10 @@ suite('ChatModeService', () => { await chatModeService.getLocalModes(); }); + const waitForRefresh = async (): Promise => { + await (await chatModeService.getLocalModes()).waitForPendingUpdates(); + }; + test('should return builtin modes', async () => { const modes = await chatModeService.getLocalModes(); @@ -135,10 +138,10 @@ suite('ChatModeService', () => { promptsService.setCustomModes([customMode]); - // Wait for the service to refresh - await timeout(0); + await waitForRefresh(); const modes = await chatModeService.getLocalModes(); + assert.strictEqual(modes.custom.length, 1); const testMode = modes.custom[0]; @@ -175,8 +178,7 @@ suite('ChatModeService', () => { promptsService.setCustomModes([customMode]); - // Wait for the event to fire - await timeout(0); + await waitForRefresh(); assert.ok(eventFired); }); @@ -197,8 +199,7 @@ suite('ChatModeService', () => { promptsService.setCustomModes([customMode]); - // Wait for the service to refresh - await timeout(0); + await waitForRefresh(); const foundMode = (await chatModeService.getLocalModes()).findModeById(customMode.uri.toString()); assert.ok(foundMode); @@ -224,7 +225,7 @@ suite('ChatModeService', () => { }; promptsService.setCustomModes([initialMode]); - await timeout(0); + await waitForRefresh(); const initialModes = await chatModeService.getLocalModes(); const initialCustomMode = initialModes.custom[0]; @@ -240,7 +241,7 @@ suite('ChatModeService', () => { }; promptsService.setCustomModes([updatedMode]); - await timeout(0); + await waitForRefresh(); const updatedModes = await chatModeService.getLocalModes(); const updatedCustomMode = updatedModes.custom[0]; @@ -256,6 +257,44 @@ suite('ChatModeService', () => { assert.deepStrictEqual(updatedCustomMode.source, workspaceSource); }); + test('should not fire change event when custom mode payload is unchanged', async () => { + const baseMode: ICustomAgent = { + id: 'stable-mode', + uri: URI.parse('file:///test/stable-mode.md'), + name: 'Stable Mode', + description: 'Stable description', + tools: ['tool1'], + agentInstructions: { content: 'Stable body', toolReferences: [] }, + source: workspaceSource, + target: Target.Undefined, + visibility: { userInvocable: true, agentInvocable: true }, + enabled: true + }; + + promptsService.setCustomModes([baseMode]); + await waitForRefresh(); + + let eventCount = 0; + testDisposables.add((await chatModeService.getLocalModes()).onDidChange(() => { + eventCount++; + })); + + const equivalentMode: ICustomAgent = { + ...baseMode, + tools: [...(baseMode.tools ?? [])], + agentInstructions: { + content: baseMode.agentInstructions.content, + toolReferences: [...baseMode.agentInstructions.toolReferences], + }, + visibility: { ...baseMode.visibility }, + }; + + promptsService.setCustomModes([equivalentMode]); + await waitForRefresh(); + + assert.strictEqual(eventCount, 0); + }); + test('should remove custom modes that no longer exist', async () => { const mode1: ICustomAgent = { id: 'mode1', @@ -285,14 +324,14 @@ suite('ChatModeService', () => { // Add both modes promptsService.setCustomModes([mode1, mode2]); - await timeout(0); + await waitForRefresh(); let modes = await chatModeService.getLocalModes(); assert.strictEqual(modes.custom.length, 2); // Remove one mode promptsService.setCustomModes([mode1]); - await timeout(0); + await waitForRefresh(); modes = await chatModeService.getLocalModes(); assert.strictEqual(modes.custom.length, 1); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts index ee30e3f60dc08..332a29fe730d0 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { resolveHookCommand, resolveEffectiveCommand, formatHookCommandLabel, IHookCommand, parseSubagentHooksFromYaml } from '../../../common/promptSyntax/hookSchema.js'; +import { resolveHookCommand, resolveEffectiveCommand, formatHookCommandLabel, IHookCommand, parseSubagentHooksFromYaml, ChatRequestHooks } from '../../../common/promptSyntax/hookSchema.js'; import { URI } from '../../../../../../base/common/uri.js'; import { OperatingSystem } from '../../../../../../base/common/platform.js'; import { HookType } from '../../../common/promptSyntax/hookTypes.js'; @@ -645,4 +645,28 @@ suite('HookSchema', () => { assert.strictEqual(result[HookType.PreToolUse], undefined); }); }); + + suite('ChatRequestHooks.isEquals', () => { + test('returns true for equivalent hook arrays', () => { + const left: ChatRequestHooks = { + [HookType.PreToolUse]: [{ command: './scripts/pre.sh', cwd: URI.file('/workspace') }], + }; + const right: ChatRequestHooks = { + [HookType.PreToolUse]: [{ command: './scripts/pre.sh', cwd: URI.file('/workspace') }], + }; + + assert.strictEqual(ChatRequestHooks.isEquals(left, right), true); + }); + + test('returns false for different hook commands', () => { + const left: ChatRequestHooks = { + [HookType.PreToolUse]: [{ command: './scripts/pre.sh' }], + }; + const right: ChatRequestHooks = { + [HookType.PreToolUse]: [{ command: './scripts/other.sh' }], + }; + + assert.strictEqual(ChatRequestHooks.isEquals(left, right), false); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 324f21bd7a9ec..e19ebe3053b94 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -20,7 +20,7 @@ import { IModelService } from '../../../../../../../editor/common/services/model import { ModelService } from '../../../../../../../editor/common/services/modelService.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IExtensionDescription } from '../../../../../../../platform/extensions/common/extensions.js'; +import { ExtensionIdentifier, IExtensionDescription } from '../../../../../../../platform/extensions/common/extensions.js'; import { IFileService } from '../../../../../../../platform/files/common/files.js'; import { FileService } from '../../../../../../../platform/files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; @@ -41,7 +41,7 @@ import { ComputeAutomaticInstructions, newInstructionsCollectionEvent, newInstru import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; import { AGENTS_SOURCE_FOLDER, CLAUDE_CONFIG_FOLDER, HOOKS_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptFileSource, PromptsType, Target } from '../../../../common/promptSyntax/promptTypes.js'; -import { IAgentDiscoveryResult, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { IAgentDiscoveryResult, IAgentSource, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js'; import { mockFiles } from '../testUtils/mockFilesystem.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../../platform/storage/common/storage.js'; @@ -213,6 +213,29 @@ suite('PromptsService', () => { instaService.stub(IPromptsService, service); }); + suite('IAgentSource.isEquals', () => { + test('returns true for equivalent local sources', () => { + const left: IAgentSource = { storage: PromptsStorage.local }; + const right: IAgentSource = { storage: PromptsStorage.local }; + + assert.strictEqual(IAgentSource.isEquals(left, right), true); + }); + + test('returns true for equivalent extension sources', () => { + const left: IAgentSource = { storage: PromptsStorage.extension, extensionId: new ExtensionIdentifier('ms.vscode') }; + const right: IAgentSource = { storage: PromptsStorage.extension, extensionId: new ExtensionIdentifier('ms.vscode') }; + + assert.strictEqual(IAgentSource.isEquals(left, right), true); + }); + + test('returns false for different plugin source URIs', () => { + const left: IAgentSource = { storage: PromptsStorage.plugin, pluginUri: URI.file('/workspace/plugin-a') }; + const right: IAgentSource = { storage: PromptsStorage.plugin, pluginUri: URI.file('/workspace/plugin-b') }; + + assert.strictEqual(IAgentSource.isEquals(left, right), false); + }); + }); + suite('parse', () => { test('explicit', async function () { const rootFolderName = 'resolves-nested-file-references'; From 05fe42d226f82b521377b94b6fbc66cdfaa21649 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Fri, 5 Jun 2026 14:53:24 +0100 Subject: [PATCH 15/29] Update hover background colors and titlebar widget styling for 2026 themes (#320099) fix: update hover background colors for 2026 themes and adjust titlebar widget styling Co-authored-by: mrleemurray Co-authored-by: Copilot --- extensions/theme-defaults/themes/2026-dark.json | 9 +++++---- extensions/theme-defaults/themes/2026-light.json | 9 +++++---- src/vs/sessions/browser/media/openInVSCode.css | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index 738540a5ce828..a9feefb29ffb4 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -58,10 +58,11 @@ "list.activeSelectionForeground": "#ededed", "list.inactiveSelectionBackground": "#2C2D2E", "list.inactiveSelectionForeground": "#ededed", - "list.hoverBackground": "#FFFFFF0D", + "list.hoverBackground": "#1E1F20", "list.hoverForeground": "#bfbfbf", "list.dropBackground": "#3994BC1A", - "toolbar.hoverBackground": "#FFFFFF18", + "toolbar.hoverBackground": "#323233", + "toolbar.activeBackground": "#3C3C3D", "list.focusBackground": "#3994BC26", "list.focusForeground": "#bfbfbf", "list.focusOutline": "#3994BCB3", @@ -182,7 +183,7 @@ "statusBar.noFolderBackground": "#191A1B", "statusBar.noFolderForeground": "#8C8C8C", "statusBarItem.activeBackground": "#4B4C4D", - "statusBarItem.hoverBackground": "#262728", + "statusBarItem.hoverBackground": "#323233", "statusBarItem.focusBorder": "#3994BCB3", "statusBarItem.prominentBackground": "#3994BC", "statusBarItem.prominentForeground": "#FFFFFF", @@ -234,7 +235,7 @@ "quickInputList.focusForeground": "#FFFFFF", "quickInputList.focusIconForeground": "#FFFFFF", "quickInputList.focusHighlightForeground": "#FFFFFF", - "quickInputList.hoverBackground": "#262728", + "quickInputList.hoverBackground": "#1E1F20", "terminal.selectionBackground": "#3994BC33", "terminal.background": "#191A1B", "terminal.border": "#2A2B2CFF", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index 46c622a72ee06..952b402bbc002 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -68,7 +68,7 @@ "list.activeSelectionForeground": "#202020", "list.inactiveSelectionBackground": "#DADADA99", "list.inactiveSelectionForeground": "#202020", - "list.hoverBackground": "#DADADA4f", + "list.hoverBackground": "#F1F1F3", "list.hoverForeground": "#202020", "list.dropBackground": "#0069CC15", "list.focusBackground": "#0069CC1A", @@ -187,12 +187,13 @@ "statusBar.noFolderBackground": "#F0F0F3", "statusBar.noFolderForeground": "#606060", "statusBarItem.activeBackground": "#EEEEEE", - "statusBarItem.hoverBackground": "#DADADA4f", + "statusBarItem.hoverBackground": "#E3E3E5", "statusBarItem.focusBorder": "#0069CCFF", "statusBarItem.prominentBackground": "#0069CCDD", "statusBarItem.prominentForeground": "#FFFFFF", "statusBarItem.prominentHoverBackground": "#0069CC", - "toolbar.hoverBackground": "#00000010", + "toolbar.hoverBackground": "#E3E3E5", + "toolbar.activeBackground": "#D6D6D8", "tab.activeBackground": "#FFFFFF", "tab.activeForeground": "#202020", "tab.inactiveBackground": "#FAFAFD", @@ -240,7 +241,7 @@ "quickInputList.focusForeground": "#FFFFFF", "quickInputList.focusIconForeground": "#FFFFFF", "quickInputList.focusHighlightForeground": "#FFFFFF", - "quickInputList.hoverBackground": "#EDF0F5", + "quickInputList.hoverBackground": "#F1F1F3", "terminal.selectionBackground": "#0069CC26", "terminalCursor.foreground": "#202020", "terminalCursor.background": "#FFFFFF", diff --git a/src/vs/sessions/browser/media/openInVSCode.css b/src/vs/sessions/browser/media/openInVSCode.css index f558bcab22ab1..5d89e0be650fc 100644 --- a/src/vs/sessions/browser/media/openInVSCode.css +++ b/src/vs/sessions/browser/media/openInVSCode.css @@ -89,7 +89,7 @@ .monaco-workbench .open-in-vscode-titlebar-widget:hover, .monaco-workbench .open-in-vscode-titlebar-widget:focus-visible { - background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-toolbar-hoverBackground)); + background-color: var(--vscode-toolbar-hoverBackground); outline: none; } From 1dd80fa5ea20038ffeaf2ba23560da5590af0b80 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 5 Jun 2026 16:07:56 +0200 Subject: [PATCH 16/29] sessions: retain selected session type across window reload (#320084) The session type picker only persisted on manual dropdown selection, so an auto-selected or defaulted type was never written to storage and reverted to the first provider by order (Copilot Chat) on reload. It now persists the active session's {providerId, sessionTypeId} on every change, like any picker. NewChatWidget no longer pre-validates the stored pick against the folder's currently-available types in onDidSelectWorkspace (which discarded a valid preference whose provider had not yet registered). Instead it creates the draft immediately with the best available provider and upgrades it in place once the preferred provider registers (driven by onDidChangeSessionTypes), cancelling on re-pick or send. No timeout or LifecyclePhase give-up, since agent hosts can connect arbitrarily late. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/sessions/SKILL.md | 4 +- src/vs/sessions/SESSIONS.md | 4 + .../contrib/chat/browser/newChatWidget.ts | 101 ++++++++---------- .../contrib/chat/browser/sessionTypePicker.ts | 9 +- 4 files changed, 56 insertions(+), 62 deletions(-) diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index 8dc6af7bd9b8d..d29daad857880 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -33,7 +33,9 @@ Then read the relevant spec for the area you are changing (see table below). If - **Modifying workbench code**: Prefer extending/wrapping workbench classes in the sessions layer over modifying shared workbench components. - **Timeouts as fixes**: Never use `setTimeout`/`disposableTimeout`/arbitrary delays to fix bugs or implement behaviour. They are race-prone guesses that mask the real ordering/state problem. Drive logic off deterministic signals instead — observables (`autorun`/`derived`), explicit events (`onDidChange*`), lifecycle phases, or awaiting the actual async operation. - **Stashed state read back later (side-channels)**: Never stash a value on a service during one method call and read it back from a separate query later, assuming it is still valid (e.g. a `Set`/flag set in `openSession` and consumed by a `shouldX()` pull-API). This is fragile temporal coupling. Instead, make it reactive state that is set **atomically together with its source of truth** and consumed reactively. Example: per-activation intent like "open in background / preserve focus" is exposed as an `IObservable` set in the **same transaction** as `activeSession` (via a single internal setter so it can never go stale), and read with `.read(reader)` in the consumer's `autorun` — never via a consume-once getter. -- **Blocking on a "pending/waiting" state instead of creating + upgrading**: When an entity (e.g. a draft session) depends on something that registers asynchronously, don't withhold creation behind a pending/waiting state. Prefer creating immediately with the best available data, then **replace/upgrade** it once the awaited dependency arrives (driven by an `onDidChange*`/observable signal), cancelling the upgrade if the user changes the inputs meanwhile and giving up at a deterministic lifecycle milestone (e.g. `LifecyclePhase.Eventually`) rather than a timeout. This keeps the UI populated and avoids fragile "is everything ready yet?" gating. +- **Blocking on a "pending/waiting" state instead of creating + upgrading**: When an entity (e.g. a draft session) depends on something that registers asynchronously, don't withhold creation behind a pending/waiting state. Prefer creating immediately with the best available data, then **replace/upgrade** it once the awaited dependency arrives (driven by an `onDidChange*`/observable signal), cancelling the upgrade if the user changes the inputs meanwhile. Do **not** bound the upgrade with a timeout or even a lifecycle milestone like `LifecyclePhase.Eventually` — an agent host connects lazily and can surface its session types after `Eventually` has already fired, which would lock in the wrong fallback. Let the upgrade listener live for the consumer's lifetime instead. +- **Persisting a picker value only on manual selection**: A picker that writes storage only when the user actively changes the dropdown will lose any auto-selected/defaulted value on reload (the stored preference is empty, so it falls back to a default). Persist on **every** change of the underlying value — exactly like a normal picker — so the last effective value always survives reload. Example: `SessionTypePicker` writes `{ providerId, sessionTypeId }` from both the manual pick handler and the active-session `refresh`, through the single `_writeStoredPick` persistence point. +- **Pre-validating a stored preference against not-yet-ready state in an event handler**: Don't re-check a restored preference against currently-available data (e.g. validating the stored session-type pick against `getSessionTypesForFolder` inside `onDidSelectWorkspace`) and null it out on mismatch. During reload the awaited provider hasn't registered yet, so a perfectly valid preference is discarded and the wrong default is locked in. Pass the preference straight through to the single place that reconciles preference-vs-availability (which creates now and upgrades later). ## Capturing Feedback (meta-rule) diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 46f59ace8a3a8..b90724e756bdd 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -113,6 +113,10 @@ An **`ISessionType`** identifies an agent backend (e.g., `'copilot-cli'`, `'copi Session types are surfaced ordered by each provider's `order` property (lower first; ties keep registration order). The default `order` is `0`, so the Copilot Chat sessions provider keeps precedence by default. The local agent host provider sets its `order` reactively from the experimental `chat.agentHost.defaultSessionsProvider` setting (default `false`, gated behind `chat.agentHost.enabled`): when enabled it returns a negative order so its session types sort before all other providers; otherwise it sorts after the defaults. The provider fires `onDidChangeSessionTypes` when the setting toggles so the management service re-collects and re-sorts. The sort itself lives in `SessionsManagementService._getOrderedProviders()` and applies to both `getAllSessionTypes()` and `getSessionTypesForFolder()` — the orchestration layer stays provider-agnostic (it sorts purely by `order`, with no knowledge of specific provider ids). +The session type picker persists the last selection as `{ providerId, sessionTypeId }` (the `providerId` disambiguates when two providers offer the same `sessionType.id`, e.g. `copilotcli`). Like any picker, it writes storage whenever the value changes — both on a manual dropdown pick and whenever the active session's type changes — so an auto-selected or defaulted type also survives reload (otherwise the stored preference would be empty and the restored draft would fall back to the first provider by `order`). + +On reload, providers register asynchronously and agent hosts connect lazily, so the preferred provider may not have surfaced its session types when the restored draft is created. The workspace picker fires `onDidSelectWorkspace` as soon as the workspace is recognized; `NewChatWidget` must **not** pre-validate the stored pick against the folder's currently-available types at that moment (doing so discards a still-valid preference whose provider just hasn't registered yet, locking in the wrong default). Instead `NewChatWidget._createNewSession` creates the draft immediately with the best available provider, then upgrades it in place once the preferred `(providerId, sessionTypeId)` pair becomes servable (driven by `onDidChangeSessionTypes`). The upgrade listener lives for the widget's lifetime — there is **no** timeout or `LifecyclePhase` give-up, since an agent host can connect arbitrarily late — and is cancelled if the user picks a different type/workspace or the draft is sent. + ### Changesets Sessions produce file changes organized into **`ISessionChangeset`** groups — named, togglable collections of file modifications that let users review and selectively apply changes. diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 863cdb8d8565d..5725936747689 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -13,6 +13,7 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { ILogService } from '../../../../platform/log/common/log.js'; import { localize } from '../../../../nls.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISession } from '../../../services/sessions/common/session.js'; import { IAquariumService, IMountedToggleHandle } from '../../aquarium/browser/aquariumOverlay.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { WorkspacePicker } from './sessionWorkspacePicker.js'; @@ -31,8 +32,8 @@ export class NewChatWidget extends Disposable { private readonly _newChatInput: NewChatInputWidget; private _aquariumToggle: IMountedToggleHandle | undefined; - /** Tracks an in-flight wait for a provider's session types to become available. */ - private readonly _pendingSessionTypeWait = new MutableDisposable(); + /** Recreates the draft once a better/late-registering provider can serve the folder (see {@link _createNewSession}). */ + private readonly _pendingPreferredUpgrade = new MutableDisposable(); /** * The currently mounted no-agent-host empty state, if any. Set by @@ -57,7 +58,7 @@ export class NewChatWidget extends Disposable { // {@link WorkspacePicker} is fine — phones never run there. const PickerCtor = isWeb ? WebWorkspacePicker : WorkspacePicker; this._workspacePicker = this._register(this.instantiationService.createInstance(PickerCtor)); - this._register(this._pendingSessionTypeWait); + this._register(this._pendingPreferredUpgrade); const canSendRequest = derived(reader => { const session = this.sessionsManagementService.activeSession.read(reader); @@ -83,20 +84,7 @@ export class NewChatWidget extends Disposable { })); this._register(this._workspacePicker.onDidSelectWorkspace(async folderUri => { - if (folderUri) { - // Carry over the user's preferred session type if some - // provider can still serve it for this folder; otherwise - // fall back to the provider's natural default by passing - // only the folder URI. - const picked = this._newChatInput.sessionTypePicker.selectedPick; - const folderTypes = picked ? this.sessionsManagementService.getSessionTypesForFolder(folderUri) : undefined; - const validForFolder = picked && folderTypes!.some(t => - (picked.providerId === undefined || t.providerId === picked.providerId) - && t.sessionType.id === picked.sessionTypeId); - await this._onWorkspaceSelected(folderUri, validForFolder ? picked : undefined); - } else { - await this._onWorkspaceSelected(undefined, undefined); - } + await this._onWorkspaceSelected(folderUri, folderUri ? this._newChatInput.sessionTypePicker.selectedPick : undefined); this._newChatInput.focus(); })); this._register(this._newChatInput.sessionTypePicker.onDidSelectSessionType(async pick => { @@ -165,59 +153,56 @@ export class NewChatWidget extends Disposable { return true; } - private _createNewSession(folderUri: URI, pick: IPreferredSessionType | undefined): void { - // If the carried-over pick can no longer be served for this folder - // (the picker upgraded to a different provider after restore), drop - // it so the management service picks the natural default. When the - // pick has no providerId yet (legacy stored preference), we accept - // any provider that offers the same sessionTypeId. - let effectivePick = pick; - if (effectivePick) { - const available = this.sessionsManagementService.getSessionTypesForFolder(folderUri); - const matches = available.some(t => - (effectivePick!.providerId === undefined || t.providerId === effectivePick!.providerId) - && t.sessionType.id === effectivePick!.sessionTypeId); - if (!matches) { - effectivePick = undefined; - } - } + private _isPreferredServable(folderUri: URI, pick: IPreferredSessionType): boolean { + return this.sessionsManagementService.getSessionTypesForFolder(folderUri).some(t => + (pick.providerId === undefined || t.providerId === pick.providerId) + && t.sessionType.id === pick.sessionTypeId); + } - // Session types may not be available yet (e.g., agent host still connecting). - // If so, wait for them before creating the session — otherwise createNewSession - // throws and the new chat view is left without an active session, which hides - // agent-host-specific UI (model picker etc.) until the user re-picks the workspace. - // If the connection fails, the picker fires onDidSelectWorkspace(undefined) which - // clears the pending wait via _onWorkspaceSelected. - const availableNow = this.sessionsManagementService.getSessionTypesForFolder(folderUri); - if (availableNow.length === 0) { - const pendingStore = new DisposableStore(); - this._pendingSessionTypeWait.value = pendingStore; - pendingStore.add(this.sessionsManagementService.onDidChangeSessionTypes(() => { - if (this.sessionsManagementService.getSessionTypesForFolder(folderUri).length > 0) { - this._pendingSessionTypeWait.clear(); - this._createNewSession(folderUri, pick); - } - })); - return; + private _createNewSession(folderUri: URI, pick: IPreferredSessionType | undefined): void { + this._pendingPreferredUpgrade.clear(); + const created = this._createSessionNow(folderUri, pick); + // Watch for late-registering providers when the draft could not be + // created yet (no provider serves the folder), or was created with a + // non-preferred fallback. Agent hosts connect lazily, so there is no + // timeout — the listener lives until the draft is sent or replaced. + if (!created || (pick && !this._isPreferredServable(folderUri, pick))) { + this._scheduleRecreateOnProviderChange(folderUri, pick, created); } + } - // Fall back to the provider associated with the recently-picked - // workspace (e.g. Local Agent Host) when the session type picker has - // no explicit pick yet. This preserves the user's historical provider - // association across iteration-order changes in the providers list. + private _createSessionNow(folderUri: URI, pick: IPreferredSessionType | undefined): ISession | undefined { + const effectivePick = pick && this._isPreferredServable(folderUri, pick) ? pick : undefined; const fallbackProviderId = this._workspacePicker.selectedResolved?.providerId; - try { - this.sessionsManagementService.createNewSession(folderUri, effectivePick + return this.sessionsManagementService.createNewSession(folderUri, effectivePick ? { providerId: effectivePick.providerId, sessionTypeId: effectivePick.sessionTypeId } : fallbackProviderId ? { providerId: fallbackProviderId } : undefined); } catch (e) { this.logService.error('Failed to create new session:', e); + return undefined; } } + private _scheduleRecreateOnProviderChange(folderUri: URI, pick: IPreferredSessionType | undefined, created: ISession | undefined): void { + const store = new DisposableStore(); + store.add(this.sessionsManagementService.onDidChangeSessionTypes(() => { + if (created) { + const active = this.sessionsManagementService.activeSession.get(); + if (active?.sessionId !== created.sessionId || active.isCreated.get()) { + return; // the draft was sent or is no longer the active session + } + if (pick && !this._isPreferredServable(folderUri, pick)) { + return; // the preferred provider still cannot serve the folder + } + } + this._createNewSession(folderUri, pick); + })); + this._pendingPreferredUpgrade.value = store; + } + /** * Returns the workspace URI for the context picker based on the current workspace selection. */ @@ -398,8 +383,8 @@ export class NewChatWidget extends Disposable { * Requests folder trust if needed and creates a new session. */ private async _onWorkspaceSelected(folderUri: URI | undefined, pick: IPreferredSessionType | undefined): Promise { - // Cancel any in-flight wait for a previous selection. - this._pendingSessionTypeWait.clear(); + // Cancel any in-flight upgrade for a previous selection. + this._pendingPreferredUpgrade.clear(); if (!folderUri) { this.sessionsManagementService.unsetNewSession(); diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 6571ee99095e3..868c9d80d2125 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -112,9 +112,12 @@ export class SessionTypePicker extends Disposable { if (session) { const folderUri = session.workspace.get()?.folders[0]?.root; this._folderSessionTypes = folderUri ? this.sessionsManagementService.getSessionTypesForFolder(folderUri) : []; - // The active session's actual type wins over any stored preference - // for trigger-label rendering. - this._picked = { providerId: session.providerId, sessionTypeId: session.sessionType }; + const concrete = { providerId: session.providerId, sessionTypeId: session.sessionType }; + const changed = concrete.providerId !== this._picked?.providerId || concrete.sessionTypeId !== this._picked?.sessionTypeId; + this._picked = concrete; + if (changed) { + this._writeStoredPick(concrete); + } } else { this._folderSessionTypes = []; // Preserve the stored pick when no active session exists, From 413c652a85a565c01fef7c864683a1f0a8534483 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:21:09 -0400 Subject: [PATCH 17/29] Agents: Align session list action icon buttons with top bar search icon button (#320050) Align session list action icon buttons with top bar search icon button --- .../sessions/contrib/sessions/browser/media/sessionsList.css | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 7febc0e384ad5..993ec5c93c27b 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -356,8 +356,7 @@ font-size: var(--vscode-agents-fontSize-label2, 11px); font-weight: var(--vscode-agents-fontWeight-semiBold, 600); color: var(--vscode-descriptionForeground); - /* align left with session item margin; keep extra right padding for section content spacing */ - padding: 0 16px 0 10px; + padding: 0 10px; .session-section-label { flex: 1 1 auto; @@ -386,7 +385,6 @@ .session-section-chevron { flex-shrink: 0; - margin-left: 4px; display: none; color: var(--vscode-descriptionForeground); font-size: var(--vscode-codiconFontSize-compact, 12px); From ac855f19183408b5b465827b0e3bfca9bc78a9fc Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Fri, 5 Jun 2026 19:21:50 +0500 Subject: [PATCH 18/29] Fix issue reporter wizard dropping preset extension `data` (#320103) Fix issue reporter dropping preset extension data The new IssueReporterOverlay wizard dropped the `data` payload provided via `workbench.action.openIssueReporter` when an `extensionId` was also preset. This broke callers like Copilot's NES feedback flow, which attaches an STest and recording context via the `data` that context was silently lostfield from the resulting issue body. The constructor calls `updateSelectedExtension(extensionId, /* loadExtensionData */ false)`. With `loadExtensionData=false`, the existing `applyExtensionIssueData` path that propagates `data` onto the selected extension and into the model (`extensionData` + `includeExtensionData`) was never invoked. Fix: in `updateSelectedExtension`, when `loadExtensionData=false` but the caller-supplied `this.data` carries `data`/`uri`/`privateUri`, apply it when `updateExtensionOptions` re-invokes `updateSelectedExtension` after `updateModel` repopulates `allExtensions`. Fixes #320101 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/inlineEditDebugComponent.ts | 2 +- .../issue/browser/issueReporterOverlay.ts | 44 +++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/components/inlineEditDebugComponent.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/components/inlineEditDebugComponent.ts index 72898ec9a9527..4c59d40bf91d9 100644 --- a/extensions/copilot/src/extension/inlineEdits/vscode-node/components/inlineEditDebugComponent.ts +++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/components/inlineEditDebugComponent.ts @@ -91,7 +91,7 @@ export class InlineEditDebugComponent extends Disposable { await openIssueReporter({ title: '', data: data.toString(), - issueBody: '# Description\nPlease describe the expected outcome and attach a screenshot!', + issueBody: 'Please describe the expected outcome and attach a screenshot!', public: !isInternalUser }); })); diff --git a/src/vs/workbench/contrib/issue/browser/issueReporterOverlay.ts b/src/vs/workbench/contrib/issue/browser/issueReporterOverlay.ts index 8b704044b57e1..a04c28b1d4fd2 100644 --- a/src/vs/workbench/contrib/issue/browser/issueReporterOverlay.ts +++ b/src/vs/workbench/contrib/issue/browser/issueReporterOverlay.ts @@ -730,7 +730,13 @@ export class IssueReporterOverlay { selectedExtension: this.selectedExtension, }); this.data.issueSource = this.selectedIssueSource; - this.data.extensionId = fileOnExtension ? this.selectedExtension?.id : undefined; + // Preserve a preset `extensionId` while the extension list is still loading: + // `selectedExtension` may be undefined here even though the caller asked + // for a specific extension, and overwriting with `undefined` would prevent + // the catch-up retry in `updateExtensionOptions` from re-resolving it. + this.data.extensionId = fileOnExtension + ? (this.selectedExtension?.id ?? this.data.extensionId) + : undefined; } private updateTitlePlaceholder(): void { @@ -793,7 +799,15 @@ export class IssueReporterOverlay { ? this.model.getData().allExtensions.find(candidate => candidate.id.toLowerCase() === extensionId.toLowerCase()) : undefined; this.selectedExtension = extension; - this.data.extensionId = extension?.id; + // Preserve the requested extensionId even when the extension list hasn't + // been populated yet (typical wizard flow: the constructor runs before + // `populateReporterDataAsync` finishes filling `allExtensions`). Without + // this preservation, the later catch-up retry in `updateExtensionOptions` + // sees `this.data.extensionId === undefined` and never re-resolves, + // dropping any preset extension data with it. + if (extensionId === undefined || extension) { + this.data.extensionId = extension?.id; + } this.extensionSelect.select(this.getSelectedExtensionIndex()); this.updateExtensionValidation(); this.updateIssueSourceFlags(); @@ -804,6 +818,25 @@ export class IssueReporterOverlay { return; } + // Apply any preset extension data BEFORE the built-in source-switch below. + // When the reporter is opened programmatically (e.g. via the + // `workbench.action.openIssueReporter` command) with a preset `extensionId` + // plus extension `data`/`uri`, propagate that data onto the selected + // extension and the model so it shows up in the issue body. Doing this + // before the built-in early-return is important: extensions bundled with + // the dev build (Copilot, etc.) are flagged `isBuiltin`, which triggers + // the source switch to VSCode and returns — otherwise the preset data + // would be silently lost for every built-in caller. We guard on + // `!this.includeExtensionData` (rather than `!extension.data`) because + // `issueService` pre-populates `extension.data` on every enabled + // extension, so that field is not a reliable "already applied" signal — + // `includeExtensionData` is only flipped to `true` by + // `applyExtensionIssueData`. + const hasPresetData = !this.includeExtensionData && (this.data.data !== undefined || this.data.uri !== undefined || this.data.privateUri !== undefined); + if (!loadExtensionData && hasPresetData) { + this.applyExtensionIssueData(extension, this.data); + } + if (extension.isBuiltin && this.selectedIssueSource === IssueSource.Extension && !this.data.issueSource) { this.setIssueSource(IssueSource.VSCode); return; @@ -1414,7 +1447,12 @@ export class IssueReporterOverlay { loading.textContent = localize('loadingSystemInfo', "Loading system information..."); } - if (modelData.fileOnExtension && modelData.extensionData) { + if (modelData.extensionData) { + // Match `buildIssueBody`, which only gates on `extensionData`. Gating + // here on `fileOnExtension` as well would hide the section in the + // review UI whenever the issue source was auto-switched away from + // Extension (e.g. built-in extensions are filed against VS Code), + // even though the extension data still ends up in the submitted body. diagnosticSectionCount++; diagnosticSectionStates.push(() => this.includeExtensionData); this.createDiagSection(diagContainer, { From f08110f2e5cdd39783a652421a492a042eb303dd Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Fri, 5 Jun 2026 19:24:19 +0500 Subject: [PATCH 19/29] agents: fix Mark as Done on cancelled new chat session (#318750) agents: fix Mark as Done on cancelled new chat session (#318550) When a Mark as Done action was invoked on an uncommitted (NEW) chat session that had been cancelled mid-flight, the click was a no-op: the session stayed in the active list and the UI never refreshed. Root cause: 'CopilotChatSessionsProvider.archiveSession' tried the agent-host lookup ('_findAgentSession') first. For untitled sessions that were started via the chat view, an entry already exists in the agent-host model (registered by 'getOrCreateChatSession' during the initial chat send) but with providerType 'Local'. That providerType is filtered out by '_refreshSessionCache', so calling 'agentSession.setArchived(true)' updates the agent-host model in isolation and never propagates to the chat adapter's '_isArchived' observable that the UI is bound to. Fix: reorder the logic in both 'archiveSession' and 'unarchiveSession' to check the chat-adapter first. If it is a NEW (uncommitted) session, archive it via the chat adapter and fire 'onDidChangeSessions' so the UI updates immediately. Only fall back to the agent-host path for committed sessions (where 'AgentSessionAdapter' is the chat adapter and '_refreshSessionCache' does propagate the change). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/copilotChatSessionsProvider.ts | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index bd432aff14063..e07e898fbb4a7 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -1678,34 +1678,38 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // -- Session Actions -- async archiveSession(sessionId: string): Promise { - const agentSession = this._findAgentSession(sessionId); - if (agentSession) { - agentSession.setArchived(true); - return; - } - - // Temp session that hasn't been committed — archive it in-place - // so the user can still review whatever content was produced. + // Uncommitted (NEW) sessions — including those that were cancelled mid-flight — + // must be archived via their chat-adapter directly. Their agent-host entry + // (if any, from `getOrCreateChatSession`) has providerType `Local`, which + // is filtered out by `_refreshSessionCache`, so changes made through + // `agentSession.setArchived(true)` would never propagate to the chat + // adapter's `_isArchived` observable. The result would be a no-op tick + // in the UI even though the agent-host model thinks the session is archived. const chatSession = this._findChatSession(sessionId); if (chatSession && isNewSession(chatSession)) { chatSession.setArchived(true); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(chatSession)] }); return; } - } - async unarchiveSession(sessionId: string): Promise { const agentSession = this._findAgentSession(sessionId); if (agentSession) { - agentSession.setArchived(false); - return; + agentSession.setArchived(true); } + } - // Temp session that hasn't been committed — unarchive it in-place + async unarchiveSession(sessionId: string): Promise { + // See `archiveSession` for why NEW sessions take a separate path. const chatSession = this._findChatSession(sessionId); if (chatSession && isNewSession(chatSession)) { chatSession.setArchived(false); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(chatSession)] }); + return; + } + + const agentSession = this._findAgentSession(sessionId); + if (agentSession) { + agentSession.setArchived(false); } } From 47d4f065b9f4ede575530c4b011df8e9b8eaa063 Mon Sep 17 00:00:00 2001 From: Ulugbek Abdullaev Date: Fri, 5 Jun 2026 19:35:16 +0500 Subject: [PATCH 20/29] NES: more detailed speculation prediction for patch-based prompts (#320107) * nes: patch: implement prediction formats * address Copilot review: docs, eager config, rename - Rewrite PatchModelPrediction JSDoc in conventional style; note line numbers are 0-based - Skip patchModelPredictionKind experiment lookup when responseFormat is not CustomDiffPatch - Rename cursorOriginalLinesOffset -> cursorLineInEditWindowOffset on UnifiedXmlInsertContext and its handler --- .../xtab/node/responseFormatHandlers.ts | 12 +-- .../src/extension/xtab/node/xtabProvider.ts | 66 ++++++++++----- .../test/node/responseFormatHandlers.spec.ts | 2 +- .../xtab/test/node/xtabProvider.spec.ts | 83 ++++++++++++++++--- .../common/configurationService.ts | 3 +- .../common/dataTypes/xtabPromptOptions.ts | 63 ++++++++++++++ 6 files changed, 190 insertions(+), 39 deletions(-) diff --git a/extensions/copilot/src/extension/xtab/node/responseFormatHandlers.ts b/extensions/copilot/src/extension/xtab/node/responseFormatHandlers.ts index 50ff5f744923c..a36817a5d84da 100644 --- a/extensions/copilot/src/extension/xtab/node/responseFormatHandlers.ts +++ b/extensions/copilot/src/extension/xtab/node/responseFormatHandlers.ts @@ -94,7 +94,7 @@ export async function handleEditWindowWithEditIntent( export interface UnifiedXmlInsertContext { readonly editWindowLines: string[]; readonly editWindowLineRange: OffsetRange; - readonly cursorOriginalLinesOffset: number; + readonly cursorLineInEditWindowOffset: number; readonly cursorColumnZeroBased: number; readonly editWindow: OffsetRange; readonly originalEditWindow: OffsetRange | undefined; @@ -150,18 +150,18 @@ async function* generateInsertEdits( ctx: UnifiedXmlInsertContext, documentBeforeEdits: StringText, ): AsyncGenerator { - const { editWindowLines, editWindowLineRange, cursorOriginalLinesOffset, cursorColumnZeroBased, editWindow, originalEditWindow, targetDocument, isFromCursorJump } = ctx; + const { editWindowLines, editWindowLineRange, cursorLineInEditWindowOffset, cursorColumnZeroBased, editWindow, originalEditWindow, targetDocument, isFromCursorJump } = ctx; const lineWithCursorContinued = await linesIter.next(); if (lineWithCursorContinued.done || lineWithCursorContinued.value.includes(ResponseTags.INSERT.end)) { return new NoNextEditReason.NoSuggestions(documentBeforeEdits, editWindow); } - const cursorLineContent = editWindowLines[cursorOriginalLinesOffset]; + const cursorLineContent = editWindowLines[cursorLineInEditWindowOffset]; const edit = new LineReplacement( new LineRange( - editWindowLineRange.start + cursorOriginalLinesOffset + 1 /* 0-based to 1-based */, - editWindowLineRange.start + cursorOriginalLinesOffset + 2, + editWindowLineRange.start + cursorLineInEditWindowOffset + 1 /* 0-based to 1-based */, + editWindowLineRange.start + cursorLineInEditWindowOffset + 2, ), [cursorLineContent.slice(0, cursorColumnZeroBased) + lineWithCursorContinued.value + cursorLineContent.slice(cursorColumnZeroBased)], ); @@ -178,7 +178,7 @@ async function* generateInsertEdits( v = await linesIter.next(); } - const line = editWindowLineRange.start + cursorOriginalLinesOffset + 2; + const line = editWindowLineRange.start + cursorLineInEditWindowOffset + 2; yield { edit: new LineReplacement( new LineRange(line, line), diff --git a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts index bbd4f3a5eafd7..f5713d49e96a4 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts @@ -107,7 +107,7 @@ interface RequestTracingContext { interface EditWindowInfo { editWindow: OffsetRange; editWindowLines: string[]; - cursorOriginalLinesOffset: number; + cursorLineInEditWindowOffset: number; editWindowLineRange: OffsetRange; } @@ -289,7 +289,7 @@ export class XtabProvider implements IStatelessNextEditProvider { const editWindowLinesRange = this.computeEditWindowLinesRange(currentDocument, request, tracer, telemetry); - const cursorOriginalLinesOffset = Math.max(0, currentDocument.cursorLineOffset - editWindowLinesRange.start); + const cursorLineInEditWindowOffset = Math.max(0, currentDocument.cursorLineOffset - editWindowLinesRange.start); const editWindowLastLineLength = currentDocument.transformer.getLineLength(editWindowLinesRange.endExclusive); const editWindow = currentDocument.transformer.getOffsetRange(new Range(editWindowLinesRange.start + 1, 1, editWindowLinesRange.endExclusive, editWindowLastLineLength + 1)); @@ -401,7 +401,7 @@ export class XtabProvider implements IStatelessNextEditProvider { const responseFormat = xtabPromptOptions.ResponseFormat.fromPromptingStrategy(promptOptions.promptingStrategy); - const prediction = this.getPredictedOutput(activeDocument, editWindowLines, responseFormat); + const prediction = this.getPredictedOutput(activeDocument, currentDocument.cursorLineOffset, editWindowLines, cursorLineInEditWindowOffset, responseFormat); const messages = constructMessages({ systemMsg: pickSystemPrompt(promptOptions.promptingStrategy), @@ -448,7 +448,7 @@ export class XtabProvider implements IStatelessNextEditProvider { editWindowInfo: { editWindow, editWindowLines, - cursorOriginalLinesOffset, + cursorLineInEditWindowOffset, editWindowLineRange: editWindowLinesRange, }, promptPieces, @@ -891,7 +891,7 @@ export class XtabProvider implements IStatelessNextEditProvider { ): EditStreaming { const { tracer, logContext, telemetry } = tracing; const { endpoint, messages, clippedTaggedCurrentDoc, editWindowInfo, promptPieces, prediction, originalEditWindow } = editStreamCtx; - const { editWindow, editWindowLines, cursorOriginalLinesOffset, editWindowLineRange } = editWindowInfo; + const { editWindow, editWindowLines, cursorLineInEditWindowOffset, editWindowLineRange } = editWindowInfo; const targetDocument = request.getActiveDocument().id; @@ -961,7 +961,7 @@ export class XtabProvider implements IStatelessNextEditProvider { { editWindowLines, editWindowLineRange, - cursorOriginalLinesOffset, + cursorLineInEditWindowOffset, cursorColumnZeroBased: promptPieces.currentDocument.cursorPosition.column - 1, editWindow, originalEditWindow, @@ -1042,7 +1042,7 @@ export class XtabProvider implements IStatelessNextEditProvider { ? cleanedLinesStream : linesWithIntermediateEditDivergenceCheck( cleanedLinesStream, - cursorOriginalLinesOffset, + cursorLineInEditWindowOffset, request, editWindowLineRange, editWindowLines, @@ -1054,7 +1054,7 @@ export class XtabProvider implements IStatelessNextEditProvider { let i = 0; let hasBeenDelayed = false; - for await (const edit of ResponseProcessor.diff(editWindowLines, divergenceCheckedStream, cursorOriginalLinesOffset, diffOptions)) { + for await (const edit of ResponseProcessor.diff(editWindowLines, divergenceCheckedStream, cursorLineInEditWindowOffset, diffOptions)) { if (lineDiverged) { break; @@ -1479,13 +1479,19 @@ export class XtabProvider implements IStatelessNextEditProvider { return createProxyXtabEndpoint(this.instaService, configuredModelName); } - private getPredictedOutput(doc: StatelessNextEditDocument, editWindowLines: string[], responseFormat: xtabPromptOptions.ResponseFormat): Prediction | undefined { - return this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabProviderUsePrediction, this.expService) - ? { - type: 'content', - content: getPredictionContents(doc, editWindowLines, responseFormat) - } - : undefined; + private getPredictedOutput(doc: StatelessNextEditDocument, cursorLineOffset: number, editWindowLines: string[], cursorLineInEditWindowOffset: number, responseFormat: xtabPromptOptions.ResponseFormat): Prediction | undefined { + const usePrediction = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabProviderUsePrediction, this.expService); + if (!usePrediction) { + return undefined; + } + // Only the CustomDiffPatch shape consults `patchModelPredictionKind`; skip the experiment lookup otherwise. + const patchModelPredictionKind = responseFormat === xtabPromptOptions.ResponseFormat.CustomDiffPatch + ? this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabProviderPatchModelPredictionKind, this.expService) + : xtabPromptOptions.PatchModelPrediction.FilePath; + return { + type: 'content', + content: getPredictionContents(doc, cursorLineOffset, editWindowLines, cursorLineInEditWindowOffset, responseFormat, patchModelPredictionKind) + }; } private async debounce(delaySession: DelaySession, retryState: RetryState.t, logger: ILogger, telemetry: StatelessNextEditTelemetryBuilder, cancellationToken: CancellationToken) { @@ -1684,7 +1690,7 @@ export function determineLanguageContextOptions(languageId: LanguageId, { enable return { enabled, maxTokens, traitPosition }; } -export function getPredictionContents(doc: StatelessNextEditDocument, editWindowLines: readonly string[], responseFormat: xtabPromptOptions.ResponseFormat): string { +export function getPredictionContents(doc: StatelessNextEditDocument, cursorLineOffset: number, editWindowLines: readonly string[], cursorLineInEditWindowOffset: number, responseFormat: xtabPromptOptions.ResponseFormat, patchModelPredictionKind: xtabPromptOptions.PatchModelPrediction): string { if (responseFormat === xtabPromptOptions.ResponseFormat.UnifiedWithXml) { return ['', ...editWindowLines, ''].join('\n'); } else if (responseFormat === xtabPromptOptions.ResponseFormat.EditWindowOnly) { @@ -1700,7 +1706,29 @@ export function getPredictionContents(doc: StatelessNextEditDocument, editWindow } else if (responseFormat === xtabPromptOptions.ResponseFormat.CustomDiffPatch) { const workspacePath = doc.workspaceRoot?.path; const workspaceRelativeDocPath = toUniquePath(doc.id, workspacePath); - return `${workspaceRelativeDocPath}:`; + + if (patchModelPredictionKind === xtabPromptOptions.PatchModelPrediction.FilePath) { + return `${workspaceRelativeDocPath}:`; + } + + // Guard the upper bound so we never emit a literal `-undefined` into the prompt. + const lineWithCursor = editWindowLines[cursorLineInEditWindowOffset]; + if (lineWithCursor === undefined) { + return `${workspaceRelativeDocPath}:`; + } + + // 0-based, matching `Patch.lineNumZeroBased` parsed by `XtabCustomDiffPatchResponseHandler`. + const lineLocation = `${workspaceRelativeDocPath}:${cursorLineOffset}`; + switch (patchModelPredictionKind) { + case xtabPromptOptions.PatchModelPrediction.CurrentLine: + return [lineLocation, `-${lineWithCursor}`].join('\n'); + case xtabPromptOptions.PatchModelPrediction.CurrentLineReplaced: + return [lineLocation, `-${lineWithCursor}`, `+`].join('\n'); + case xtabPromptOptions.PatchModelPrediction.CurrentLineCompleted: + return [lineLocation, `-${lineWithCursor}`, `+${lineWithCursor}`].join('\n'); + default: + assertNever(patchModelPredictionKind); + } } else { assertNever(responseFormat); } @@ -1708,7 +1736,7 @@ export function getPredictionContents(doc: StatelessNextEditDocument, editWindow async function* linesWithIntermediateEditDivergenceCheck( cleanedLinesStream: AsyncIterable, - cursorOriginalLinesOffset: number, + cursorLineInEditWindowOffset: number, request: StatelessNextEditRequest, editWindowLineRange: OffsetRange, editWindowLines: readonly string[], @@ -1736,7 +1764,7 @@ async function* linesWithIntermediateEditDivergenceCheck( } switch (mode) { case EarlyDivergenceCancellationMode.Cursor: - return lineIdx === cursorOriginalLinesOffset; + return lineIdx === cursorLineInEditWindowOffset; case EarlyDivergenceCancellationMode.EditWindow: return true; } diff --git a/extensions/copilot/src/extension/xtab/test/node/responseFormatHandlers.spec.ts b/extensions/copilot/src/extension/xtab/test/node/responseFormatHandlers.spec.ts index 234ca928796de..3eefb5c29b051 100644 --- a/extensions/copilot/src/extension/xtab/test/node/responseFormatHandlers.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/responseFormatHandlers.spec.ts @@ -44,7 +44,7 @@ function makeInsertContext(overrides?: Partial): Unifie return { editWindowLines: ['line0', 'cursor_line', 'line2'], editWindowLineRange: new OffsetRange(10, 13), - cursorOriginalLinesOffset: 1, + cursorLineInEditWindowOffset: 1, cursorColumnZeroBased: 6, editWindow: new OffsetRange(0, 100), originalEditWindow: undefined, diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts index f059346d8aa49..21d54d68c9c4a 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts @@ -13,7 +13,7 @@ import { InMemoryConfigurationService } from '../../../../platform/configuration import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/documentId'; import { Edits } from '../../../../platform/inlineEdits/common/dataTypes/edit'; import { LanguageId } from '../../../../platform/inlineEdits/common/dataTypes/languageId'; -import { DEFAULT_OPTIONS, EarlyDivergenceCancellationMode, LanguageContextLanguages, LintOptionShowCode, LintOptionWarning, ModelConfiguration, PromptingStrategy, ResponseFormat } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; +import { DEFAULT_OPTIONS, EarlyDivergenceCancellationMode, LanguageContextLanguages, LintOptionShowCode, LintOptionWarning, ModelConfiguration, PatchModelPrediction, PromptingStrategy, ResponseFormat } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; import { InlineEditRequestLogContext } from '../../../../platform/inlineEdits/common/inlineEditLogContext'; import { IInlineEditsModelService } from '../../../../platform/inlineEdits/common/inlineEditsModelService'; import { NoNextEditReason, StatelessNextEditDocument, StatelessNextEditRequest, StreamedEdit, WithStatelessProviderTelemetry } from '../../../../platform/inlineEdits/common/statelessNextEditProvider'; @@ -454,8 +454,22 @@ describe('getPredictionContents', () => { const editWindowLines = ['const x = 1;', 'const y = 2;']; const doc = makeActiveDocument(['line0', ...editWindowLines, 'line3']); + // Defaults for response formats that ignore the cursor/patch args. + const call = ( + lines: readonly string[], + format: ResponseFormat, + opts?: { cursorLineOffset?: number; cursorLineInEditWindowOffset?: number; patchModelPredictionKind?: PatchModelPrediction; doc?: StatelessNextEditDocument }, + ) => getPredictionContents( + opts?.doc ?? doc, + opts?.cursorLineOffset ?? 0, + lines, + opts?.cursorLineInEditWindowOffset ?? 0, + format, + opts?.patchModelPredictionKind ?? PatchModelPrediction.FilePath, + ); + it('returns correct content for UnifiedWithXml', () => { - expect(getPredictionContents(doc, editWindowLines, ResponseFormat.UnifiedWithXml)).toMatchInlineSnapshot(` + expect(call(editWindowLines, ResponseFormat.UnifiedWithXml)).toMatchInlineSnapshot(` " const x = 1; const y = 2; @@ -464,14 +478,14 @@ describe('getPredictionContents', () => { }); it('returns correct content for EditWindowOnly', () => { - expect(getPredictionContents(doc, editWindowLines, ResponseFormat.EditWindowOnly)).toMatchInlineSnapshot(` + expect(call(editWindowLines, ResponseFormat.EditWindowOnly)).toMatchInlineSnapshot(` "const x = 1; const y = 2;" `); }); it('returns correct content for EditWindowWithEditIntent', () => { - expect(getPredictionContents(doc, editWindowLines, ResponseFormat.EditWindowWithEditIntent)).toMatchInlineSnapshot(` + expect(call(editWindowLines, ResponseFormat.EditWindowWithEditIntent)).toMatchInlineSnapshot(` "<|edit_intent|>high<|/edit_intent|> const x = 1; const y = 2;" @@ -479,7 +493,7 @@ describe('getPredictionContents', () => { }); it('returns correct content for EditWindowWithEditIntentShort', () => { - expect(getPredictionContents(doc, editWindowLines, ResponseFormat.EditWindowWithEditIntentShort)).toMatchInlineSnapshot(` + expect(call(editWindowLines, ResponseFormat.EditWindowWithEditIntentShort)).toMatchInlineSnapshot(` "H const x = 1; const y = 2;" @@ -487,7 +501,7 @@ describe('getPredictionContents', () => { }); it('returns correct content for CodeBlock', () => { - expect(getPredictionContents(doc, editWindowLines, ResponseFormat.CodeBlock)).toMatchInlineSnapshot(` + expect(call(editWindowLines, ResponseFormat.CodeBlock)).toMatchInlineSnapshot(` "\`\`\` const x = 1; const y = 2; @@ -500,21 +514,66 @@ describe('getPredictionContents', () => { ['line0', 'line1'], { workspaceRoot: URI.file('/workspace/project') }, ); - const result = getPredictionContents(docWithRoot, ['line0'], ResponseFormat.CustomDiffPatch); + const result = call(['line0'], ResponseFormat.CustomDiffPatch, { doc: docWithRoot }); expect(result.endsWith(':')).toBe(true); }); it('returns correct content for CustomDiffPatch without workspace root', () => { - const result = getPredictionContents(doc, editWindowLines, ResponseFormat.CustomDiffPatch); + const result = call(editWindowLines, ResponseFormat.CustomDiffPatch); + expect(result.endsWith(':')).toBe(true); + }); + + it('CustomDiffPatch / CurrentLine emits path:line and a `-` deletion', () => { + expect(call(editWindowLines, ResponseFormat.CustomDiffPatch, { + cursorLineOffset: 7, + cursorLineInEditWindowOffset: 1, + patchModelPredictionKind: PatchModelPrediction.CurrentLine, + })).toMatchInlineSnapshot(` + "/test/file.ts:7 + -const y = 2;" + `); + }); + + it('CustomDiffPatch / CurrentLineReplaced emits a deletion plus an empty `+`', () => { + expect(call(editWindowLines, ResponseFormat.CustomDiffPatch, { + cursorLineOffset: 7, + cursorLineInEditWindowOffset: 1, + patchModelPredictionKind: PatchModelPrediction.CurrentLineReplaced, + })).toMatchInlineSnapshot(` + "/test/file.ts:7 + -const y = 2; + +" + `); + }); + + it('CustomDiffPatch / CurrentLineCompleted echoes the line as a `+`', () => { + expect(call(editWindowLines, ResponseFormat.CustomDiffPatch, { + cursorLineOffset: 7, + cursorLineInEditWindowOffset: 1, + patchModelPredictionKind: PatchModelPrediction.CurrentLineCompleted, + })).toMatchInlineSnapshot(` + "/test/file.ts:7 + -const y = 2; + +const y = 2;" + `); + }); + + it('CustomDiffPatch falls back to path-only when the cursor is past the edit window', () => { + const result = call(editWindowLines, ResponseFormat.CustomDiffPatch, { + cursorLineOffset: 99, + cursorLineInEditWindowOffset: 99, + patchModelPredictionKind: PatchModelPrediction.CurrentLine, + }); expect(result.endsWith(':')).toBe(true); + expect(result.includes('undefined')).toBe(false); }); it('handles empty editWindowLines', () => { - expect(getPredictionContents(doc, [], ResponseFormat.EditWindowOnly)).toBe(''); + expect(call([], ResponseFormat.EditWindowOnly)).toBe(''); }); it('handles single-line editWindowLines', () => { - expect(getPredictionContents(doc, ['only line'], ResponseFormat.EditWindowOnly)).toBe('only line'); + expect(call(['only line'], ResponseFormat.EditWindowOnly)).toBe('only line'); }); }); @@ -2231,7 +2290,7 @@ describe('XtabProvider integration', () => { // Doc at request time: "const a = 1;\nfunction fi\n}" // Offsets: "const a = 1;" = 0..11, \n = 12, "function fi" = 13..23, \n = 24, "}" = 25 // Cursor on line 1 (0-based), insertionOffset 23 = last 'i' of "function fi" - // Edit window: all 3 lines, cursorOriginalLinesOffset = 1 + // Edit window: all 3 lines, cursorLineInEditWindowOffset = 1 // // User typed "x" at offset 24 (end of "function fi") → "function fix" // Model responds with "function fibonacci(n): number" @@ -2395,7 +2454,7 @@ describe('XtabProvider integration', () => { const provider = createProvider(); // Doc: "const a = 1;\nfunction fi\n}" - // Cursor on line 1 (0-indexed), cursorOriginalLinesOffset = 1 + // Cursor on line 1 (0-indexed), cursorLineInEditWindowOffset = 1 // Lines before cursor (line 0) should be yielded normally. // Cursor line should trigger divergence cancellation. const request = createDivergenceRequest( diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 96cf10650a406..ed50d017d908b 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -12,6 +12,7 @@ import * as objects from '../../../util/vs/base/common/objects'; import { IObservable, observableFromEventOpts } from '../../../util/vs/base/common/observable'; import * as types from '../../../util/vs/base/common/types'; import { ICopilotTokenStore } from '../../authentication/common/copilotTokenStore'; +import type { IModelCapabilityOverride } from '../../endpoint/common/chatModelCapabilities'; import { packageJson } from '../../env/common/packagejson'; import { ImportChanges } from '../../inlineEdits/common/dataTypes/importFilteringOptions'; import { JointCompletionsProviderStrategy, JointCompletionsProviderTriggerChangeStrategy } from '../../inlineEdits/common/dataTypes/jointCompletionsProviderOptions'; @@ -22,7 +23,6 @@ import { DuplicateAdditionsMode, EarlyDivergenceCancellationMode, LANGUAGE_CONTE import { ResponseProcessor } from '../../inlineEdits/common/responseProcessor'; import { FetcherId } from '../../networking/common/fetcherService'; import { AlternativeNotebookFormat } from '../../notebook/common/alternativeContentFormat'; -import type { IModelCapabilityOverride } from '../../endpoint/common/chatModelCapabilities'; import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; import { IValidator, vBoolean, vNumber, vString } from './validator'; @@ -799,6 +799,7 @@ export namespace ConfigKey { */ export const InlineEditsNesMimicGhostTextBehavior = defineTeamInternalSetting('chat.advanced.inlineEdits.nesMimicGhostTextBehavior', ConfigType.ExperimentBased, false, vBoolean()); export const InlineEditsXtabProviderUsePrediction = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.usePrediction', ConfigType.ExperimentBased, true, vBoolean()); + export const InlineEditsXtabProviderPatchModelPredictionKind = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.patchModelPredictionKind', ConfigType.ExperimentBased, xtabPromptOptions.PatchModelPrediction.FilePath, xtabPromptOptions.PatchModelPrediction.VALIDATOR); export const InlineEditsXtabLanguageContextEnabledLanguages = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.languageContext.enabledLanguages', ConfigType.Simple, LANGUAGE_CONTEXT_ENABLED_LANGUAGES); export const InlineEditsXtabLanguageContextTraitsPosition = defineTeamInternalSetting<'before' | 'after'>('chat.advanced.inlineEdits.xtabProvider.languageContext.traitsPosition', ConfigType.ExperimentBased, 'before'); export const InlineEditsDiagnosticsExplorationEnabled = defineTeamInternalSetting('chat.advanced.inlineEdits.inlineEditsDiagnosticsExplorationEnabled', ConfigType.Simple, false); diff --git a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts index b46245914b37b..b92faf96a0a5e 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts @@ -773,3 +773,66 @@ export enum SpeculativeRequestsAutoExpandEditWindowLines { export namespace SpeculativeRequestsAutoExpandEditWindowLines { export const VALIDATOR = vEnum(SpeculativeRequestsAutoExpandEditWindowLines.Off, SpeculativeRequestsAutoExpandEditWindowLines.Smart, SpeculativeRequestsAutoExpandEditWindowLines.Always); } + +/** + * Shape of the predicted output we send to the patch-based model along with the prompt. + * In every example below, `{currentLineNumber}` is 0-based — matching `Patch.lineNumZeroBased` + * parsed by `XtabCustomDiffPatchResponseHandler`. + */ +export enum PatchModelPrediction { + /** + * Expects changes in the current file but doesn't expect where (line number is not specified). + * + * Example: + * + * ``` + * path/to/file: + * ``` + */ + FilePath = 'filePath', + /** + * Predicts the file path, cursor line number, and a deletion of the current line. + * The model is free to follow with further `-`/`+` lines as needed. + * + * Example: + * + * ``` + * path/to/file:{currentLineNumber} + * - class Foo { + * ``` + */ + CurrentLine = 'currentLine', + /** + * Expects the current line to be replaced. + * + * Example: + * + * ``` + * path/to/file:{currentLineNumber} + * - class Foo { + * + + * ``` + */ + CurrentLineReplaced = 'currentLineReplaced', + /** + * Expects the current line to be completed. + * + * Example: + * + * ``` + * path/to/file:{currentLineNumber} + * - class Foo + * + class Foo + * ``` + */ + CurrentLineCompleted = 'currentLineCompleted', +} + +export namespace PatchModelPrediction { + export const VALIDATOR = vEnum( + PatchModelPrediction.FilePath, + PatchModelPrediction.CurrentLine, + PatchModelPrediction.CurrentLineReplaced, + PatchModelPrediction.CurrentLineCompleted + ); +} From 621079ff563d3d38b7c167b3d74425c629d0584f Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:50:14 -0700 Subject: [PATCH 21/29] bump distro (#319955) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e292bc1a6d4cc..c2173d026e125 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.124.0", - "distro": "31bfc4c2834d5d34dcfb8dfe445e13ba25a92f54", + "distro": "b85469481bdd242ea1c0b9c07894f86c2b6fa408", "author": { "name": "Microsoft Corporation" }, From 992e28da80f6a61064a1ddeb94b38a793bcf1bc0 Mon Sep 17 00:00:00 2001 From: Muhammad Ishaq Khan <66727653+ishaq2321@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:07:15 +0200 Subject: [PATCH 22/29] editor: Cache getComputedStyle result in shadowCaretRangeFromPoint (#319803) (#319804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduces 6x redundant getComputedStyle() calls to 1x in the mouse move hot path. Each call forces a synchronous style recalculation — caching the result eliminates 5 unnecessary recalculations per mousemove event over shadow DOM editor content. No functional change — same properties read, same output, same behavior. Closes #319803 --- src/vs/editor/browser/controller/mouseTarget.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/vs/editor/browser/controller/mouseTarget.ts b/src/vs/editor/browser/controller/mouseTarget.ts index 8256f6b487ce6..98694f0d9f621 100644 --- a/src/vs/editor/browser/controller/mouseTarget.ts +++ b/src/vs/editor/browser/controller/mouseTarget.ts @@ -1133,12 +1133,13 @@ function shadowCaretRangeFromPoint(shadowRoot: ShadowRoot, x: number, y: number) // And its font (the computed shorthand font property might be empty, see #3217) const elWindow = dom.getWindow(el); - const fontStyle = elWindow.getComputedStyle(el, null).getPropertyValue('font-style'); - const fontVariant = elWindow.getComputedStyle(el, null).getPropertyValue('font-variant'); - const fontWeight = elWindow.getComputedStyle(el, null).getPropertyValue('font-weight'); - const fontSize = elWindow.getComputedStyle(el, null).getPropertyValue('font-size'); - const lineHeight = elWindow.getComputedStyle(el, null).getPropertyValue('line-height'); - const fontFamily = elWindow.getComputedStyle(el, null).getPropertyValue('font-family'); + const computedStyle = elWindow.getComputedStyle(el, null); + const fontStyle = computedStyle.getPropertyValue('font-style'); + const fontVariant = computedStyle.getPropertyValue('font-variant'); + const fontWeight = computedStyle.getPropertyValue('font-weight'); + const fontSize = computedStyle.getPropertyValue('font-size'); + const lineHeight = computedStyle.getPropertyValue('line-height'); + const fontFamily = computedStyle.getPropertyValue('font-family'); const font = `${fontStyle} ${fontVariant} ${fontWeight} ${fontSize}/${lineHeight} ${fontFamily}`; // And also its txt content From 00dd5b43f45c05bb185d30911afc62e5d79e2b9d Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:15:22 -0700 Subject: [PATCH 23/29] Better shrinking of items in browser toolbar (#320112) * Better shrinking of items in browser toolbar * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * feedback --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../features/browserNavigationFeatures.ts | 2 +- .../electron-browser/media/browser.css | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts index fe1dafde561c9..4ce2d169e89fc 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts @@ -114,7 +114,7 @@ class BrowserNavigationBar extends Disposable { getAvailableWidth: () => { const toolbarBounds = this.element.getBoundingClientRect(); const urlBarBounds = this._urlBar.element.getBoundingClientRect(); - return Math.max(0, toolbarBounds.right - urlBarBounds.left - 220 - 24 /* 220px min width, 8px padding on both sides plus 8px gap */); + return Math.max(0, toolbarBounds.right - urlBarBounds.left - 240 /* approximate: preferred width of the URL input plus padding */); } }, } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index f1d731a7339a8..a0eaa5c206d40 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -60,7 +60,7 @@ display: flex; align-items: center; box-sizing: border-box; - min-width: 220px; + min-width: 0; background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border); border-radius: var(--vscode-cornerRadius-medium); @@ -73,7 +73,8 @@ .browser-url-bar-widgets { display: flex; align-items: center; - flex-shrink: 0; + flex-shrink: 1; + overflow: hidden; padding: 2px; gap: 2px; } @@ -279,7 +280,8 @@ display: flex; align-items: center; margin: 0; - flex-shrink: 0; + min-width: 0; + flex-shrink: 1; .browser-share-toggle { padding: 2px 4px; @@ -290,6 +292,12 @@ gap: 4px; outline: none !important; + > span:not(.codicon) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + &:focus-visible { outline: 1px solid var(--vscode-focusBorder) !important; outline-offset: 0px !important; @@ -708,7 +716,7 @@ .browser-url-display { flex: 1; - min-width: 0; + min-width: 60px; display: block; box-sizing: border-box; padding: 4px 6px; From c8c5977964f0afde1de4516ec257143e6f099923 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:34:34 -0700 Subject: [PATCH 24/29] Send repoInfo telemetry for each repo in multi-repo workspaces (#320054) * Send repoInfo telemetry for each repo in multi-repo workspaces * Feedback update * fix test --- .../prompt/node/repoInfoTelemetry.ts | 139 +++++++--- .../node/test/repoInfoTelemetry.spec.ts | 256 +++++++++++++++++- 2 files changed, 358 insertions(+), 37 deletions(-) diff --git a/extensions/copilot/src/extension/prompt/node/repoInfoTelemetry.ts b/extensions/copilot/src/extension/prompt/node/repoInfoTelemetry.ts index 4709e0a0bd9dc..53d5838b8069d 100644 --- a/extensions/copilot/src/extension/prompt/node/repoInfoTelemetry.ts +++ b/extensions/copilot/src/extension/prompt/node/repoInfoTelemetry.ts @@ -6,6 +6,7 @@ import { ICopilotTokenStore } from '../../../platform/authentication/common/copilotTokenStore'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; +import { RelativePattern } from '../../../platform/filesystem/common/fileTypes'; import { IGitDiffService } from '../../../platform/git/common/gitDiffService'; import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService'; import { getOrderedRepoInfosFromContext, IGitService, normalizeFetchUrl, RepoContext, ResolvedRepoRemoteInfo } from '../../../platform/git/common/gitService'; @@ -52,6 +53,11 @@ const MAX_MERGE_BASE_AGE_DAYS = 30; // Max number of commits between merge base and HEAD before we skip the diff const MAX_DIFF_COMMITS = 30; +// Cap on the number of repositories we collect/send telemetry for per request. +// Covers >p90 of real-world multi-repo workspaces in long-tail cases (e.g. git.autoRepositoryDetection: 'subFolders' with many submodules). +// The active repository is always included; remaining slots are filled from the workspace repo list. +const MAX_REPOS_FOR_TELEMETRY = 5; + // EVENT: repoInfo type RepoInfoTelemetryResult = 'success' | 'filesChanged' | 'diffTooLarge' | 'noChanges' | 'tooManyChanges' | 'mergeBaseTooOld' | 'virtualFileSystem' | 'tooManyCommits'; @@ -64,12 +70,15 @@ type RepoInfoTelemetryProperties = { fileRelativePaths: string | undefined; diffsJSON: string | undefined; result: RepoInfoTelemetryResult; + isActiveRepository: 'true' | 'false'; }; type RepoInfoTelemetryMeasurements = { workspaceFileCount: number; changedFileCount: number; diffSizeBytes: number; + repoIndex: number; + repoCount: number; }; type RepoInfoTelemetryData = { @@ -94,8 +103,8 @@ function shouldSendEndTelemetry(result: RepoInfoTelemetryResult | undefined): bo */ export class RepoInfoTelemetry { private _beginTelemetrySent = false; - private _beginTelemetryPromise: Promise | undefined; - private _beginTelemetryResult: RepoInfoTelemetryResult | undefined; + private _beginTelemetryPromise: Promise> | undefined; + private readonly _beginTelemetryResults = new Map(); constructor( private readonly _telemetryMessageId: string, @@ -124,8 +133,7 @@ export class RepoInfoTelemetry { try { this._beginTelemetrySent = true; this._beginTelemetryPromise = this._sendRepoInfoTelemetry('begin'); - const gitInfo = await this._beginTelemetryPromise; - this._beginTelemetryResult = gitInfo?.properties.result; + await this._beginTelemetryPromise; } catch (error) { this._logService.warn(`Failed to send begin repo info telemetry ${error}`); } @@ -137,8 +145,9 @@ export class RepoInfoTelemetry { public async sendEndTelemetry(): Promise { await this._beginTelemetryPromise; - // Skip end telemetry if begin wasn't successful - if (!shouldSendEndTelemetry(this._beginTelemetryResult)) { + // Skip end telemetry if no repos had a result that warrants an end event + const hasAnyEndCandidate = Array.from(this._beginTelemetryResults.values()).some(shouldSendEndTelemetry); + if (!hasAnyEndCandidate) { return; } @@ -149,38 +158,93 @@ export class RepoInfoTelemetry { } } - private async _sendRepoInfoTelemetry(location: 'begin' | 'end'): Promise { + private async _sendRepoInfoTelemetry(location: 'begin' | 'end'): Promise> { + const results = new Map(); if (this._configurationService.getConfig(ConfigKey.TeamInternal.DisableRepoInfoTelemetry)) { - return undefined; + return results; } - const repoInfo = await this._getRepoInfoTelemetry(); - if (!repoInfo) { - return undefined; + const allRepositories = this._gitService.repositories ?? []; + const totalRepoCount = allRepositories.length; + if (totalRepoCount === 0) { + return results; } - const internalProperties: RepoInfoInternalTelemetryProperties = { - ...repoInfo.properties, - location, - telemetryMessageId: this._telemetryMessageId - }; - + const activeRepoUri = this._gitService.activeRepository?.get()?.rootUri; const isInternal = !!this._copilotTokenStore.copilotToken?.isInternal; - if (isInternal) { - const { headBranchName: _, fileRelativePaths: _2, ...msftProperties } = internalProperties; - this._telemetryService.sendInternalMSFTTelemetryEvent('request.repoInfo', msftProperties, repoInfo.measurements); + + // Cap the number of repos we process per request. Always include the active repo, then fill + // remaining slots from the workspace order. `repoCount` in measurements reports the TRUE total + // so dashboards can detect sampling. + const repositories: RepoContext[] = []; + if (activeRepoUri) { + const active = allRepositories.find(r => extUriBiasedIgnorePathCase.isEqual(r.rootUri, activeRepoUri)); + if (active) { + repositories.push(active); + } + } + for (const r of allRepositories) { + if (repositories.length >= MAX_REPOS_FOR_TELEMETRY) { + break; + } + if (!repositories.includes(r)) { + repositories.push(r); + } } - this._telemetryService.sendEnhancedGHTelemetryEvent('request.repoInfo', internalProperties, repoInfo.measurements); - return repoInfo; - } + // Use allSettled so a failure on one repo does not drop telemetry for siblings. + const settled = await Promise.allSettled(repositories.map(async (repoContext, repoIndex) => { + const rootUriKey = repoContext.rootUri.toString(); + const isActiveRepository = !!activeRepoUri && extUriBiasedIgnorePathCase.isEqual(repoContext.rootUri, activeRepoUri); - private async _resolveRepoContext(): Promise<{ repoContext: RepoContext; repoInfo: ResolvedRepoRemoteInfo; repository: Repository; upstreamCommit: string; headBranchName: string | undefined } | undefined> { - const repoContext = this._gitService.activeRepository?.get(); - if (!repoContext) { - return; + // For end events, only emit for repos whose begin result warranted an end event. + if (location === 'end' && !shouldSendEndTelemetry(this._beginTelemetryResults.get(rootUriKey))) { + return { rootUriKey, data: undefined }; + } + + const data = await this._getRepoInfoTelemetryForRepo(repoContext, repoIndex, totalRepoCount, isActiveRepository); + return { rootUriKey, data }; + })); + + const perRepo: { rootUriKey: string; data: RepoInfoTelemetryData | undefined }[] = []; + for (let i = 0; i < settled.length; i++) { + const outcome = settled[i]; + if (outcome.status === 'fulfilled') { + perRepo.push(outcome.value); + } else { + this._logService.warn(`Failed to compute repo info telemetry for repo ${repositories[i].rootUri.toString()}: ${outcome.reason}`); + perRepo.push({ rootUriKey: repositories[i].rootUri.toString(), data: undefined }); + } } + for (const { rootUriKey, data } of perRepo) { + results.set(rootUriKey, data); + if (location === 'begin') { + // Populate begin-results within the same promise so any awaiter of `_beginTelemetryPromise` + // is guaranteed to see populated results without relying on continuation ordering. + this._beginTelemetryResults.set(rootUriKey, data?.properties.result); + } + if (!data) { + continue; + } + + const internalProperties: RepoInfoInternalTelemetryProperties = { + ...data.properties, + location, + telemetryMessageId: this._telemetryMessageId + }; + + if (isInternal) { + const { headBranchName: _, fileRelativePaths: _2, ...msftProperties } = internalProperties; + this._telemetryService.sendInternalMSFTTelemetryEvent('request.repoInfo', msftProperties, data.measurements); + } + this._telemetryService.sendEnhancedGHTelemetryEvent('request.repoInfo', internalProperties, data.measurements); + } + + return results; + } + + private async _resolveRepoContext(repoContext: RepoContext): Promise<{ repoInfo: ResolvedRepoRemoteInfo; repository: Repository; upstreamCommit: string; headBranchName: string | undefined } | undefined> { const repoInfo = Array.from(getOrderedRepoInfosFromContext(repoContext))[0]; if (!repoInfo || !repoInfo.fetchUrl) { return; @@ -206,16 +270,16 @@ export class RepoInfoTelemetry { } const headBranchName = repository.state.HEAD?.name; - return { repoContext, repoInfo, repository, upstreamCommit, headBranchName }; + return { repoInfo, repository, upstreamCommit, headBranchName }; } - private async _getRepoInfoTelemetry(): Promise { - const ctx = await this._resolveRepoContext(); + private async _getRepoInfoTelemetryForRepo(repoContext: RepoContext, repoIndex: number, repoCount: number, isActiveRepository: boolean): Promise { + const ctx = await this._resolveRepoContext(repoContext); if (!ctx) { return; } - const { repoContext, repoInfo, repository, upstreamCommit, headBranchName } = ctx; + const { repoInfo, repository, upstreamCommit, headBranchName } = ctx; const normalizedFetchUrl = normalizeFetchUrl(repoInfo.fetchUrl!); const skipDiffResult = (result: RepoInfoTelemetryResult): RepoInfoTelemetryData => ({ @@ -228,11 +292,14 @@ export class RepoInfoTelemetry { fileRelativePaths: undefined, diffsJSON: undefined, result, + isActiveRepository: isActiveRepository ? 'true' : 'false', }, measurements: { workspaceFileCount: 0, changedFileCount: 0, diffSizeBytes: 0, + repoIndex, + repoCount, } }); @@ -280,10 +347,9 @@ export class RepoInfoTelemetry { return skipDiffResult('tooManyCommits'); } - // Before we calculate our async diffs, sign up for file system change events - // Any changes during the async operations will invalidate our diff data and we send it - // as a failure without a diffs - const watcher = this._fileSystemService.createFileSystemWatcher('**/*'); + // Before we calculate our async diffs, sign up for file system change events scoped to this repo. + // A change in another repo should not invalidate this repo's diff. + const watcher = this._fileSystemService.createFileSystemWatcher(new RelativePattern(repoContext.rootUri, '**/*')); let filesChanged = false; const createDisposable = watcher.onDidCreate(() => filesChanged = true); const changeDisposable = watcher.onDidChange(() => filesChanged = true); @@ -296,6 +362,7 @@ export class RepoInfoTelemetry { repoType: repoInfo.repoId.type, headCommitHash: upstreamCommit, headBranchName, + isActiveRepository: isActiveRepository ? 'true' : 'false', }; // Workspace file index will be used to get a rough count of files in the repository @@ -306,6 +373,8 @@ export class RepoInfoTelemetry { workspaceFileCount: this._workspaceFileIndex.fileCount, changedFileCount: 0, // Will be updated diffSizeBytes: 0, // Will be updated + repoIndex, + repoCount, }; // Combine our diff against the upstream commit with untracked changes, and working tree changes diff --git a/extensions/copilot/src/extension/prompt/node/test/repoInfoTelemetry.spec.ts b/extensions/copilot/src/extension/prompt/node/test/repoInfoTelemetry.spec.ts index 977d3dfb57ff2..28f093f874292 100644 --- a/extensions/copilot/src/extension/prompt/node/test/repoInfoTelemetry.spec.ts +++ b/extensions/copilot/src/extension/prompt/node/test/repoInfoTelemetry.spec.ts @@ -71,13 +71,19 @@ suite('RepoInfoTelemetry', () => { services.define(IWorkspaceFileIndex, new SyncDescriptor(NullWorkspaceFileIndex)); // Override IGitService with a proper mock that has an observable activeRepository + const activeRepository = observableValue('test-git-activeRepo', undefined); const mockGitService: IGitService = { _serviceBrand: undefined, - activeRepository: observableValue('test-git-activeRepo', undefined), + activeRepository, onDidOpenRepository: Event.None, onDidCloseRepository: Event.None, onDidFinishInitialization: Event.None, - repositories: [], + get repositories() { + // Mirror activeRepository so existing tests that only set activeRepository + // continue to work after the multi-repo refactor of RepoInfoTelemetry. + const repo = activeRepository.get(); + return repo ? [repo] : []; + }, isInitialized: true, initRepository: vi.fn(), openRepository: vi.fn(), @@ -1948,6 +1954,162 @@ suite('RepoInfoTelemetry', () => { assert.strictEqual(call[1].result, 'mergeBaseTooOld'); }); + // ======================================== + // Multi-Repository Tests + // ======================================== + + test('should send one telemetry event per repository in a multi-repo workspace', async () => { + setupInternalUser(); + + const repoAUri = URI.file('/test/repoA'); + const repoBUri = URI.file('/test/repoB'); + mockMultiRepoSetup([ + { rootUri: repoAUri, remoteUrl: 'https://github.com/microsoft/vscode.git' }, + { rootUri: repoBUri, remoteUrl: 'https://github.com/microsoft/typescript.git' }, + ], repoAUri); + + const repoTelemetry = new RepoInfoTelemetry( + 'test-message-id', + telemetryService, + gitService, + gitDiffService, + gitExtensionService, + logService, + fileSystemService, + workspaceFileIndex, + configurationService, + copilotTokenStore + ); + + await repoTelemetry.sendBeginTelemetryIfNeeded(); + + const calls = (telemetryService.sendInternalMSFTTelemetryEvent as any).mock.calls; + assert.strictEqual(calls.length, 2, 'should emit one event per repo'); + + const propsByRepoIndex = new Map(); + for (const call of calls) { + assert.strictEqual(call[0], 'request.repoInfo'); + propsByRepoIndex.set(call[2].repoIndex, { props: call[1], measurements: call[2] }); + } + + const first = propsByRepoIndex.get(0)!; + const second = propsByRepoIndex.get(1)!; + assert.strictEqual(first.measurements.repoCount, 2); + assert.strictEqual(second.measurements.repoCount, 2); + assert.strictEqual(first.props.isActiveRepository, 'true'); + assert.strictEqual(second.props.isActiveRepository, 'false'); + assert.strictEqual(first.props.remoteUrl, 'https://github.com/microsoft/vscode.git'); + assert.strictEqual(second.props.remoteUrl, 'https://github.com/microsoft/typescript.git'); + }); + + test('should include repoIndex/repoCount/isActiveRepository for single-repo workspace', async () => { + setupInternalUser(); + mockGitServiceWithRepository(); + mockGitExtensionWithUpstream('abc123'); + mockGitDiffService([{ uri: '/test/repo/file.ts', diff: 'some diff' }]); + + const repoTelemetry = new RepoInfoTelemetry( + 'test-message-id', + telemetryService, + gitService, + gitDiffService, + gitExtensionService, + logService, + fileSystemService, + workspaceFileIndex, + configurationService, + copilotTokenStore + ); + + await repoTelemetry.sendBeginTelemetryIfNeeded(); + + assert.strictEqual((telemetryService.sendInternalMSFTTelemetryEvent as any).mock.calls.length, 1); + const call = (telemetryService.sendInternalMSFTTelemetryEvent as any).mock.calls[0]; + assert.strictEqual(call[2].repoIndex, 0); + assert.strictEqual(call[2].repoCount, 1); + assert.strictEqual(call[1].isActiveRepository, 'true'); + }); + + test('should gate end telemetry per repo', async () => { + setupInternalUser(); + + const repoAUri = URI.file('/test/repoA'); + const repoBUri = URI.file('/test/repoB'); + // Repo A: success path (1 change). Repo B: tooManyChanges (101 changes). + mockMultiRepoSetup([ + { rootUri: repoAUri, remoteUrl: 'https://github.com/microsoft/vscode.git' }, + { rootUri: repoBUri, remoteUrl: 'https://github.com/microsoft/typescript.git', changeCount: 101 }, + ], repoAUri); + + const repoTelemetry = new RepoInfoTelemetry( + 'test-message-id', + telemetryService, + gitService, + gitDiffService, + gitExtensionService, + logService, + fileSystemService, + workspaceFileIndex, + configurationService, + copilotTokenStore + ); + + await repoTelemetry.sendBeginTelemetryIfNeeded(); + await repoTelemetry.sendEndTelemetry(); + + // 2 begin events (one per repo) + 1 end event (only the success repo) + const calls = (telemetryService.sendInternalMSFTTelemetryEvent as any).mock.calls; + assert.strictEqual(calls.length, 3); + + const beginCalls = calls.filter((c: any) => c[1].location === 'begin'); + const endCalls = calls.filter((c: any) => c[1].location === 'end'); + assert.strictEqual(beginCalls.length, 2); + assert.strictEqual(endCalls.length, 1); + assert.strictEqual(endCalls[0][1].remoteUrl, 'https://github.com/microsoft/vscode.git'); + assert.strictEqual(endCalls[0][1].result, 'success'); + }); + + test('should cap telemetry at 5 repos with active repo always included', async () => { + setupInternalUser(); + + // 7 repos total; active repo is the 6th one (would normally be excluded by a naive slice). + const repos = Array.from({ length: 7 }, (_, i) => ({ + rootUri: URI.file(`/test/repo${i}`), + remoteUrl: `https://github.com/microsoft/repo${i}.git`, + })); + const activeRepoUri = repos[5].rootUri; + mockMultiRepoSetup(repos, activeRepoUri); + + const repoTelemetry = new RepoInfoTelemetry( + 'test-message-id', + telemetryService, + gitService, + gitDiffService, + gitExtensionService, + logService, + fileSystemService, + workspaceFileIndex, + configurationService, + copilotTokenStore + ); + + await repoTelemetry.sendBeginTelemetryIfNeeded(); + + const calls = (telemetryService.sendInternalMSFTTelemetryEvent as any).mock.calls; + // Capped at 5 events even though 7 repos exist. + assert.strictEqual(calls.length, 5); + + // Every event reports the TRUE repoCount (7), not the capped count. + for (const call of calls) { + assert.strictEqual(call[2].repoCount, 7); + } + + // Active repo must be present exactly once and marked as active. + const activeCalls = calls.filter((c: any) => c[1].isActiveRepository === 'true'); + assert.strictEqual(activeCalls.length, 1); + assert.strictEqual(activeCalls[0][1].remoteUrl, 'https://github.com/microsoft/repo5.git'); + }); + // ======================================== // Helper Functions // ======================================== @@ -2063,6 +2225,96 @@ suite('RepoInfoTelemetry', () => { })) ); } + + function mockMultiRepoSetup( + repos: Array<{ rootUri: URI; remoteUrl: string; upstreamCommit?: string; changeCount?: number }>, + activeRootUri: URI + ) { + const repoContexts = repos.map(r => ({ + rootUri: r.rootUri, + changes: { + mergeChanges: [], + indexChanges: [], + workingTree: [{ + uri: URI.joinPath(r.rootUri, 'file.ts'), + originalUri: URI.joinPath(r.rootUri, 'file.ts'), + renameUri: undefined, + status: Status.MODIFIED + }], + untrackedChanges: [] + }, + remotes: ['origin'], + remoteFetchUrls: [r.remoteUrl], + upstreamRemote: 'origin', + headBranchName: 'main', + headCommitHash: r.upstreamCommit ?? 'abc123', + upstreamBranchName: 'origin/main', + isRebasing: false, + })); + + const active = repoContexts.find(r => r.rootUri.toString() === activeRootUri.toString()); + vi.spyOn(gitService.activeRepository, 'get').mockReturnValue(active as any); + Object.defineProperty(gitService, 'repositories', { + configurable: true, + get: () => repoContexts, + }); + + // Per-repo git extension API: getRepository(uri) returns a mock repo with the upstream + // configured to point at . + const reposByPath = new Map(); + for (const ctx of repoContexts) { + const r = repos.find(x => x.rootUri.toString() === ctx.rootUri.toString())!; + const upstreamCommit = r.upstreamCommit ?? 'abc123'; + const mockRepo: any = { + getMergeBase: vi.fn(async (ref1: string, ref2: string) => + (ref1 === 'HEAD' && ref2 === '@{upstream}') ? upstreamCommit : undefined + ), + getBranchBase: vi.fn().mockResolvedValue(undefined), + getCommit: vi.fn().mockResolvedValue({ + hash: upstreamCommit, + message: 'test commit', + commitDate: new Date(), + }), + getConfig: vi.fn().mockResolvedValue(''), + log: vi.fn().mockResolvedValue([]), + state: { + HEAD: { upstream: { commit: upstreamCommit, remote: 'origin' } }, + remotes: [{ name: 'origin', fetchUrl: r.remoteUrl, pushUrl: r.remoteUrl, isReadOnly: false }], + workingTreeChanges: [], + untrackedChanges: [], + }, + }; + reposByPath.set(ctx.rootUri.toString(), mockRepo); + } + vi.spyOn(gitExtensionService, 'getExtensionApi').mockReturnValue({ + getRepository: (uri: URI) => reposByPath.get(uri.toString()), + } as any); + + // diffWith: synthesize changes for this repo + vi.spyOn(gitService, 'diffWith').mockImplementation(async (uri: URI) => { + const r = repos.find(x => x.rootUri.toString() === uri.toString()); + const count = r?.changeCount ?? 1; + return Array.from({ length: count }, (_, i) => ({ + uri: URI.joinPath(uri, `file${i}.ts`), + originalUri: URI.joinPath(uri, `file${i}.ts`), + renameUri: undefined, + status: Status.MODIFIED, + })) as any; + }); + + vi.spyOn(gitDiffService, 'getWorkingTreeDiffsFromRef').mockImplementation(async (repositoryOrUri: any) => { + const uri: URI = URI.isUri(repositoryOrUri) ? repositoryOrUri : repositoryOrUri.rootUri; + const r = repos.find(x => x.rootUri.toString() === uri.toString()); + const count = r?.changeCount ?? 1; + return Array.from({ length: count }, (_, i) => ({ + uri: URI.joinPath(uri, `file${i}.ts`), + originalUri: URI.joinPath(uri, `file${i}.ts`), + renameUri: undefined, + status: Status.MODIFIED, + diff: 'test diff', + })) as any; + }); + } }); // ======================================== From 779e91440eb8d0bb2eacafebb98caf8db8fa3ae3 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:34:38 +0200 Subject: [PATCH 25/29] Refactor session header context menu for title editing (#320115) Refactor session header context menu and rename actions for improved session title editing --- extensions/copilot/package.json | 12 --------- src/vs/sessions/LAYOUT.md | 2 +- .../browser/parts/media/chatCompositeBar.css | 2 +- .../sessions/browser/parts/sessionHeader.ts | 14 +++++++---- src/vs/sessions/browser/parts/sessionView.ts | 4 +++ .../sessions/browser/sessionsActions.ts | 25 ++++++++++++++++++- .../browser/views/sessionsViewActions.ts | 5 ---- .../actions/common/menusExtensionPoint.ts | 7 ------ 8 files changed, 39 insertions(+), 32 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index a02798faa6e83..3d4116a3db53f 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6209,18 +6209,6 @@ "group": "1_edit@4" } ], - "chatSessions/header/context": [ - { - "command": "github.copilot.claude.sessions.rename", - "when": "chatSessionType == claude-code && chatSessionProviderId == default-copilot", - "group": "2_edit@4" - }, - { - "command": "github.copilot.cli.sessions.rename", - "when": "chatSessionType == copilotcli && chatSessionProviderId == default-copilot", - "group": "2_edit@4" - } - ], "chat/multiDiff/context": [ { "command": "github.copilot.cloud.sessions.installPRExtension", diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 47e25658d083f..85ebe17f92fe9 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -100,7 +100,7 @@ The Sessions Part (`SessionsPart` in [browser/parts/sessionsPart.ts](src/vs/sess A `SessionView` ([browser/parts/sessionView.ts](src/vs/sessions/browser/parts/sessionView.ts)) is a single leaf in the Sessions Part's internal grid. It hosts: -- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (workspace · branch · diff stats), and the session toolbars (Run, Open in VS Code, New Chat). Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The same menu id is exposed to extensions via the `chatSessions/header/context` contribution point, so providers (e.g. Copilot CLI, Claude Code) can add their own rename actions alongside the built-in entries. +- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (workspace · branch · diff stats), and the session toolbars (Run, Open in VS Code, New Chat). Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. - A **chat composite bar** below the header ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) — the chat tab strip, shown only when the session has more than one chat. A single chat is already represented by the header title. - A **chat view** below the bars, swapped in/out based on session state. - A floating toolbar overlay ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts), `SessionViewFloatingToolbar`) shown for not-yet-created sessions in place of the header. diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 6023b6556a5e9..ee6bf44f10fa0 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -68,7 +68,7 @@ } .chat-composite-bar-session-title { - flex: 1 1 auto; + flex: 0 1 auto; min-width: 0; overflow: hidden; display: flex; diff --git a/src/vs/sessions/browser/parts/sessionHeader.ts b/src/vs/sessions/browser/parts/sessionHeader.ts index a712cc0d30409..1b3d55e70022f 100644 --- a/src/vs/sessions/browser/parts/sessionHeader.ts +++ b/src/vs/sessions/browser/parts/sessionHeader.ts @@ -123,10 +123,7 @@ export class SessionHeader extends Disposable { // mousedown so that initiating a drag from the title doesn't also // flip into edit mode. this._register(addDisposableListener(this._titleEl, EventType.CLICK, () => { - if (!this._isTitleEditable() || this._renameInput) { - return; - } - this._startTitleEditing(); + this.startTitleEditing(); })); const titleActions = $('.chat-composite-bar-title-actions'); @@ -179,7 +176,7 @@ export class SessionHeader extends Disposable { menuId: Menus.SessionHeaderContext, menuActionOptions: { shouldForwardArgs: true, arg: session }, getAnchor: () => anchor, - contextKeyService: this._contextKeyService + contextKeyService: this._contextKeyService, }); })); } @@ -356,6 +353,13 @@ export class SessionHeader extends Disposable { return !!this._session && isAgentHostProviderId(this._session.providerId); } + startTitleEditing(): void { + if (!this._isTitleEditable() || this._renameInput) { + return; + } + this._startTitleEditing(); + } + /** * Replace the rendered title text with an `` containing the current * title (pre-selected). Enter commits via {@link ISessionsManagementService.renameChat}, diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index c19d53804f32a..60db0f330af20 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -219,6 +219,10 @@ export class SessionView extends Disposable implements ISerializableView { this._currentView.value?.focus(); } + startTitleEditing(): void { + this._header.startTitleEditing(); + } + selectWorkspace(folderUri: URI, providerId?: string): void { this._currentView.value?.selectWorkspace(folderUri, providerId); } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index b5fbc37ae4238..cd0995df55568 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -17,7 +17,8 @@ import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../. import { EditorAreaFocusContext, IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, MultipleSessionsVisibleContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, ChatSessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../browser/parts/sessionsPartService.js'; @@ -417,6 +418,28 @@ MenuRegistry.appendMenuItem(Menus.SessionHeaderContext, { when: SessionIsCreatedContext, }); +registerAction2(class RenameSessionHeaderAction extends Action2 { + constructor() { + super({ + id: 'sessions.sessionHeader.rename', + title: localize2('renameSessionHeader', "Rename..."), + menu: [{ + id: Menus.SessionHeaderContext, + group: '2_edit', + order: 1, + when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), + }], + }); + } + + override run(accessor: ServicesAccessor, session: IActiveSession | undefined): void { + if (!session) { + return; + } + accessor.get(ISessionsPartService).getSessionView(session.sessionId)?.startTitleEditing(); + } +}); + registerAction2(class CloseSessionAction extends Action2 { constructor() { super({ diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 42ccf54f32afc..65f2df83a57dd 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -656,11 +656,6 @@ registerAction2(class RenameSessionAction extends Action2 { group: '1_edit', order: 1, when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), - }, { - id: Menus.SessionHeaderContext, - group: '2_edit', - order: 1, - when: ContextKeyExpr.regex(ChatSessionProviderIdContext.key, ANY_AGENT_HOST_PROVIDER_RE), }] }); } diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index a5a7e3b93e095..430f538c5fd39 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -505,13 +505,6 @@ const apiMenus: IAPIMenu[] = [ supportsSubmenus: false, proposed: 'chatSessionsProvider' }, - { - key: 'chatSessions/header/context', - id: MenuId.SessionHeaderContext, - description: localize('menus.chatSessionsHeaderContext', "The context menu for the session header in the Sessions window."), - supportsSubmenus: false, - proposed: 'chatSessionsProvider' - }, { key: 'chatSessions/newSession', id: MenuId.AgentSessionsCreateSubMenu, From ae352b995359d052729a4556e659dfa0842fd5ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:36:00 -0700 Subject: [PATCH 26/29] chore(deps): bump hono from 4.12.18 to 4.12.23 in /extensions/copilot (#320007) Bumps [hono](https://github.com/honojs/hono) from 4.12.18 to 4.12.23. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.18...v4.12.23) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.23 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- extensions/copilot/package-lock.json | 66 ++-------------------------- 1 file changed, 3 insertions(+), 63 deletions(-) diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 52ce954705ad6..77d7f19815a40 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -2992,9 +2992,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -3011,9 +3008,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -3030,9 +3024,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -3049,9 +3040,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -3304,9 +3292,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3323,9 +3308,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3342,9 +3324,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3361,9 +3340,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3380,9 +3356,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3399,9 +3372,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3424,9 +3394,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3449,9 +3416,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3474,9 +3438,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3499,9 +3460,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5783,9 +5741,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5803,9 +5758,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5823,9 +5775,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5843,9 +5792,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5863,9 +5809,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -5883,9 +5826,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -11286,9 +11226,9 @@ } }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", "engines": { "node": ">=16.9.0" From e3c5afbc4a092a02f7192e243767bd7d0229e190 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Fri, 5 Jun 2026 17:51:36 +0200 Subject: [PATCH 27/29] fix: add overrideAuthType=token to CLI smoke tests and re-enable them (#320109) The Copilot CLI smoke tests were failing because overrideAuthType was not set, causing the CLI SDK to use HMAC auth (the default) against the mock LLM server. The mock server cannot validate HMAC signatures, so /models calls returned 401, model fetching failed, and prompt rendering threw "Unexpected generated prompt structure". Add the missing ['github.copilot.advanced.debug.overrideAuthType', '"token"'] setting to both chatSessions.test.ts and copilotCli.test.ts (matching what agentsWindow.test.ts already does), and remove the it.skip that was added as a temporary workaround. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/smoke/src/areas/chat/chatSessions.test.ts | 5 ++++- test/smoke/src/areas/chat/copilotCli.test.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/test/smoke/src/areas/chat/chatSessions.test.ts b/test/smoke/src/areas/chat/chatSessions.test.ts index 8c6030a529193..28236f82d5ba8 100644 --- a/test/smoke/src/areas/chat/chatSessions.test.ts +++ b/test/smoke/src/areas/chat/chatSessions.test.ts @@ -66,6 +66,9 @@ export function setup(logger: Logger) { await app.workbench.settingsEditor.addUserSettings([ ['github.copilot.advanced.debug.overrideProxyUrl', JSON.stringify(mockServer.url)], ['github.copilot.advanced.debug.overrideCapiUrl', JSON.stringify(mockServer.url)], + // Use token auth (not HMAC) so the CLI SDK can call /models and + // /models/session against the mock server without HMAC validation. + ['github.copilot.advanced.debug.overrideAuthType', '"token"'], ['chat.allowAnonymousAccess', 'true'], ['github.copilot.chat.githubMcpServer.enabled', 'false'], ['chat.mcp.discovery.enabled', 'false'], @@ -81,7 +84,7 @@ export function setup(logger: Logger) { await mockServer?.close(); }); - it.skip('Test Copilot CLI session', async function () { + it('Test Copilot CLI session', async function () { const app = this.app as Application; const requestsBefore = mockServer.requestCount(); diff --git a/test/smoke/src/areas/chat/copilotCli.test.ts b/test/smoke/src/areas/chat/copilotCli.test.ts index 89b41ff356ec5..4e02bf9fa0db5 100644 --- a/test/smoke/src/areas/chat/copilotCli.test.ts +++ b/test/smoke/src/areas/chat/copilotCli.test.ts @@ -52,6 +52,9 @@ export function setup(logger: Logger) { await app.workbench.settingsEditor.addUserSettings([ ['github.copilot.advanced.debug.overrideProxyUrl', JSON.stringify(mockServer.url)], ['github.copilot.advanced.debug.overrideCapiUrl', JSON.stringify(mockServer.url)], + // Use token auth (not HMAC) so the CLI SDK can call /models and + // /models/session against the mock server without HMAC validation. + ['github.copilot.advanced.debug.overrideAuthType', '"token"'], ['chat.allowAnonymousAccess', 'true'], ['github.copilot.chat.githubMcpServer.enabled', 'false'], ['chat.mcp.discovery.enabled', 'false'], @@ -63,7 +66,7 @@ export function setup(logger: Logger) { await mockServer?.close(); }); - it.skip('opens a Copilot CLI session and receives a response', async function () { + it('opens a Copilot CLI session and receives a response', async function () { const app = this.app as Application; const requestsBefore = mockServer.requestCount(); From 401bbd9f916e7ca6654d49b1511a1c088987740a Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 5 Jun 2026 09:22:10 -0700 Subject: [PATCH 28/29] agentHost: narrow Copilot resume fallback to true empty-session errors (#319456) * agentHost: narrow Copilot resume fallback to true empty-session errors The catch block in CopilotAgent._doResumeSession converted *any* -32603 JSON-RPC error from client.resumeSession into a fresh session created with the same id via client.createSession({ sessionId }). When the underlying session file fails schema validation (e.g. a session.compaction_complete event with batchSize: 0, rejected by @github/copilot's schema), this masked the corruption: the user saw an empty chat instead of an error, and the original session contents were not surfaced. Narrow the fallback so we only recreate an empty session when the error message clearly indicates 'no messages / empty session', and never when the message looks like corruption / validation / parse failure. All other -32603s now propagate so the UI and logs reflect the real failure. Add regression tests that exercise the real _doResumeSession path via a ResumePathCopilotAgent subclass: one confirming the empty-session fallback still works, one confirming a corrupted-session error is no longer swallowed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: preserve resume fallback for Start Over / truncated sessions The previous narrowing was too aggressive: it required the SDK error message to match a small whitelist ('no messages', 'empty session', ...) to trigger the fallback. But the legitimate empty-session case this fallback exists for is post-'Start Over' / post-truncateSession, where the SDK currently surfaces a generic 'no events' message that doesn't match any of those phrases. The previous version would have regressed Start Over (user truncates all turns, reopens session, gets a hard error instead of an empty chat). Invert the heuristic: treat any -32603 from resumeSession as the empty-session case (preserving Start Over and any future SDK rewording) UNLESS the message clearly indicates corruption / schema validation / parse failure / malformed those should propagate so the userinput sees the real error rather than silently getting an empty session. Refresh tests: add a Start Over / truncated-session test using a realistic 'returned no events' message, plus an 'unknown -32603' defensive test; keep the corruption test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * monaco: regenerate monaco.d.ts after folding markers change PR #312679 (folding markers pattern+flags syntax) removed a doc line from FoldingMarkers in languageConfiguration.ts but didn't regenerate src/vs/monaco.d.ts. Picking up the regeneration so the monaco-d.ts check passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/copilot/copilotAgent.ts | 50 ++++++++- .../agentHost/test/node/copilotAgent.test.ts | 100 +++++++++++++++++- 2 files changed, 143 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 69cc370dcc00e..f46f8ea8050e2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -58,6 +58,50 @@ interface ICreatedWorktree { readonly worktree: URI; } +function getCopilotSdkErrorCode(err: unknown): number | undefined { + if (typeof err !== 'object' || err === null) { + return undefined; + } + const code = Object.getOwnPropertyDescriptor(err, 'code')?.value; + return typeof code === 'number' ? code : undefined; +} + +function getErrorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + if (typeof err === 'object' && err !== null) { + const message = Object.getOwnPropertyDescriptor(err, 'message')?.value; + if (typeof message === 'string') { + return message; + } + } + return String(err); +} + +/** + * Decide whether a Copilot SDK `resumeSession` failure should fall back to + * `createSession({ sessionId })`. We want to preserve the original + * recovery for empty / truncated sessions (e.g. after the user invoked + * "Start Over", which calls `truncateSession` and leaves the on-disk + * session with zero events — the SDK then refuses to resume it), but we + * must NOT silently swallow corruption / schema-validation / parse + * failures: those should surface so the user sees the real error and the + * original session contents are not masked by a fresh empty session. + * + * Heuristic: any `-32603` Internal Error is treated as the empty-session + * case UNLESS the message clearly indicates corruption, schema + * validation, parse failure, or malformed input. + */ +function shouldCreateEmptySessionAfterResumeError(err: unknown): boolean { + if (getCopilotSdkErrorCode(err) !== -32603) { + return false; + } + + const message = getErrorMessage(err); + return !/\b(corrupt|corrupted|invalid|validation|schema|must be|parse|malformed|unexpected token)\b/i.test(message); +} + export type ICopilotPluginInfo = IParsedPlugin & { readonly pluginDir?: URI }; /** @@ -1662,13 +1706,13 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info(`[Copilot:${sessionId}] SDK resumeSession succeeded`); return new CopilotSessionWrapper(raw); } catch (err) { - const errCode = (err as { code?: number })?.code; - const errMsg = err instanceof Error ? err.message : String(err); + const errCode = getCopilotSdkErrorCode(err); + const errMsg = getErrorMessage(err); this._logService.warn(`[Copilot:${sessionId}] SDK resumeSession failed: code=${errCode}, message=${errMsg}`); // The SDK fails to resume sessions that have no messages. // Fall back to creating a new session with the same ID, // seeding model & working directory from stored metadata. - if (!err || errCode !== -32603) { + if (!shouldCreateEmptySessionAfterResumeError(err)) { throw err; } diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 93b6c7e786c03..2c3d57649e10c 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -28,7 +28,7 @@ import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; +import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { buildSubagentSessionUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; @@ -240,6 +240,12 @@ class MockCopilotSession { async disconnect(): Promise { } } +class TestSdkError extends Error { + constructor(message: string, readonly code: number) { + super(message); + } +} + class MockAgentHostOTelService implements IAgentHostOTelService { readonly _serviceBrand: undefined; @@ -254,6 +260,27 @@ class MockAgentHostOTelService implements IAgentHostOTelService { } } +class ResumePathCopilotAgent extends CopilotAgent { + constructor( + private readonly _copilotClient: ITestCopilotClient, + @ILogService logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService, + @IFileService fileService: IFileService, + @ISessionDataService sessionDataService: ISessionDataService, + @IAgentHostGitService gitService: IAgentHostGitService, + @IAgentHostTerminalManager terminalManager: IAgentHostTerminalManager, + @IAgentConfigurationService configurationService: IAgentConfigurationService, + @IAgentHostCompletions completions: IAgentHostCompletions, + ) { + super(logService, instantiationService, fileService, sessionDataService, gitService, terminalManager, configurationService, new MockAgentHostOTelService(), completions, NULL_CHECKPOINT_SERVICE); + this._enablePlanModeOnClient(this._copilotClient as CopilotClient); + } + + protected override _createCopilotClient(): CopilotClient { + return this._copilotClient as CopilotClient; + } +} + class TestableCopilotAgent extends CopilotAgent { private readonly _fakeSessions = new Map(); readonly resumeCalls: string[] = []; @@ -319,7 +346,7 @@ class TestableCopilotAgent extends CopilotAgent { } } -function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; fileService: FileService } { +function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; fileService: FileService } { const services = new ServiceCollection(); const logService = new NullLogService(); const fileService = options?.fileService ?? disposables.add(new FileService(logService)); @@ -350,12 +377,12 @@ function createTestAgentContext(disposables: Pick, optio const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); services.set(IInstantiationService, instantiationService); const agent = options?.copilotClient - ? instantiationService.createInstance(TestableCopilotAgent, options.copilotClient) + ? instantiationService.createInstance(options.useRealResumePath ? ResumePathCopilotAgent : TestableCopilotAgent, options.copilotClient) : instantiationService.createInstance(CopilotAgent); return { agent, instantiationService, configurationService: configService, fileService }; } -function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager }): CopilotAgent { +function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager }): CopilotAgent { return createTestAgentContext(disposables, options).agent; } @@ -1281,6 +1308,71 @@ suite('CopilotAgent', () => { }); }); + suite('_resumeSession fallback', () => { + type AgentInternals = { + _resumeSession: (id: string) => Promise; + }; + + function createResumeFailingClient(message: string, code = -32603): { readonly client: TestCopilotClient; readonly getCreateSessionCalls: () => number } { + let createSessionCalls = 0; + const client = new TestCopilotClient([sdkSession('s1', '/workspace')]); + client.resumeSession = async () => { + throw new TestSdkError(message, code); + }; + client.createSession = async () => { + createSessionCalls++; + return new MockCopilotSession() as unknown as CopilotSession; + }; + return { client, getCreateSessionCalls: () => createSessionCalls }; + } + + test('falls back to createSession after a Start Over truncate leaves the session empty', async () => { + // Simulates the post-`truncateSession`/"Start Over" case: the on-disk + // session has zero events, so the SDK's resumeSession refuses to + // resume it. The exact wording varies across SDK versions, so we + // assert on the general -32603 + "no events" shape. + const { client, getCreateSessionCalls } = createResumeFailingClient(`Request session.resume failed with message: LocalRpcSession: 'session.getMessages' returned no events for session s1`); + const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService: disposables.add(new TestSessionDataService()) }); + const internals = agent as unknown as AgentInternals; + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await internals._resumeSession('s1'); + assert.strictEqual(getCreateSessionCalls(), 1); + } finally { + await disposeAgent(agent); + } + }); + + test('falls back to createSession for an unknown -32603 from resumeSession', async () => { + // Defensive: if the SDK starts emitting some other generic + // "cannot resume this session" message, we should still recover + // rather than leaving the user with an unopenable session. + const { client, getCreateSessionCalls } = createResumeFailingClient('Request session.resume failed: something went wrong'); + const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService: disposables.add(new TestSessionDataService()) }); + const internals = agent as unknown as AgentInternals; + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await internals._resumeSession('s1'); + assert.strictEqual(getCreateSessionCalls(), 1); + } finally { + await disposeAgent(agent); + } + }); + + test('does not replace a corrupted session file with an empty session', async () => { + const { client, getCreateSessionCalls } = createResumeFailingClient('Request session.resume failed with message: Session file is corrupted (line 19567: data.compactionTokensUsed.copilotUsage.tokenDetails.0.batchSize: Number must be greater than 0)'); + const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService: disposables.add(new TestSessionDataService()) }); + const internals = agent as unknown as AgentInternals; + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await assert.rejects(() => internals._resumeSession('s1'), /Session file is corrupted/); + assert.strictEqual(getCreateSessionCalls(), 0); + } finally { + await disposeAgent(agent); + } + }); + }); + suite('worktree announcement', () => { // Drives a real session through worktree creation (calling the // agent's _resolveSessionWorkingDirectory via a test seam so we don't From 816978a6437ccb701a349e86b6f851f5f40ad88c Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Fri, 5 Jun 2026 18:22:49 +0200 Subject: [PATCH 29/29] Increase timeouts for the smoke tests (#320130) * fix: add overrideAuthType=token to CLI smoke tests and re-enable them The Copilot CLI smoke tests were failing because overrideAuthType was not set, causing the CLI SDK to use HMAC auth (the default) against the mock LLM server. The mock server cannot validate HMAC signatures, so /models calls returned 401, model fetching failed, and prompt rendering threw "Unexpected generated prompt structure". Add the missing ['github.copilot.advanced.debug.overrideAuthType', '"token"'] setting to both chatSessions.test.ts and copilotCli.test.ts (matching what agentsWindow.test.ts already does), and remove the it.skip that was added as a temporary workaround. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Increase timeouts (reason for failure on Linux) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/smoke/extensions/vscode-smoketest-ext-host/extension.js | 4 ++-- test/smoke/src/areas/chat/chatSessions.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/smoke/extensions/vscode-smoketest-ext-host/extension.js b/test/smoke/extensions/vscode-smoketest-ext-host/extension.js index 67fa974bf5590..fa0a5837867ee 100644 --- a/test/smoke/extensions/vscode-smoketest-ext-host/extension.js +++ b/test/smoke/extensions/vscode-smoketest-ext-host/extension.js @@ -97,7 +97,7 @@ function activate(context) { await vscode.workspace.getConfiguration('chat').update('disableAIFeatures', false, vscode.ConfigurationTarget.Global); await vscode.workspace.getConfiguration('github.copilot.chat').update('backgroundAgent.enabled', true, vscode.ConfigurationTarget.Global); await vscode.commands.executeCommand('github.copilot.debug.extensionState'); - await waitForCommand(command, 30_000); + await waitForCommand(command, 60_000); await vscode.commands.executeCommand('workbench.action.closeAllEditors'); await vscode.commands.executeCommand(command); }) @@ -109,7 +109,7 @@ function activate(context) { await vscode.workspace.getConfiguration('chat').update('disableAIFeatures', false, vscode.ConfigurationTarget.Global); await vscode.workspace.getConfiguration('github.copilot.chat').update('claudeAgent.enabled', true, vscode.ConfigurationTarget.Global); await vscode.commands.executeCommand('github.copilot.debug.extensionState'); - await waitForCommand(command, 30_000); + await waitForCommand(command, 60_000); await vscode.commands.executeCommand('workbench.action.closeAllEditors'); await vscode.commands.executeCommand(command); }) diff --git a/test/smoke/src/areas/chat/chatSessions.test.ts b/test/smoke/src/areas/chat/chatSessions.test.ts index 28236f82d5ba8..c2ce1b1fb9745 100644 --- a/test/smoke/src/areas/chat/chatSessions.test.ts +++ b/test/smoke/src/areas/chat/chatSessions.test.ts @@ -24,7 +24,7 @@ const LOCAL_REPLY = 'MOCKED_CHAT_SESSIONS_LOCAL_RESPONSE'; export function setup(logger: Logger) { describe('Chat Sessions', function () { - this.timeout(3 * 60 * 1000); + this.timeout(5 * 60 * 1000); this.retries(0); let mockServer: MockLlmServer;