mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-09 08:15:05 +01:00
Merge branch 'main' into agents/unified-model-picker-implementation-69c82b16
This commit is contained in:
@@ -9,6 +9,11 @@
|
||||
"version": "1.5.0",
|
||||
"resolved": "ghcr.io/devcontainers/features/rust@sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c",
|
||||
"integrity": "sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/sshd:1": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "ghcr.io/devcontainers/features/sshd@sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee",
|
||||
"integrity": "sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/desktop-lite:": {},
|
||||
"ghcr.io/devcontainers/features/rust:": {}
|
||||
"ghcr.io/devcontainers/features/rust:": {},
|
||||
"ghcr.io/devcontainers/features/sshd:1": {}
|
||||
},
|
||||
"containerEnv": {
|
||||
"DISPLAY": "" // Allow the Dev Containers extension to set DISPLAY, post-create.sh will add it back in ~/.bashrc and ~/.zshrc if not set.
|
||||
|
||||
@@ -33,6 +33,7 @@ Then read the relevant spec for the area you are changing (see table below). If
|
||||
- **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.
|
||||
- **Grid `onDidChange` is not a sash-drag signal**: the workbench `SerializableGrid`/`GridView` `onDidChange` fires for size changes and view add/remove, but not internal splitview sash drags. If logic must react to a part node being resized by a sash, route it through that part's `layout(width, ...)` callback, which receives the in-progress node width.
|
||||
- **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.
|
||||
- **Provider-owned model/mode selection belongs in the loaded chat model, with draft persistence driven by debounce**: For AHP-backed chats, `setModel` / `setAgent` must push the selection into the loaded `IChatModel.inputModel` (like `_updateChatSessionState`) and let the draft-sync debounce emit `chat/draftChanged`. Do not immediately dispatch a model/agent-only draft from the provider, because it can overwrite unsaved typed text before the debounced full input-state draft is persisted.
|
||||
- **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 arbitrarily late, which would lock in the wrong fallback. Let the upgrade listener live for the consumer's lifetime instead.
|
||||
@@ -75,6 +76,30 @@ Whenever the user flags a wrong pattern, rejects an approach, or gives design/ru
|
||||
|
||||
- **`ISession.capabilities` must be observable, not a live plain getter**: capabilities can hydrate/change after a session first surfaces (e.g. an agent host whose root state arrives after the session's first `SessionState`). A plain getter cannot be tracked by the context-key autorun (`setActiveSessionContextKeys` reads it inside an `autorun`), so `supportsMultipleChats`/`sessionSupportsFork` would stay stale, and a multi-chat catalog processed while `supportsMultipleChats` was still `false` would stay collapsed to `[defaultChat]`. Expose `capabilities` as `IObservable<ISessionCapabilities>` (agent host derives it from `connection.rootState` via `observableFromEvent` + `derivedOpts` with `structuralEquals`; static providers use `constObservable`), have consumers read `.read(reader)`/`.get()`, and re-apply the chat catalog from the last `SessionState` in an `autorun` on capability change. Do **not** fix this by firing `_onDidChangeSessions` — the active-session context autorun tracks the session's own observables, not the provider's session-list event.
|
||||
|
||||
- **A managed/default editor tab must be re-ensured every sync, not opened once**: the per-session editor working-set restore (`baseSessionLayoutController` [B2] `_applyWorkingSet`) runs on session activation and is **not** docked-gated, so it reinstates the session's *saved* editor set and will close any tab not in it (e.g. a set persisted before a new tab existed). A controller that opens a default tab **once** (guarded by a `Set` of initialized sessions) therefore loses it permanently after the restore. Ensure managed tabs (the pinned Changes tab, the default File tab) **idempotently on every sync** (`group.editors.some(e => e instanceof X)` → open if absent), exactly like the Changes tab — never with an open-once `Set`. Also: `IEditorService.openEditor(input, group)` on a typed `EditorInput` binds `group` to the `options` param (overload is `(editor, options?, group?)`); pass `openEditor(input, undefined, group)`.
|
||||
|
||||
- **A "keep the editor closed" rule must not react to the editor's visibility transition**: the single-pane new-session rule (R1, `_registerSinglePaneNewSessionRules`) must drive its hide off the **session + active editor**, never off `onDidChangePartVisibility`/an `editorVisibleObs`. `onWillOpenEditor` reveals the editor *before* the opened file becomes the active editor, and toggling the detail panel off reveals the empty editor via `setAuxiliaryBarHidden` — both fire the visibility event synchronously with the active editor still stale (managed empty tab). A rule that re-runs on that transition re-hides the editor the user just asked to show (file never appears; the whole side pane vanishes on detail-toggle). Hide only when the active editor is non-real content, read the current visibility **untracked** when deciding to hide, and block spurious width-based reveals at the source with `setSuppressDockedEditorRevealSync(true)` rather than a visibility backstop. **Refinement:** dropping the visibility trigger entirely loses the backstop for *automatic* post-activation reveals (working-set restore, an editor left visible across a session switch), so the editor can appear in a fresh new-session view. Keep the visibility trigger but gate the hide on an explicit-reveal flag: the workbench records `_editorRevealedExplicitly` (set true only on `onWillOpenEditor` and the detail-toggle reveal, cleared on any hide and on the suppress false->true transition so a stale cross-session flag can't leak) and exposes `isEditorRevealedExplicitly()`; R1 re-hides only when the editor is visible **and** the reveal was not explicit.
|
||||
|
||||
- **When the docked editor is hidden while the detail stays visible, clear the sidebar-grow snapshots**: hiding the editor resizes the docked node down to the detail width (`_dockedAuxiliaryBarWidth`). Any `_editorSizeGrownForSidebarHide`/`_detailWidthGrownForSidebarHide` snapshot captured earlier (while the editor was visible and the sessions list was hidden) is now stale — restoring it when the sessions list is later shown re-inflates the node so the detail fills the whole side pane instead of the chat reclaiming the freed editor width. `setEditorHidden(true)` must drop both snapshots in the detail-still-visible branch.
|
||||
|
||||
- **Overriding a workbench toolbar hover needs matching specificity**: the core rule `.monaco-workbench .monaco-action-bar:not(.vertical) .action-label:not(.disabled):hover` (and the `:hover` outline rule) sets the toolbar hover background/outline at ~6-7 class specificity. A static (non-interactive) action-item label (e.g. the single-pane diff-stats label) that should show no hover affordance must override with an equal-or-higher-specificity selector (prefix `.monaco-workbench ... .monaco-action-bar:not(.vertical) .action-item.<class> .action-label:hover`), not a short `.<class> .action-label:hover` that loses the cascade.
|
||||
|
||||
- **A managed placeholder editor must never reveal the docked editor content**: the empty Files placeholder tab (`EmptyFileEditorInput`) activates as a *side effect* of closing another tab (e.g. the Changes tab), firing `onWillOpenEditor` before it is in the group model. The workbench `onWillOpenEditor` reveal handler must skip revealing for that placeholder (check `e.editor.typeId === 'workbench.editors.agentSessions.emptyFile'` — a literal, since `src/vs/sessions/browser/*` is core and must not import the contrib `EmptyFileEditorInput`). Do NOT use a broad "editor already in group -> skip" rule: that also stops a hidden editor from revealing when the user re-opens an already-open real file. Extract the handler into a named method (`_handleWillOpenEditor`) so it is unit-testable via `Reflect.get(Workbench.prototype, ...)`.
|
||||
|
||||
- **Gate single-pane editor-title actions on `MainEditorAreaVisibleContext`**: every single-pane (`config.<DOCK_DETAIL_PANEL_SETTING>`-gated) editor-title menu item (Maximize/Restore, Toggle Details, Hide Editor, Open in Modal) must include `MainEditorAreaVisibleContext` in its `when` so the whole action set disappears when the editor content is closed — the docked tab bar stays but its right-hand actions do not. The docked reveal-sync (`_syncDockedEditorVisibility`) must be *symmetric*: it reveals when the node widens past the detail width and **hides** (sets `partVisibility.editor=false`, flips `MainEditorAreaVisibleContext`) when a sash drag squeezes the editor content back down to the detail width — same guards (`_syncingDockedEditorVisibility`, `_suppressDockedEditorRevealSync`, `_dockDetailPanel`, and only while the detail is visible).
|
||||
|
||||
- **The managed Files placeholder tab is conditional, not always-on**: `ChangesTabController` shows the empty Files tab only when the editor area is closed OR no real (non-managed) editor is open; once a real file/diff is open in a visible editor area it removes the placeholder (and re-adds it when the area closes). Drive this off an `autorun` that also reads the editor-area visibility (`observableFromEvent(onDidChangePartVisibility, () => isVisible(EDITOR_PART, mainWindow))`) and an editor-change signal (`observableSignalFromEvent(this, Event.any(onDidActiveEditorChange, onDidEditorsChange))`), and keep the ensure/remove idempotent so it settles instead of looping. Close/open the placeholder under `suppressEditorPartAutoVisibility()`.
|
||||
|
||||
- **A per-session aux-bar (detail) capture must be skipped during a session-switch restore**: the D2 `onDidChangePartVisibility` listener that records a created session's detail visibility must bail while `_isRestoringSessionLayout` is true. During restore an external component (e.g. `DetailPanelController`) can transiently reveal the aux bar for the incoming active editor; capturing that overwrites the session's saved detail-hidden state, so switching back shows the detail even though the user had closed it. Keep the aux-bar sync work synchronous (fire-and-forget the view-open calls) so the restore epoch ends promptly and legitimate post-restore user toggles are still captured.
|
||||
|
||||
- **The detail-panel forced reveal must be gated on the editor content being visible**: `DetailPanelController._syncForcedTarget` reveals the docked detail (aux bar) when a Changes/File editor becomes active while the detail is hidden. That reveal must additionally require `isVisible(EDITOR_PART, mainWindow)` — otherwise, on a window reload where the user had closed the **whole** side pane (editor + detail both hidden, persisted), the restored managed tab becoming active re-reveals the detail and the closed state is lost. Reveal the detail only to accompany a *visible* editor; when the whole pane is intentionally closed, leave it closed.
|
||||
|
||||
- **Auto-managed tabs must still be user-closable — remember user dismissals instead of blindly re-ensuring**: `ChangesTabController` re-ensures the managed Changes/Files tabs on many signals (session state, editor visibility, editor changes). Without care this re-creates a tab the instant the user closes it, so the tab feels un-closable (the managed tabs are non-preview `pinEditor`, NOT sticky — they *do* have close buttons; the blocker is the re-ensure, not a missing button). Track user-initiated closes in a `_dismissedManagedTabs` set (via `onDidCloseEditor`, ignoring the controller's own closes tracked in an `_internallyClosingEditors` set) and skip ensuring a dismissed kind. Clear the set only on a **session change** or when the **side pane is reopened from fully closed** (edge-detected via a stored `_sidePaneWasVisible`), so closes stick within the session while reopening/switching re-populates (Scenario B).
|
||||
|
||||
- **D10 (empty aux-bar cleanup) must gate on quick-chat, not the racy container-active check, or it flickers the side pane closed on reload**: the Agents-window Changes/Files aux-bar views gate on `SessionHasWorkspaceContext` + `WorkspaceFolderCountContext`, which are set **asynchronously** (via the `setActiveSessionContextKeys` autorun reading the session's async `workspace`) after a session activates/reloads. So right after D3b/DetailPanelController/a manual toggle reveals the aux bar, `isViewContainerActive(Files/Changes)` is transiently `false` (context keys not settled) even for a real workspace session. The D10 reconcile (`_syncAuxiliaryBarPartVisibility`, which runs synchronously on the `onDidChangePartVisibility(visible)` signal and only ever hides) then closes the just-opened side pane, and since it never re-reveals, it stays closed — the reload "side pane opens then closes" flicker, "Files not shown when opening the side pane", and "new-session side-pane state not remembered". Fix: D10 hides only when the aux is **genuinely** empty for the active session's lifetime — no active session, or a **workspace-less quick chat** (`activeSession.isQuickChat?.get() === true`, its Changes+Files permanently gated off) — never for a workspace-backed session whose gating context keys are merely still settling. Do NOT use the transient `_hasActiveAuxViewContainers()` result to hide a workspace session's aux.
|
||||
|
||||
- **`DetailPanelController` must not hide the detail on an empty editor group in the new-session view**: `_computeTarget` returns `Hidden` when the main editor part is empty (a created session's all-tabs-closed → whole side pane closed). But the new-session (uncreated) view's editor group is *transiently* empty while its Files tab is (re)ensured, and its Files detail is open by default and owned by the layout controller's D3b. Gating the empty-group `Hidden` on `activeSession.isCreated` avoids a transient hide that the D2 visibility listener would otherwise capture as the new-session preference — making every subsequent `cmd+n` open with the side pane hidden. Combined with the editor-visibility reveal gate, the new-session default stays open while a user's explicit hide is still remembered (D3b `_newSessionViewState`).
|
||||
|
||||
## Validating Changes
|
||||
|
||||
You **must** run these checks before declaring work complete:
|
||||
@@ -82,3 +107,20 @@ You **must** run these checks before declaring work complete:
|
||||
1. `npm run typecheck-client` — TypeScript compilation check. **Do not run `tsc` directly.**
|
||||
2. `npm run valid-layers-check` — **MANDATORY.** Catches layering violations. If this fails, fix the imports before proceeding.
|
||||
3. `scripts/test.sh --grep <pattern>` — unit tests for affected areas
|
||||
|
||||
- **Managed-tab reconciliation must run entirely under `suppressEditorPartAutoVisibility()`**: `ChangesTabController._syncChangesEditor` closes stale managed tabs (e.g. a restored Changes tab whose session's workspace hasn't resolved yet on reload) before ensuring the current ones. If any close (esp. `_closeInactiveChangesEditors`) runs **unsuppressed** and empties the group, the workbench `handleDidCloseEditor` docked branch treats it as "user closed all tabs" and closes the whole side pane — the reload flicker where the side pane appears then vanishes. Wrap the full reconciliation body in one suppression window so transient empty states are never mistaken for a user action; don't rely on per-open suppressions alone. Relatedly, the managed Files placeholder tab must NOT be auto-removed based on editor-area visibility (old Scenario 9 auto-remove) — that removal also emptied the group on reload; only remove it on an explicit user close (tracked via `_dismissedManagedTabs`).
|
||||
|
||||
- **Layout-driven editor closes (working-set apply) must not be mistaken for user closes**: On any single-pane session switch (incl. Cmd+N to a new untitled session and reload), the base controller applies the target session's editor working set — an **empty** working set closes the managed Changes/Files tabs *externally*. Two bugs resulted: (1) the workbench `handleDidCloseEditor` docked branch saw an empty group and closed the whole side pane (reload/Cmd+N flicker: pane/Files-tab appears then vanishes); (2) `ChangesTabController._handleEditorClosed` recorded the external close as a user dismissal, poisoning `_dismissedManagedTabs` so the Files tab toggled on/off on alternate Cmd+N presses. Fix: `_withSessionLayoutRestore` (base controller) holds `suppressEditorPartAutoVisibility()` for the whole (async) restore **only when `isSinglePaneLayoutEnabled`** (OFF layout unchanged), so layout-driven closes never reach `handleDidCloseEditor`; and `_handleEditorClosed` ignores closes while `layoutService.isEditorPartAutoVisibilitySuppressed()` is true, so only a genuine user close dismisses a managed tab. Added `isEditorPartAutoVisibilitySuppressed()` to `IAgentWorkbenchLayoutService` as the shared "this close is layout-driven, not a user action" signal.
|
||||
|
||||
- **DetailPanelController must not force-reveal the detail during a layout-driven restore**: `_syncForcedTarget` reveals the aux-bar detail to accompany the active Changes/Files editor. On a session switch the target session's editor working set is restored, making its Changes/Files editor active — an editor change that is NOT a user open. If the user had hidden the detail for that session, that restore-driven editor change would force-reveal it, losing the per-session detail-hidden state. Gate the reveal (`setPartHidden(false, AUXILIARYBAR)`) on `!isEditorPartAutoVisibilitySuppressed()` (in addition to the existing editor-visible guard): during `_withSessionLayoutRestore` the base controller holds `suppressEditorPartAutoVisibility()` (single-pane), so a restore-driven forced target skips the reveal while a genuine user editor open (unsuppressed) still reveals. Note the existing `[D3c/single-pane]` test passed because it simulated the reveal *synchronously during* apply (so D3 re-hid last); the real bug is the *async* DetailPanelController reveal firing on the editor-change after D3 already hid.
|
||||
|
||||
- **Single-pane detail/tab behaviour lives ON the layout controller, not in separate controllers or a shared service**: `SinglePaneDesktopSessionLayoutController` owns both the managed docked tabs (pinned Changes multi-diff + empty Files placeholder) and the detail-panel mapping (active editor → Changes/Files container, aux-bar reveal/hide). They were previously `ChangesTabController`/`DetailPanelController` (registered by a `SinglePaneModeController` contribution) coordinating via global `IAgentWorkbenchLayoutService` flags, then briefly via an `ISessionLayoutCoordinatorService`. Both were removed: because everything is now one class, "is a session-switch restore in progress?" is just the base protected getter `this._isRestoringSessionLayout` (set by `_withSessionLayoutRestore`), so a restore-driven editor change never force-reveals the detail or dismisses a managed tab. The two sub-behaviours register in the `_registerAuxiliaryControllers()` base hook, deferred to `LifecyclePhase.Restored` so managed tabs reconcile on top of the restored editor group. The base controller gained `IChangesViewService` + `IContextKeyService` deps and a protected `_editorGroupsService` to support this (a subclass can't add DI ctor params without redeclaring all base params, so the shared services live on the base). Tests: the layout harness got `activeGroupEditors`/`closeSuppressionFlags`, a real `mainPart.activeGroup`, an `activateAux` opt-in that resolves the lifecycle, and a `TestSinglePaneController.runWithRestore(...)` seam to hold `_isRestoringSessionLayout` across an async editor change; `changesTabController.test.ts` was deleted and its scenarios moved into `desktopSessionLayoutController.test.ts`.
|
||||
|
||||
- **Single-pane created-session default is Editor-only (Changes editor, detail closed) — the detail is not force-opened by editor activation**: a Changes/file editor becoming active must NOT auto-reveal the docked detail (aux bar). `SinglePaneDesktopSessionLayoutController._syncForcedDetailTarget` reveals a hidden detail ONLY when it was transiently hidden by a browser tab (`_hiddenByBrowser`), never when it is hidden by the per-session default or an explicit user hide; when the detail is visible it still switches the container (Changes/Files) to match the active editor. The reopen default is layout-aware via the base `_defaultReopenSidePaneParts()` hook (base returns both parts; single-pane returns `{editor:true, auxiliaryBar:false}` for a created session and `{editor:false, auxiliaryBar:true}` for a new-session view). The detail is opened only via Toggle Details or restored per-session state. When changing this, update the `[single-pane] ... file tab activation` / `[per-session detail]` / reopen-default tests together — they encode the reveal semantics.
|
||||
- **Editor-title actions that only make sense with a restorable editor must also gate on `EditorMaximizedContext.negate()`**: the single-pane "Hide Editor" action is meaningless while the editor area is maximized, so its `when` includes `EditorMaximizedContext.negate()` (in addition to `MainEditorAreaVisibleContext` + `SinglePaneDetailChangesOrFilesActiveContext`).
|
||||
|
||||
- **R1 (new-session editor hide) must be transition-triggered, not level-triggered on the active editor**: hiding the editor in the new-session view must fire only when the editor **just became visible** (visibility false→true) or when the view was **just entered** with the editor already visible (inherited-visible editor) — never merely because the active editor changed to a managed placeholder while the editor is already visible. A level-triggered rule ("hide whenever active editor is non-real content and editor visible") wrongly hides the editor when the user switches to the Files tab with a file already open (the reveal-sync suppression re-arm clears `isEditorRevealedExplicitly`, so the level rule then hides). Track `previousEditorVisible` + `previousInNewSessionView` in the autorun and hide only on `(editorJustRevealed || justEnteredNewSessionView) && !isEditorRevealedExplicitly()`. The two workbench methods `setSuppressDockedEditorRevealSync` (blocks width-based reveals at the source, avoiding flicker) and `isEditorRevealedExplicitly` (distinguishes an explicit toggle-details-off/file-open reveal that must stick) are still required by R1 — they are independent of the ChangesTab/DetailPanel controller merge.
|
||||
|
||||
- **Single-pane created sessions need the docked editor part revealed on switch — the `isModal` gate in `_applyWorkingSet` skips it**: `baseSessionLayoutController._applyWorkingSet` only reveals the editor part when `!isModal` (i.e. `workbench.editor.useModal !== 'all'`), because in the classic layout editors open in a modal part. But in single-pane the docked editor lives in the **grid** even when `useModal` is `'all'` (the default), so that gate wrongly skips the reveal and a created session's side pane looks fully closed (worse once the Changes editor no longer force-reveals the detail). Fix: compute `revealEditorPart = !editorPartHidden && !isInitialRestore && (isSinglePaneLayoutEnabled ? isCreatedSession : !isModal)` and also reveal for the `'empty'` working-set case in single-pane (a first-visit created session has no saved editors but still shows its managed Changes editor). This restores the Editor-only default while respecting the per-session `editorPartHidden` (Detail-only / side-pane-closed) state and excluding new-session views (R1 keeps their editor closed). Note the layout test harness leaves `isSinglePaneLayoutEnabled` falsy by default, so base single-pane branches are inert in tests unless a test opts in via the `singlePaneLayoutEnabled` create option.
|
||||
|
||||
- **R1 can drop `setSuppressDockedEditorRevealSync` — always hide on new-session-view entry instead**: the width-based reveal-sync suppression (`setSuppressDockedEditorRevealSync`/`_suppressDockedEditorRevealSync`) was removed. It did two jobs: (1) block a momentary width-reveal of the editor in the new-session view, and (2) clear `_editorRevealedExplicitly` on entering the view so R1 re-hides an inherited-explicit editor across a session switch (the working-set apply runs under `suppressEditorPartAutoVisibility`, so `handleDidCloseEditor` doesn't clear the flag naturally). Job (1) is now handled by R1 re-hiding any non-explicit reveal (a sash-drag reveal flickers then re-hides — acceptable). Job (2) is handled by making R1's hide condition `justEnteredNewSessionView || (editorJustRevealed && !isEditorRevealedExplicitly())` — i.e. **entering the new-session view always resets to editor-closed** (a stale cross-session explicit flag can't keep the editor open), while the explicit flag is only honored for *in-session* reveals (toggle-details-off revealing the empty editor). `isEditorRevealedExplicitly` is still needed for that in-session case.
|
||||
|
||||
@@ -71,9 +71,6 @@ jobs:
|
||||
- name: Copy codicons
|
||||
run: cp node_modules/@vscode/codicons/dist/codicon.ttf src/vs/base/browser/ui/codicons/codicon/codicon.ttf
|
||||
|
||||
- name: Transpile source
|
||||
run: npm run transpile-client
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install chromium
|
||||
|
||||
|
||||
@@ -135,6 +135,10 @@ jobs:
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||
GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }}
|
||||
|
||||
- name: Verify native optional dependency binaries
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: node build/azure-pipelines/common/checkNativeOptionalDeps.ts
|
||||
|
||||
- name: Save node_modules cache
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/save-node-modules
|
||||
@@ -190,6 +194,10 @@ jobs:
|
||||
# on macOS.
|
||||
GYP_DEFINES: "kerberos_use_rtld=false"
|
||||
|
||||
- name: Verify native optional dependency binaries
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: node build/azure-pipelines/common/checkNativeOptionalDeps.ts
|
||||
|
||||
- name: Save node_modules cache
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/save-node-modules
|
||||
@@ -245,6 +253,10 @@ jobs:
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||
GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }}
|
||||
|
||||
- name: Verify native optional dependency binaries
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: node build/azure-pipelines/common/checkNativeOptionalDeps.ts
|
||||
|
||||
- name: Save node_modules cache
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
uses: ./.github/actions/save-node-modules
|
||||
|
||||
+36
-36
@@ -6,26 +6,26 @@
|
||||
"": {
|
||||
"name": "agent-sdk-claude",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.187"
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.198"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.187.tgz",
|
||||
"integrity": "sha512-HHkuC3he/Wi8fX/WjfS8mJr4TFPGoAd1QyxOuTwjnyOLboGkX1H+UhBYLxHX1ouFvHnYVJglB2wsuWemVQgahQ==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.198.tgz",
|
||||
"integrity": "sha512-xt469sSCyclTPtzpLAg0Aschy665GiRMgZKabSmESbGUA5/H56HcILVOiFxclXswkeMUk2fQxfHJUY9UZfiTnA==",
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.187"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.198"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -34,9 +34,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.187.tgz",
|
||||
"integrity": "sha512-0DNzATzaRmjS/Q1T4T9SLP/VT67gRX7J4reYPnEdcTm91lJwjqOPkHr17+sv+7DoerZ421ZG38dPsQd/V67qQw==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.198.tgz",
|
||||
"integrity": "sha512-ZmiAybQKIKcP1qEAE/vfXvfxtKxG9CnJn98QTXC5Zxiwuy7Mllx2ALXh9dfmsf0V87CGEodlZQmMgUJotNIsUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -47,9 +47,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.187.tgz",
|
||||
"integrity": "sha512-Va3O8lTDa9IHq/DbSJwU63JJknNV3YCBh4PEznOHXJtyFoXHgRjrks4QQvN3baZm79avVq2sn+6tvpTz9CuY8A==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.198.tgz",
|
||||
"integrity": "sha512-XwH5vgN46WSwg8aC1OagNofnJpV/G1ciEu118GEKer8ZhVkq/dvK/DqShxMkb6r1jV7u5IJ7zPXu9uKliyNJAw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -60,9 +60,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.187.tgz",
|
||||
"integrity": "sha512-VEvrs3MgwckdWRNa7+2rJdyOBbCWGoRaM6OnL+/n9qi4gKlZnXZlj/90Tw9o8ttcN260Opz120/SE1fYwzu/JA==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.198.tgz",
|
||||
"integrity": "sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -76,9 +76,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.187.tgz",
|
||||
"integrity": "sha512-XQ7w2pX0mjPYUC7ayTKiHeAbePOny/ezBATxTWt5JVDZZPfljzvHA0jyXHsN8aLphbgRUfbUpdrtt5b57Bhx5g==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.198.tgz",
|
||||
"integrity": "sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -92,9 +92,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.187.tgz",
|
||||
"integrity": "sha512-s/Fmg29tzMrbdeCbseTpL3DlzfWineSQ3bDPvhdKw4jcsgLC1YgQhIkAyE8WlceUyhQyw82NAUgYoPwfGny6hQ==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.198.tgz",
|
||||
"integrity": "sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -108,9 +108,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.187.tgz",
|
||||
"integrity": "sha512-yi2/L5oztFqTDnAZl4mWOINr+r7WBot70gYUxG2jOoqdYUl6j50vG/ME605X80v+l2qBmKjSGxb5trY/6DkRzQ==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.198.tgz",
|
||||
"integrity": "sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -124,9 +124,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.187.tgz",
|
||||
"integrity": "sha512-HjEl3cDRLAWKopdlrLI54fxTVWq4lZpWA4bTTBVc9UDdDj6AmJIRHKxaCCYLQRvnoswbAUF1sJmJ8EFJ+b6lfw==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.198.tgz",
|
||||
"integrity": "sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -137,9 +137,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.187.tgz",
|
||||
"integrity": "sha512-FnrLhoZ8y/+gkZynL12UeQ8pWKCu6B/8myyRrYMNJRZqFw4DZlPvPCywAjFZf1e1hiXCoiSK0Orl5wzKfAeQsQ==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.198.tgz",
|
||||
"integrity": "sha512-y3HLuCCz1kDwUrhd6OnqO+d5BUpTFSzNUsPT9kf3r1vk9HYKF+eMC9eIlcOhiW2kX491kxEvuEOfqgIkGx15cg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"private": true,
|
||||
"comment": "Pinned dependency set for the build/agent-sdk claude tarball. The package-lock.json alongside is the source of truth for transitive deps — produce.ts runs `npm ci` against this directory to get byte-deterministic output across pipeline runs.",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.187"
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.198"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Some dependencies ship their native binary in a per-platform package that is
|
||||
// declared as an *optional* dependency of a small base package — e.g.
|
||||
// `@openai/codex` and `@anthropic-ai/claude-agent-sdk` are thin launchers and
|
||||
// the real binaries live in `@openai/codex-<platform>-<arch>` /
|
||||
// `@anthropic-ai/claude-agent-sdk-<platform>-<arch>`. `npm install` / `npm ci`
|
||||
// do NOT fail when an optional dependency cannot be installed, so a transient
|
||||
// hiccup can leave the base package present while the per-platform package is
|
||||
// missing. That broken tree then gets frozen into the node_modules cache and
|
||||
// served to every consumer, failing far away from the cause
|
||||
// (see https://github.com/microsoft/vscode/pull/323881).
|
||||
//
|
||||
// This check runs after `npm ci` when building a node_modules cache and fails
|
||||
// the job when a required per-platform package is missing, so a poisoned cache
|
||||
// is never saved.
|
||||
|
||||
const ROOT = path.join(import.meta.dirname, '../../../');
|
||||
|
||||
// Base packages whose per-platform package (`<base>-<platform>-<arch>`) is
|
||||
// required whenever the base package itself is installed.
|
||||
const NATIVE_OPTIONAL_DEP_BASE_PACKAGES = [
|
||||
'@openai/codex',
|
||||
'@anthropic-ai/claude-agent-sdk',
|
||||
];
|
||||
|
||||
// Platform/arch combinations these packages publish a per-platform package for.
|
||||
const SUPPORTED_PLATFORMS = new Set(['linux', 'darwin', 'win32']);
|
||||
const SUPPORTED_ARCHS = new Set(['x64', 'arm64']);
|
||||
|
||||
function isInstalled(pkg: string): boolean {
|
||||
return fs.existsSync(path.join(ROOT, 'node_modules', pkg));
|
||||
}
|
||||
|
||||
const { platform, arch } = process;
|
||||
|
||||
if (!SUPPORTED_PLATFORMS.has(platform) || !SUPPORTED_ARCHS.has(arch)) {
|
||||
console.log(`Skipping native optional-dependency check on unsupported ${platform}-${arch}.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const basePackage of NATIVE_OPTIONAL_DEP_BASE_PACKAGES) {
|
||||
// Only enforce when the base package is installed; if it is not, the
|
||||
// dependency simply was not requested here and there is nothing to verify.
|
||||
if (!isInstalled(basePackage)) {
|
||||
continue;
|
||||
}
|
||||
// The glibc Linux package (no `-musl` suffix) is the one required on the
|
||||
// glibc-based CI hosts; the naming is always `<base>-<platform>-<arch>`.
|
||||
const platformPackage = `${basePackage}-${platform}-${arch}`;
|
||||
if (!isInstalled(platformPackage)) {
|
||||
errors.push(`${basePackage}: required per-platform package '${platformPackage}' is missing from node_modules — the optional dependency was silently skipped during install`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('\x1b[1;31m*** Missing native optional-dependency packages — refusing to save a poisoned node_modules cache ***\x1b[0m');
|
||||
for (const err of errors) {
|
||||
console.error(` - ${err}`);
|
||||
}
|
||||
console.error('\nnpm does not fail when an optional dependency cannot be installed, so this tree would poison the shared node_modules cache. Re-run a fresh `npm ci` (e.g. after bumping build/.cachesalt) to restore the package before the cache is saved.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Verified native optional-dependency packages for ${platform}-${arch}.`);
|
||||
Generated
+176
-3334
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,9 @@
|
||||
"serve-out": "rspack serve --config rspack.serve-out.config.mts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rspack/cli": "^1.3.18",
|
||||
"@rspack/core": "^1.3.18",
|
||||
"@rspack/cli": "^2.1.2",
|
||||
"@rspack/core": "^2.1.2",
|
||||
"@rspack/dev-server": "^2.1.0",
|
||||
"@vscode/esm-url-webpack-plugin": "^1.0.1-3",
|
||||
"source-map-loader": "^5.0.0"
|
||||
},
|
||||
|
||||
@@ -44,6 +44,13 @@ export default {
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
// Component Explorer fixtures live in `src` as `.ts` and import sibling
|
||||
// modules via `.js` specifiers; try `.ts` first so those resolve, then
|
||||
// fall back to `.js` for everything loaded from `out`.
|
||||
extensionAlias: {
|
||||
'.js': ['.ts', '.js'],
|
||||
'.mjs': ['.mts', '.mjs'],
|
||||
},
|
||||
fallback: {
|
||||
path: path.resolve(repoRoot, 'node_modules', 'path-browserify'),
|
||||
fs: false,
|
||||
@@ -59,9 +66,25 @@ export default {
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
enforce: 'pre',
|
||||
use: ['source-map-loader'],
|
||||
// Component Explorer fixtures (and any `src` TypeScript they pull
|
||||
// in) are compiled on the fly with rspack's built-in SWC.
|
||||
test: /\.ts$/,
|
||||
loader: 'builtin:swc-loader',
|
||||
options: {
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
decorators: true,
|
||||
},
|
||||
transform: {
|
||||
legacyDecorator: true,
|
||||
decoratorMetadata: false,
|
||||
useDefineForClassFields: false,
|
||||
},
|
||||
target: 'es2022',
|
||||
},
|
||||
},
|
||||
type: 'javascript/auto',
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
@@ -86,7 +109,7 @@ export default {
|
||||
},
|
||||
plugins: [
|
||||
new ComponentExplorerPlugin({
|
||||
include: 'out/**/*.fixture.js',
|
||||
include: 'src/**/*.fixture.ts',
|
||||
}),
|
||||
new rspack.NormalModuleReplacementPlugin(/\.css$/, resource => {
|
||||
if (!resource.request.startsWith('.')) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
|
||||
<!DOCTYPE html>
|
||||
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
@@ -1826,6 +1826,11 @@
|
||||
"secret": true,
|
||||
"description": "API key for OpenAI",
|
||||
"title": "API Key"
|
||||
},
|
||||
"zeroDataRetentionEnabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "Whether Zero Data Retention (ZDR) is enabled for this provider group. When `true`, OpenAI Responses requests from this group do not send `previous_response_id`."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -4337,7 +4342,7 @@
|
||||
},
|
||||
"github.copilot.chat.tools.grepSearch.outputFormat": {
|
||||
"type": "string",
|
||||
"default": "tag",
|
||||
"default": "grep",
|
||||
"enum": [
|
||||
"grep",
|
||||
"tag"
|
||||
|
||||
@@ -278,7 +278,7 @@
|
||||
"github.copilot.config.autoFix": "Automatically fix diagnostics for edited files.",
|
||||
"github.copilot.config.rateLimitAutoSwitchToAuto": "Automatically switch to the Auto model and retry when you hit a per-model rate limit.",
|
||||
"github.copilot.tools.createNewWorkspace.userDescription": "Scaffold a new workspace in VS Code",
|
||||
"github.copilot.chat.tools.grepSearch.outputFormat": "The output format for the grep search tool. Can be either 'grep' or 'tag'. The default is 'tag'.",
|
||||
"github.copilot.chat.tools.grepSearch.outputFormat": "The output format for the grep search tool. Can be either 'grep' or 'tag'. The default is 'grep'.",
|
||||
"github.copilot.chat.tools.grepSearch.defaultMaxResults": "The default maximum number of results to return from the grep search tool. The default is 20.",
|
||||
"github.copilot.chat.tools.grepSearch.maxResultsCap": "The maximum number of results that can be returned from the grep search tool. The default is 200.",
|
||||
"copilot.tools.errors.description": "Check errors for a particular file",
|
||||
|
||||
@@ -9,10 +9,22 @@ import { IFetcherService } from '../../../platform/networking/common/fetcherServ
|
||||
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
|
||||
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
|
||||
import { BYOKKnownModels } from '../common/byokProvider';
|
||||
import { AbstractOpenAICompatibleLMProvider } from './abstractLanguageModelChatProvider';
|
||||
import { OpenAIEndpoint } from '../node/openAIEndpoint';
|
||||
import { AbstractOpenAICompatibleLMProvider, LanguageModelChatConfiguration, OpenAICompatibleLanguageModelChatInformation } from './abstractLanguageModelChatProvider';
|
||||
import { IBYOKStorageService } from './byokStorageService';
|
||||
|
||||
export class OAIBYOKLMProvider extends AbstractOpenAICompatibleLMProvider {
|
||||
export interface OpenAIProviderConfig extends LanguageModelChatConfiguration {
|
||||
readonly zeroDataRetentionEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function applyOpenAIProviderConfig(modelInfo: IChatModelInformation, configuration: OpenAIProviderConfig | undefined): IChatModelInformation {
|
||||
return {
|
||||
...modelInfo,
|
||||
zeroDataRetentionEnabled: configuration?.zeroDataRetentionEnabled ?? modelInfo.zeroDataRetentionEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export class OAIBYOKLMProvider extends AbstractOpenAICompatibleLMProvider<OpenAIProviderConfig> {
|
||||
|
||||
public static readonly providerName = 'OpenAI';
|
||||
public static readonly providerId = this.providerName.toLowerCase();
|
||||
@@ -43,6 +55,14 @@ export class OAIBYOKLMProvider extends AbstractOpenAICompatibleLMProvider {
|
||||
return 'https://api.openai.com/v1';
|
||||
}
|
||||
|
||||
protected override async createOpenAIEndPoint(model: OpenAICompatibleLanguageModelChatInformation<OpenAIProviderConfig>): Promise<OpenAIEndpoint> {
|
||||
const modelInfo = applyOpenAIProviderConfig(this.getModelInfo(model.id, model.url), model.configuration);
|
||||
const url = modelInfo.supported_endpoints?.includes(ModelSupportedEndpoint.Responses) ?
|
||||
`${model.url}/responses` :
|
||||
`${model.url}/chat/completions`;
|
||||
return this._instantiationService.createInstance(OpenAIEndpoint, modelInfo, model.configuration?.apiKey ?? '', url);
|
||||
}
|
||||
|
||||
protected override getModelInfo(modelId: string, modelUrl: string): IChatModelInformation {
|
||||
const modelInfo = super.getModelInfo(modelId, modelUrl);
|
||||
modelInfo.supported_endpoints = [
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IChatModelInformation } from '../../../../platform/endpoint/common/endpointProvider';
|
||||
import { TokenizerType } from '../../../../util/common/tokenizer';
|
||||
import { applyOpenAIProviderConfig } from '../openAIProvider';
|
||||
|
||||
function createModelInfo(zeroDataRetentionEnabled: boolean | undefined): IChatModelInformation {
|
||||
return {
|
||||
id: 'gpt-4.1',
|
||||
name: 'GPT-4.1',
|
||||
vendor: 'OpenAI',
|
||||
version: '1.0.0',
|
||||
is_chat_default: false,
|
||||
is_chat_fallback: false,
|
||||
model_picker_enabled: true,
|
||||
zeroDataRetentionEnabled,
|
||||
capabilities: {
|
||||
type: 'chat',
|
||||
family: 'gpt-4.1',
|
||||
supports: {
|
||||
streaming: true,
|
||||
tool_calls: true,
|
||||
vision: false,
|
||||
thinking: false,
|
||||
},
|
||||
tokenizer: TokenizerType.O200K,
|
||||
limits: {
|
||||
max_context_window_tokens: 128000,
|
||||
max_prompt_tokens: 100000,
|
||||
max_output_tokens: 8192,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyOpenAIProviderConfig', () => {
|
||||
it('uses provider-level zeroDataRetentionEnabled when configured', () => {
|
||||
const merged = applyOpenAIProviderConfig(createModelInfo(undefined), {
|
||||
apiKey: 'test-key',
|
||||
zeroDataRetentionEnabled: true,
|
||||
});
|
||||
|
||||
expect(merged.zeroDataRetentionEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to model metadata zeroDataRetentionEnabled when provider-level value is unset', () => {
|
||||
const merged = applyOpenAIProviderConfig(createModelInfo(true), {
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
expect(merged.zeroDataRetentionEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1063,7 +1063,7 @@ export function registerCLIChatCommands(
|
||||
): IDisposable {
|
||||
const disposableStore = new DisposableStore();
|
||||
|
||||
async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean }): Promise<void> {
|
||||
async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean; sessionLabel?: string }): Promise<void> {
|
||||
const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
|
||||
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId);
|
||||
|
||||
@@ -1078,7 +1078,7 @@ export function registerCLIChatCommands(
|
||||
if (!repository) {
|
||||
throw new Error(l10n.t('No active repository found to delete worktree.'));
|
||||
}
|
||||
await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath);
|
||||
await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath, { label: options?.sessionLabel });
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(l10n.t('Failed to delete worktree: {0}', error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
@@ -1125,7 +1125,7 @@ export function registerCLIChatCommands(
|
||||
);
|
||||
|
||||
if (result === deleteLabel) {
|
||||
await deleteSessionById(id, { keepWorktree });
|
||||
await deleteSessionById(id, { keepWorktree, sessionLabel: sessionItem.label });
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -1154,7 +1154,7 @@ export function registerCLIChatCommands(
|
||||
const id = SessionIdForCLI.parse(sessionItem.resource);
|
||||
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(id);
|
||||
const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(id, worktreePath);
|
||||
await deleteSessionById(id, { keepWorktree });
|
||||
await deleteSessionById(id, { keepWorktree, sessionLabel: sessionItem.label });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
+4
-4
@@ -2224,7 +2224,7 @@ export function registerCLIChatCommands(
|
||||
);
|
||||
return siblings.length > 0;
|
||||
}
|
||||
async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean }): Promise<void> {
|
||||
async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean; sessionLabel?: string }): Promise<void> {
|
||||
const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
|
||||
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId);
|
||||
|
||||
@@ -2239,7 +2239,7 @@ export function registerCLIChatCommands(
|
||||
if (!repository) {
|
||||
throw new Error(l10n.t('No active repository found to delete worktree.'));
|
||||
}
|
||||
await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath);
|
||||
await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath, { label: options?.sessionLabel });
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(l10n.t('Failed to delete worktree: {0}', error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
@@ -2265,7 +2265,7 @@ export function registerCLIChatCommands(
|
||||
);
|
||||
|
||||
if (result === deleteLabel) {
|
||||
await deleteSessionById(sessionId, { keepWorktree });
|
||||
await deleteSessionById(sessionId, { keepWorktree, sessionLabel: sessionItem.label });
|
||||
copilotcliSessionItemProvider.notifySessionsChange();
|
||||
}
|
||||
}
|
||||
@@ -2296,7 +2296,7 @@ export function registerCLIChatCommands(
|
||||
const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id;
|
||||
const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId);
|
||||
const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(sessionId, worktreePath);
|
||||
await deleteSessionById(sessionId, { keepWorktree });
|
||||
await deleteSessionById(sessionId, { keepWorktree, sessionLabel: sessionItem.label });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -789,6 +789,15 @@ export class CopilotLanguageModelWrapper extends Disposable {
|
||||
|
||||
const result = await wrappedRequest();
|
||||
|
||||
if (result.type === ChatFetchResponseType.Length) {
|
||||
// The model stopped generating because it hit the length/context-window limit
|
||||
// (finish_reason "length"). The partial text has already been streamed to the
|
||||
// consumer via the finished callback, so treat this as a successful (truncated)
|
||||
// response instead of throwing "Response too long." and discarding the output.
|
||||
this._logService.warn(`[LanguageModelAccess] Response from model '${_endpoint.model}' was truncated because it hit the length limit; returning the partial response.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (result.type !== ChatFetchResponseType.Success) {
|
||||
if (result.type === ChatFetchResponseType.ExtensionBlocked) {
|
||||
if (extensionId) {
|
||||
|
||||
+35
@@ -143,6 +143,41 @@ suite('CopilotLanguageModelWrapper', () => {
|
||||
assert.deepStrictEqual(decoded, expectedUsage);
|
||||
});
|
||||
});
|
||||
|
||||
suite('length finish reason', () => {
|
||||
let wrapper: CopilotLanguageModelWrapper;
|
||||
let endpoint: IChatEndpoint;
|
||||
let fetcher: MockChatMLFetcher;
|
||||
setup(async () => {
|
||||
createAccessor();
|
||||
fetcher = accessor.get(IChatMLFetcher) as MockChatMLFetcher;
|
||||
endpoint = await accessor.get(IEndpointProvider).getChatEndpoint('copilot-utility');
|
||||
wrapper = instaService.createInstance(CopilotLanguageModelWrapper);
|
||||
});
|
||||
|
||||
test('does not throw when the response is truncated due to length', async () => {
|
||||
// A model (e.g. a custom/BYOK endpoint) can legitimately stop generating
|
||||
// because it hit the context window ceiling, returning finish_reason "length"
|
||||
// together with the partial text it already streamed. This must not surface
|
||||
// as a hard "Response too long." failure that discards the generated text.
|
||||
fetcher.setNextResponse({
|
||||
type: ChatFetchResponseType.Length,
|
||||
reason: 'Response too long.',
|
||||
requestId: 'test-request-id',
|
||||
serverRequestId: 'test-server-request-id',
|
||||
truncatedValue: 'partial answer'
|
||||
});
|
||||
|
||||
await wrapper.provideLanguageModelResponse(
|
||||
endpoint,
|
||||
[vscode.LanguageModelChatMessage.User('hello')],
|
||||
{ requestInitiator: 'unknown', toolMode: vscode.LanguageModelChatToolMode.Auto },
|
||||
vscode.extensions.all[0].id,
|
||||
{ report: () => { } },
|
||||
CancellationToken.None
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('LanguageModelAccess model info', () => {
|
||||
|
||||
@@ -47,9 +47,16 @@ interface ProxyAgentParams {
|
||||
loadSystemCertificatesFromNode: () => boolean | undefined;
|
||||
}
|
||||
|
||||
interface ResolvedProxyInfo {
|
||||
url: string | undefined;
|
||||
type: 'DIRECT' | 'PROXY' | 'HTTP' | 'HTTPS' | 'SOCKS' | 'SOCKS5' | 'SOCKS4' | 'EMPTY' | 'UNRECOGNIZED';
|
||||
source: 'localhost' | 'noProxyConfig' | 'noProxyEnv' | 'setting' | 'env' | 'remote' | 'system_cached' | 'system' | 'fallback';
|
||||
}
|
||||
|
||||
interface ProxyAgent {
|
||||
loadSystemCertificates?(params: ProxyAgentParams): Promise<string[]>;
|
||||
resolveProxyURL?(url: string): Promise<string | undefined>;
|
||||
resolveProxyByURL?(url: string): Promise<ResolvedProxyInfo>;
|
||||
}
|
||||
|
||||
export class LoggingActionsContrib {
|
||||
@@ -471,35 +478,38 @@ function getProxyEnvVariables() {
|
||||
return res.length ? `\n\nEnvironment Variables:${res.join('')}` : '';
|
||||
}
|
||||
|
||||
interface ProxyInfo {
|
||||
/** Resolved proxy type: `DIRECT`, `PROXY`, `HTTP`, `HTTPS`, `SOCKS`, `SOCKS5`, `SOCKS4`, `EMPTY`, `UNRECOGNIZED` or `UNKNOWN`. */
|
||||
type: string;
|
||||
/**
|
||||
* Where the proxy configuration came from: `localhost`, `noProxyConfig`
|
||||
* (`http.noProxy`), `noProxyEnv` (`no_proxy`), `setting` (`http.proxy`), `env`
|
||||
* (`http(s)_proxy`), `remote`, `system_cached`, `system` (OS/PAC) or `fallback`
|
||||
* from `@vscode/proxy-agent`, or one of the failure sentinels `missing`
|
||||
* (module/API unavailable), `timeout` or `error`.
|
||||
*/
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the proxy type for a URL using the bundled `@vscode/proxy-agent` module.
|
||||
* Returns one of `DIRECT`, `PROXY`, `HTTPS`, `SOCKS` or `UNKNOWN`.
|
||||
* Resolves the proxy type and configuration source for a URL using the bundled
|
||||
* `@vscode/proxy-agent` module. The source reflects which configuration actually
|
||||
* determined the resolved proxy (see `@vscode/proxy-agent`'s `resolveProxyByURL`).
|
||||
*/
|
||||
async function resolveProxyType(url: string, logService: ILogService): Promise<string> {
|
||||
async function resolveProxyInfo(url: string, logService: ILogService): Promise<ProxyInfo> {
|
||||
try {
|
||||
const proxyAgent = loadVSCodeModule<ProxyAgent>('@vscode/proxy-agent');
|
||||
if (!proxyAgent?.resolveProxyURL) {
|
||||
return 'UNKNOWN';
|
||||
if (!proxyAgent?.resolveProxyByURL) {
|
||||
return { type: 'UNKNOWN', source: 'missing' };
|
||||
}
|
||||
const proxyURL = await Promise.race([proxyAgent.resolveProxyURL(url), timeoutAfter(5000)]);
|
||||
if (proxyURL === 'timeout') {
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
if (!proxyURL) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
const scheme = proxyURL.split(':', 1)[0].toLowerCase();
|
||||
switch (scheme) {
|
||||
case 'http': return 'PROXY';
|
||||
case 'https': return 'HTTPS';
|
||||
case 'socks':
|
||||
case 'socks4':
|
||||
case 'socks5': return 'SOCKS';
|
||||
default: return 'UNKNOWN';
|
||||
const info = await Promise.race([proxyAgent.resolveProxyByURL(url), timeoutAfter(5000)]);
|
||||
if (info === 'timeout') {
|
||||
return { type: 'UNKNOWN', source: 'timeout' };
|
||||
}
|
||||
return { type: info.type, source: info.source };
|
||||
} catch (err) {
|
||||
logService.debug(`Fetcher telemetry: Failed to resolve proxy type: ${err?.message}`);
|
||||
return 'UNKNOWN';
|
||||
logService.debug(`Fetcher telemetry: Failed to resolve proxy info: ${err?.message}`);
|
||||
return { type: 'UNKNOWN', source: 'error' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,8 +582,8 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the proxy type for the CAPI endpoint (e.g. DIRECT, PROXY, HTTPS, SOCKS).
|
||||
const proxyType = await resolveProxyType(capiClientService.capiPingURL, logService);
|
||||
// Resolve the proxy type and configuration source for the CAPI endpoint.
|
||||
const { type: proxyType, source: proxySource } = await resolveProxyInfo(capiClientService.capiPingURL, logService);
|
||||
|
||||
// Second loop: send the actual telemetry event including probe results.
|
||||
const requestGroupId = generateUuid();
|
||||
@@ -589,7 +599,8 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void {
|
||||
"clientLibrary": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The fetcher library used for this request." },
|
||||
"extensionKind": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the extension runs locally or remotely." },
|
||||
"remoteName": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The remote name, if any." },
|
||||
"proxyType": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The resolved proxy type for the CAPI endpoint (e.g. DIRECT, PROXY, HTTPS, SOCKS, UNKNOWN)." },
|
||||
"proxyType": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The resolved proxy type for the CAPI endpoint (e.g. DIRECT, PROXY, HTTP, HTTPS, SOCKS, SOCKS5, SOCKS4, EMPTY, UNRECOGNIZED, UNKNOWN)." },
|
||||
"proxySource": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Where the proxy configuration came from: localhost, noProxyConfig (http.noProxy), noProxyEnv (no_proxy), setting (http.proxy), env (http(s)_proxy), remote, system_cached, system (OS/PAC), fallback, or a failure sentinel: missing, timeout or error." },
|
||||
"electronfetch": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Probe result for the electron-fetch fetcher." },
|
||||
"nodefetch": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Probe result for the node-fetch fetcher." },
|
||||
"nodehttp": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Probe result for the node-http fetcher." }
|
||||
@@ -601,6 +612,7 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void {
|
||||
extensionKind,
|
||||
remoteName: vscode.env.remoteName ?? 'none',
|
||||
proxyType,
|
||||
proxySource,
|
||||
...probeResults,
|
||||
};
|
||||
const response = await sendRawTelemetry(fetcher, envService, oneCollectorTelemetryUrl, extensionContext, 'GitHub.copilot-chat/fetcherTelemetry', properties);
|
||||
|
||||
@@ -162,6 +162,7 @@ class Gpt56Prompt extends PromptElement<DefaultAgentPromptProps> {
|
||||
You have two channels for staying in conversation with the user:<br />
|
||||
- You share updates in `commentary` channel.<br />
|
||||
- After you have completed all of your work, you send a message to the `final` channel.<br />
|
||||
Do NOT put final answer in commentary channel, or ask _blocking_ question in a commentary channel that should be asked in the final channel. Message to users in the commentary channel is only for partial updates, partial results, or non-blocking questions that can provide value to users while the AI assistant continues working. The final answer must always be fully self-contained: users should never need to read earlier commentary update, since they are collapsed after the final answer is shown to users.<br />
|
||||
The user may send messages while you are working. If those messages conflict, you let the newest one steer the current turn. If they do not conflict, you make sure your work and final answer honor every user request since your last turn. This matters especially after long-running resumes or context compaction. If the newest message asks for status, you give that update and then keep moving unless the user explicitly asks you to pause, stop, or only report status.<br />
|
||||
Before sending a final response after a resume, interruption, or context transition, you do a quick sanity check: you make sure your final answer and tool actions are answering the newest request, not an older ghost still lingering in the thread.<br />
|
||||
When you run out of context, the tool automatically compacts the conversation. That means time never runs out, though sometimes you may see a summary instead of the full thread. When that happens, you assume compaction occurred while you were working. Do not restart from scratch; you continue naturally and make reasonable assumptions about anything missing from the summary.<br />
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { PromptElement, PromptSizing } from '@vscode/prompt-tsx';
|
||||
import { isVSCModelA, isVSCModelB, isVSCModelC, isVSCModelD } from '../../../../platform/endpoint/common/chatModelCapabilities';
|
||||
import { isVSCModelA, isVSCModelB, isVSCModelC, isVSCModelD, isVSCModelE } from '../../../../platform/endpoint/common/chatModelCapabilities';
|
||||
import { IChatEndpoint } from '../../../../platform/networking/common/networking';
|
||||
import { ToolName } from '../../../tools/common/toolNames';
|
||||
import { InstructionMessage } from '../base/instructionMessage';
|
||||
@@ -664,10 +664,26 @@ class VSCModelPromptD extends PromptElement<DefaultAgentPromptProps> {
|
||||
- This first `commentary` must be 1-2 friendly sentences acknowledging the request and stating the immediate next action you will take.<br />
|
||||
- The first commentary should not begin updates with conversational interjections or meta commentary. Avoid openers such as acknowledgements ("Done —", "Got it", "Great question,") or framing phrases. Do not use starters like "Got it -" or "Understood -".<br />
|
||||
</Tag>
|
||||
{this instanceof VSCModelPromptE && <Tag name='keep_compact'>
|
||||
Operate in surgical compact mode. Correctness comes first, but every read, search, command, and note must directly reduce the chance of a wrong patch.<br />
|
||||
<br />
|
||||
Default workflow:<br />
|
||||
- Start with one targeted search or symbol lookup. Read only the exact file and narrow line range needed for the likely edit site.<br />
|
||||
- Before each additional read/search, state the one missing fact it will answer. If it is only background, neighboring style, or "more context", skip it.<br />
|
||||
- Normal budget before the first patch: at most one search and two narrow reads. Exceed this only when the result falsified the edit site or exposed a required API contract.<br />
|
||||
- Keep reads tight: prefer the specific function/class/test range; avoid whole files, broad parallel reads, neighboring tests, and repeated grep variants.<br />
|
||||
- Patch once you know the root cause and edit site. Do not inspect unrelated neighbors, add opportunistic cleanup, or broaden scope after a plausible fix is available.<br />
|
||||
- Validate with the smallest existing command that checks the changed behavior, and keep command output concise. Do not run broad suites after a focused pass.<br />
|
||||
- Keep reasoning and final answer brief. Do not narrate routine tool use, quote long snippets, or restate tool output; record only decisions that affect the patch.<br />
|
||||
<br />
|
||||
If the fix is uncertain, run one focused probe or ask for the single missing fact, then return to patch/validate. Do not continue exploration by default.<br />
|
||||
</Tag>}
|
||||
</InstructionMessage>;
|
||||
}
|
||||
}
|
||||
|
||||
class VSCModelPromptE extends VSCModelPromptD { }
|
||||
|
||||
class VSCModelPromptResolverA implements IAgentPrompt {
|
||||
static readonly familyPrefixes = ['vscModelA'];
|
||||
static async matchesModel(endpoint: IChatEndpoint): Promise<boolean> {
|
||||
@@ -730,6 +746,22 @@ class VSCModelPromptResolverD implements IAgentPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
class VSCModelPromptResolverE implements IAgentPrompt {
|
||||
static readonly familyPrefixes = ['vscModelE'];
|
||||
|
||||
static async matchesModel(endpoint: IChatEndpoint): Promise<boolean> {
|
||||
return isVSCModelE(endpoint);
|
||||
}
|
||||
|
||||
resolveSystemPrompt(endpoint: IChatEndpoint): SystemPrompt | undefined {
|
||||
return VSCModelPromptE;
|
||||
}
|
||||
|
||||
resolveReminderInstructions(endpoint: IChatEndpoint): ReminderInstructionsConstructor | undefined {
|
||||
return VSCModelReminderInstructionsA;
|
||||
}
|
||||
}
|
||||
|
||||
class VSCModelReminderInstructions extends PromptElement<ReminderInstructionsProps> {
|
||||
async render(state: void, sizing: PromptSizing) {
|
||||
return <>
|
||||
@@ -797,4 +829,5 @@ class VSCModelReminderInstructionsC extends PromptElement<ReminderInstructionsPr
|
||||
PromptRegistry.registerPrompt(VSCModelPromptResolverA);
|
||||
PromptRegistry.registerPrompt(VSCModelPromptResolverB);
|
||||
PromptRegistry.registerPrompt(VSCModelPromptResolverC);
|
||||
PromptRegistry.registerPrompt(VSCModelPromptResolverD);
|
||||
PromptRegistry.registerPrompt(VSCModelPromptResolverD);
|
||||
PromptRegistry.registerPrompt(VSCModelPromptResolverE);
|
||||
|
||||
@@ -456,7 +456,7 @@ Then if you want to include those files you can call the tool again by setting "
|
||||
|
||||
private getOutputFormat(): 'grep' | 'tag' {
|
||||
const expFlag = this.configurationService.getExperimentBasedConfig(ConfigKey.GrepSearchOutputFormat, this.experimentationService);
|
||||
return expFlag === 'grep' ? 'grep' : 'tag';
|
||||
return expFlag === 'tag' ? 'tag' : 'grep';
|
||||
}
|
||||
|
||||
private getDefaultMaxResults(): number {
|
||||
|
||||
@@ -1093,7 +1093,7 @@ export namespace ConfigKey {
|
||||
export const LocalIndexEnabled = defineSetting<boolean>('chat.localIndex.enabled', ConfigType.ExperimentBased, false);
|
||||
|
||||
/** grep_search configs */
|
||||
export const GrepSearchOutputFormat = defineSetting<'grep' | 'tag'>('chat.tools.grepSearch.outputFormat', ConfigType.ExperimentBased, 'tag');
|
||||
export const GrepSearchOutputFormat = defineSetting<'grep' | 'tag'>('chat.tools.grepSearch.outputFormat', ConfigType.ExperimentBased, 'grep');
|
||||
export const GrepSearchDefaultMaxResults = defineSetting<number>('chat.tools.grepSearch.defaultMaxResults', ConfigType.ExperimentBased, 20);
|
||||
export const GrepSearchMaxResultsCap = defineSetting<number>('chat.tools.grepSearch.maxResultsCap', ConfigType.ExperimentBased, 200);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ const VSC_MODEL_HASHES_D = [
|
||||
'e82ff0e2d4e4bae1f012dc599d520f8d61becfc4762f3717577b270be199db92',
|
||||
];
|
||||
|
||||
const VSC_MODEL_HASHES_E: string[] = [];
|
||||
|
||||
|
||||
// subset to allow replace string instead of apply patch.
|
||||
const VSC_MODEL_HASHES_EDIT_TOOL_SET = [
|
||||
@@ -215,6 +217,13 @@ export function isVSCModelD(model: LanguageModelChat | IChatEndpoint) {
|
||||
return VSC_MODEL_HASHES_D.includes(ID_hash) || VSC_MODEL_HASHES_D.includes(family_hash);
|
||||
}
|
||||
|
||||
export function isVSCModelE(model: LanguageModelChat | IChatEndpoint) {
|
||||
const modelId = getModelId(model);
|
||||
const ID_hash = getCachedSha256Hash(modelId);
|
||||
const family_hash = getCachedSha256Hash(model.family);
|
||||
return model.name.startsWith('vscModelE') || model.family.startsWith('vscModelE') || modelId.startsWith('vscModelE') || VSC_MODEL_HASHES_E.includes(ID_hash) || VSC_MODEL_HASHES_E.includes(family_hash);
|
||||
}
|
||||
|
||||
export function isGpt52CodexFamily(model: LanguageModelChat | IChatEndpoint | string): boolean {
|
||||
const family = typeof model === 'string' ? model : model.family;
|
||||
return family === 'gpt-5.2-codex';
|
||||
|
||||
@@ -404,25 +404,33 @@ export class AutomodeService extends Disposable implements IAutomodeService {
|
||||
return { lastRoutedPrompt: prompt, fallbackReason: 'emptyCandidateList' };
|
||||
}
|
||||
|
||||
// Trust the router's ranked candidate list directly.
|
||||
// Prefer chosen_model — it is the router's authoritative pick after any
|
||||
// server-side re-ranking (e.g. Cost Sorting experiments). candidate_models
|
||||
// is the ordered fallback list per the auto-intent-service contract
|
||||
// (docs/integrators_onboarding.md: "Use chosen_model for the upcoming chat
|
||||
// call, and use candidate_models as the ordered fallback list").
|
||||
// Same-provider preference is intentionally NOT applied here — the router
|
||||
// already accounts for available models and re-runs after /compact, so
|
||||
// overriding its pick with same-provider negates cost-saving decisions.
|
||||
// Same-provider is still used in _selectDefaultModel (the non-router fallback).
|
||||
const selectedModel = this._findFirstAvailableModel(result.candidate_models, knownEndpoints);
|
||||
const routerModel = result.chosen_model ?? result.candidate_models[0];
|
||||
let selectedModel = result.chosen_model ? knownEndpoints.find(e => e.model === result.chosen_model) : undefined;
|
||||
if (!selectedModel) {
|
||||
selectedModel = this._findFirstAvailableModel(result.candidate_models, knownEndpoints);
|
||||
}
|
||||
|
||||
if (!selectedModel) {
|
||||
this._logService.warn(`[AutomodeService] None of the router's candidate_models matched knownEndpoints: [${result.candidate_models.join(', ')}]`);
|
||||
this._logService.warn(`[AutomodeService] Router pick not in knownEndpoints: chosen_model=${result.chosen_model ?? 'n/a'}, candidate_models=[${result.candidate_models.join(', ')}]`);
|
||||
return { lastRoutedPrompt: prompt, fallbackReason: 'noMatchingEndpoint' };
|
||||
}
|
||||
|
||||
if (result.sticky_override) {
|
||||
this._logService.trace(`[AutomodeService] Sticky routing override: confidence=${(result.confidence * 100).toFixed(1)}%, label=${result.predicted_label}, router_model=${result.candidate_models[0]}, actual_model=${selectedModel.model}`);
|
||||
this._logService.trace(`[AutomodeService] Sticky routing override: confidence=${(result.confidence * 100).toFixed(1)}%, label=${result.predicted_label}, router_model=${routerModel}, actual_model=${selectedModel.model}`);
|
||||
}
|
||||
return {
|
||||
selectedModel,
|
||||
lastRoutedPrompt: prompt,
|
||||
candidateModel: result.candidate_models[0],
|
||||
candidateModel: routerModel,
|
||||
routingDecision: {
|
||||
resolvedModel: selectedModel.model,
|
||||
resolvedModelName: selectedModel.name,
|
||||
|
||||
@@ -29,7 +29,7 @@ import { ITelemetryService, TelemetryProperties } from '../../telemetry/common/t
|
||||
import { TelemetryData } from '../../telemetry/common/telemetryData';
|
||||
import { ITokenizerProvider } from '../../tokenizer/node/tokenizer';
|
||||
import { ICAPIClientService } from '../common/capiClient';
|
||||
import { getModelCapabilityOverride, isAnthropicFamily, isGeminiFamily, modelSupportsContextEditing, modelSupportsToolSearch } from '../common/chatModelCapabilities';
|
||||
import { getModelCapabilityOverride, isAnthropicFamily, isGeminiFamily, isKimiFamily, modelSupportsContextEditing, modelSupportsToolSearch } from '../common/chatModelCapabilities';
|
||||
import { IDomainService } from '../common/domainService';
|
||||
import { CustomModel, IChatModelInformation, ModelSupportedEndpoint } from '../common/endpointProvider';
|
||||
import { normalizeTokenPrices } from '../../../extension/conversation/common/languageModelAccess';
|
||||
@@ -390,6 +390,12 @@ export class ChatEndpoint implements IChatEndpoint {
|
||||
}
|
||||
}
|
||||
|
||||
// Force temperature and top_p for Kimi models regardless of what the client would otherwise send (per Moonshot recommendations). Temperature 0 strongly increases chances of looping.
|
||||
if (isKimiFamily(this)) {
|
||||
body.temperature = 1;
|
||||
body.top_p = 0.95;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
|
||||
@@ -1174,7 +1174,16 @@ export class OpenAIResponsesProcessor {
|
||||
|
||||
switch (chunk.type) {
|
||||
case 'error':
|
||||
return onProgress({ text: '', copilotErrors: [{ agent: 'openai', code: chunk.code || 'unknown', message: chunk.message, type: 'error', identifier: chunk.param || undefined }] });
|
||||
// Surface the error as a progress delta, but also produce a terminal
|
||||
// completion so the request resolves to a meaningful server error
|
||||
// instead of collapsing into the generic "Response contained no
|
||||
// choices" fallback when the stream ends without a terminal event.
|
||||
onProgress({ text: '', copilotErrors: [{ agent: 'openai', code: chunk.code || 'unknown', message: chunk.message, type: 'error', identifier: chunk.param || undefined }] });
|
||||
return this.buildTerminalCompletion(
|
||||
{ output: [] } as unknown as CapiResponseTerminalEvent['response'],
|
||||
FinishedCompletionReason.ServerError,
|
||||
{ error: mapResponsesApiError({ code: chunk.code, message: chunk.message } as OpenAI.Responses.ResponseError) }
|
||||
);
|
||||
case 'response.output_text.delta': {
|
||||
const capiChunk: CapiResponsesTextDeltaEvent = chunk;
|
||||
// When text arrives from a new output item, emit a paragraph
|
||||
|
||||
@@ -1076,6 +1076,38 @@ describe('AutomodeService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit candidateModel from chosen_model, not candidate_models[0]', async () => {
|
||||
enableRouter();
|
||||
const codexEndpoint = createEndpoint('gpt-5.3-codex', 'OpenAI');
|
||||
const miniEndpoint = createEndpoint('gpt-5.4-mini', 'OpenAI');
|
||||
|
||||
// Server re-ranked the pick: candidate_models[0] is gpt-5.3-codex but the
|
||||
// authoritative chosen_model is gpt-5.4-mini. The telemetry candidateModel
|
||||
// must reflect chosen_model so router-pick vs actual comparisons are valid.
|
||||
mockRouterResponse(
|
||||
['gpt-5.3-codex', 'gpt-5.4-mini'],
|
||||
{ chosen_model: 'gpt-5.4-mini', candidate_models: ['gpt-5.3-codex', 'gpt-5.4-mini'] }
|
||||
);
|
||||
|
||||
automodeService = createService();
|
||||
const chatRequest: Partial<ChatRequest> = {
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'refactor this function',
|
||||
sessionId: 'session-telemetry-chosen-model'
|
||||
};
|
||||
|
||||
await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [codexEndpoint, miniEndpoint]);
|
||||
|
||||
const telemetryCalls = mockTelemetryService.sendMSFTTelemetryEvent.mock.calls;
|
||||
const selectionEvent = telemetryCalls.find((call: unknown[]) => call[0] === 'automode.routerModelSelection');
|
||||
expect(selectionEvent).toBeDefined();
|
||||
expect(selectionEvent![1]).toMatchObject({
|
||||
candidateModel: 'gpt-5.4-mini',
|
||||
actualModel: 'gpt-5.4-mini',
|
||||
overrideReason: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit overrideReason=clientOverride when vision fallback changes the model', async () => {
|
||||
enableRouter();
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI', { supportsVision: true });
|
||||
@@ -1234,13 +1266,15 @@ describe('AutomodeService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should iterate all candidate_models when first candidate has no endpoint', async () => {
|
||||
it('should fall back to candidate_models when chosen_model has no endpoint', async () => {
|
||||
enableRouter();
|
||||
const gpt41Endpoint = createEndpoint('gpt-4.1', 'OpenAI');
|
||||
|
||||
// chosen_model is not in knownEndpoints, so selection falls back to
|
||||
// the ordered candidate_models list and picks the first resolvable one.
|
||||
mockRouterResponse(
|
||||
['gpt-4.1'],
|
||||
{ chosen_model: 'gpt-4.1', candidate_models: ['unknown-new-model', 'gpt-4.1'] }
|
||||
{ chosen_model: 'unknown-new-model', candidate_models: ['unknown-new-model', 'gpt-4.1'] }
|
||||
);
|
||||
|
||||
automodeService = createService();
|
||||
@@ -1254,6 +1288,57 @@ describe('AutomodeService', () => {
|
||||
expect(result.model).toBe('gpt-4.1');
|
||||
});
|
||||
|
||||
it('should prefer chosen_model over candidate_models[0]', async () => {
|
||||
enableRouter();
|
||||
const codexEndpoint = createEndpoint('gpt-5.3-codex', 'OpenAI');
|
||||
const miniEndpoint = createEndpoint('gpt-5.4-mini', 'OpenAI');
|
||||
|
||||
// Server re-ranked the pick (e.g. Cost Sorting experiment): chosen_model
|
||||
// is gpt-5.4-mini even though candidate_models[0] is gpt-5.3-codex. The
|
||||
// client must send the chosen_model, per the auto-intent-service contract.
|
||||
mockRouterResponse(
|
||||
['gpt-5.3-codex', 'gpt-5.4-mini'],
|
||||
{ chosen_model: 'gpt-5.4-mini', candidate_models: ['gpt-5.3-codex', 'gpt-5.4-mini'] }
|
||||
);
|
||||
|
||||
automodeService = createService();
|
||||
const chatRequest: Partial<ChatRequest> = {
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'refactor this function',
|
||||
sessionId: 'session-chosen-model'
|
||||
};
|
||||
|
||||
const result = await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [codexEndpoint, miniEndpoint]);
|
||||
expect(result.model).toBe('gpt-5.4-mini');
|
||||
});
|
||||
|
||||
it('should surface chosen_model in the routing decision the UI displays', async () => {
|
||||
enableRouter();
|
||||
const codexEndpoint = createEndpoint('gpt-5.3-codex', 'OpenAI');
|
||||
const miniEndpoint = createEndpoint('gpt-5.4-mini', 'OpenAI');
|
||||
|
||||
// The "Routed to <model>" explainability label reads the routing
|
||||
// decision surfaced via consumeLastRoutingDecision(). It must match the
|
||||
// served endpoint (chosen_model), otherwise the label diverges from the
|
||||
// model shown in the response footer (candidate_models[0]).
|
||||
mockRouterResponse(
|
||||
['gpt-5.3-codex', 'gpt-5.4-mini'],
|
||||
{ chosen_model: 'gpt-5.4-mini', candidate_models: ['gpt-5.3-codex', 'gpt-5.4-mini'] }
|
||||
);
|
||||
|
||||
automodeService = createService();
|
||||
const chatRequest: Partial<ChatRequest> = {
|
||||
location: ChatLocation.Panel,
|
||||
prompt: 'refactor this function',
|
||||
sessionId: 'session-routing-decision'
|
||||
};
|
||||
|
||||
const result = await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [codexEndpoint, miniEndpoint]);
|
||||
const decision = automodeService.consumeLastRoutingDecision();
|
||||
expect(decision?.resolvedModel).toBe('gpt-5.4-mini');
|
||||
expect(decision?.resolvedModel).toBe(result.model);
|
||||
});
|
||||
|
||||
it('should fall back to first known endpoint when all available_models are unknown', async () => {
|
||||
enableRouter();
|
||||
const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI');
|
||||
|
||||
@@ -451,4 +451,49 @@ describe('ChatEndpoint - Image Count Validation', () => {
|
||||
expect(() => filterImages(endpoint, messages, 2)).toThrow(/Too many images/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatEndpoint - Kimi temperature and top_p override', () => {
|
||||
let mockServices: ReturnType<typeof createMockServices>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockServices = createMockServices();
|
||||
});
|
||||
|
||||
const createEndpoint = (metadata: IChatModelInformation) =>
|
||||
new ChatEndpoint(
|
||||
metadata,
|
||||
mockServices.domainService,
|
||||
mockServices.chatMLFetcher,
|
||||
mockServices.tokenizerProvider,
|
||||
mockServices.instantiationService,
|
||||
mockServices.configurationService,
|
||||
mockServices.expService,
|
||||
mockServices.chatWebSocketService,
|
||||
mockServices.logService
|
||||
);
|
||||
|
||||
const createTextMessage = (): Raw.ChatMessage => ({
|
||||
role: Raw.ChatRole.User,
|
||||
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }]
|
||||
});
|
||||
|
||||
const createOptionsWithPostOptions = (): ICreateEndpointBodyOptions => ({
|
||||
...createTestOptions([createTextMessage()]),
|
||||
postOptions: { temperature: 0, top_p: 1 }
|
||||
});
|
||||
|
||||
it.each(['kimi-k2.6', 'kimi-k2.7-code'])('should force temperature=1 and top_p=0.95 for %s', (family) => {
|
||||
const endpoint = createEndpoint(createNonAnthropicModelMetadata(family));
|
||||
const body = endpoint.createRequestBody(createOptionsWithPostOptions());
|
||||
expect(body.temperature).toBe(1);
|
||||
expect(body.top_p).toBe(0.95);
|
||||
});
|
||||
|
||||
it('should not override temperature or top_p for non-Kimi models', () => {
|
||||
const endpoint = createEndpoint(createNonAnthropicModelMetadata('gpt-4o'));
|
||||
const body = endpoint.createRequestBody(createOptionsWithPostOptions());
|
||||
expect(body.temperature).toBe(0);
|
||||
expect(body.top_p).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1916,4 +1916,28 @@ describe('processResponseFromChatEndpoint terminal events', () => {
|
||||
metadata: { code: 'internal_error' },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a raw error stream event to a terminal ServerError completion', async () => {
|
||||
// Root cause of #322209: a raw Responses API `error` stream event is only
|
||||
// surfaced as a `copilotErrors` progress delta and never yields a terminal
|
||||
// completion. With no completion, the downstream chat fetcher falls through
|
||||
// to the generic `RESPONSE_CONTAINED_NO_CHOICES` ("Response contained no
|
||||
// choices") instead of a meaningful server-error message.
|
||||
const errorEvent = {
|
||||
type: 'error',
|
||||
code: 'server_error',
|
||||
message: 'The server had an error while processing your request.',
|
||||
param: null,
|
||||
};
|
||||
|
||||
const [completion] = await runStream(`data: ${JSON.stringify(errorEvent)}\n\n`);
|
||||
|
||||
expect(completion).toBeDefined();
|
||||
expect(completion.finishReason).toBe(FinishedCompletionReason.ServerError);
|
||||
expect(completion.error).toEqual({
|
||||
code: 0,
|
||||
message: 'The server had an error while processing your request.',
|
||||
metadata: { code: 'server_error' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ export interface IGitService extends IDisposable {
|
||||
restore(uri: URI, paths: string[], options?: { staged?: boolean; ref?: string }): Promise<void>;
|
||||
|
||||
createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise<string | undefined>;
|
||||
deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise<void>;
|
||||
deleteWorktree(uri: URI, path: string, options?: { force?: boolean; label?: string }): Promise<void>;
|
||||
|
||||
migrateChanges(uri: URI, sourceRepositoryUri: URI, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise<void>;
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@ export class GitServiceImpl extends Disposable implements IGitService {
|
||||
return await repository?.createWorktree(options);
|
||||
}
|
||||
|
||||
async deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise<void> {
|
||||
async deleteWorktree(uri: URI, path: string, options?: { force?: boolean; label?: string }): Promise<void> {
|
||||
const gitAPI = this.gitExtensionService.getExtensionApi();
|
||||
const repository = gitAPI?.getRepository(uri);
|
||||
return await repository?.deleteWorktree(path, options);
|
||||
|
||||
+1
-1
@@ -315,7 +315,7 @@ export interface Repository {
|
||||
dropStash(index?: number): Promise<void>;
|
||||
|
||||
createWorktree(options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise<string>;
|
||||
deleteWorktree(path: string, options?: { force?: boolean }): Promise<void>;
|
||||
deleteWorktree(path: string, options?: { force?: boolean; label?: string }): Promise<void>;
|
||||
|
||||
migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise<void>;
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ export class MockGitService implements IGitService {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
deleteWorktree(_uri: URI, _path: string, _options?: { force?: boolean }): Promise<void> {
|
||||
deleteWorktree(_uri: URI, _path: string, _options?: { force?: boolean; label?: string }): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
||||
@@ -801,7 +801,7 @@ export class TestingGitService implements IGitService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise<void> {
|
||||
async deleteWorktree(uri: URI, path: string, options?: { force?: boolean; label?: string }): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ export class ApiRepository implements Repository {
|
||||
return this.#repository.createWorktree(options);
|
||||
}
|
||||
|
||||
deleteWorktree(path: string, options?: { force?: boolean }): Promise<void> {
|
||||
deleteWorktree(path: string, options?: { force?: boolean; label?: string }): Promise<void> {
|
||||
return this.#repository.deleteWorktree(path, options);
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -326,7 +326,7 @@ export interface Repository {
|
||||
dropStash(index?: number): Promise<void>;
|
||||
|
||||
createWorktree(options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise<string>;
|
||||
deleteWorktree(path: string, options?: { force?: boolean }): Promise<void>;
|
||||
deleteWorktree(path: string, options?: { force?: boolean; label?: string }): Promise<void>;
|
||||
|
||||
migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise<void>;
|
||||
|
||||
|
||||
@@ -2120,7 +2120,7 @@ export class Repository implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteWorktree(path: string, options?: { force?: boolean }): Promise<void> {
|
||||
async deleteWorktree(path: string, options?: { force?: boolean; label?: string }): Promise<void> {
|
||||
await this.run(Operation.Worktree(false), async () => {
|
||||
const worktree = this.repositoryResolver.getRepository(path);
|
||||
|
||||
@@ -2130,12 +2130,14 @@ export class Repository implements Disposable {
|
||||
};
|
||||
|
||||
try {
|
||||
await deleteWorktree();
|
||||
await deleteWorktree(options);
|
||||
} catch (err) {
|
||||
if (err.gitErrorCode === GitErrorCodes.WorktreeContainsChanges) {
|
||||
const forceDelete = l10n.t('Force Delete');
|
||||
const message = l10n.t('The worktree contains modified or untracked files. Do you want to force delete?');
|
||||
const choice = await window.showWarningMessage(message, { modal: true }, forceDelete);
|
||||
const message = options?.label
|
||||
? l10n.t('The worktree for session "{0}" contains modified or untracked files. Do you want to force delete?', options.label)
|
||||
: l10n.t('The worktree contains modified or untracked files. Do you want to force delete?');
|
||||
const choice = await window.showWarningMessage(message, { modal: true, detail: l10n.t('Worktree: {0}', path) }, forceDelete);
|
||||
if (choice === forceDelete) {
|
||||
await deleteWorktree({ ...options, force: true });
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"input.background": "#191A1B",
|
||||
"input.border": "#333536FF",
|
||||
"input.foreground": "#bfbfbf",
|
||||
"input.placeholderForeground": "#555555",
|
||||
"input.placeholderForeground": "#828282",
|
||||
"inputOption.activeBackground": "#3994BC33",
|
||||
"inputOption.activeForeground": "#bfbfbf",
|
||||
"inputOption.activeBorder": "#2A2B2CFF",
|
||||
@@ -54,16 +54,15 @@
|
||||
"badge.background": "#3994BCF0",
|
||||
"badge.foreground": "#FFFFFF",
|
||||
"progressBar.background": "#878889",
|
||||
"list.activeSelectionBackground": "#3994BC26",
|
||||
"list.activeSelectionBackground": "#FFFFFF22",
|
||||
"list.activeSelectionForeground": "#ededed",
|
||||
"list.inactiveSelectionBackground": "#2C2D2E",
|
||||
"list.inactiveSelectionForeground": "#ededed",
|
||||
"list.hoverBackground": "#1E1F20",
|
||||
"list.hoverBackground": "#FFFFFF14",
|
||||
"list.hoverForeground": "#bfbfbf",
|
||||
"list.dropBackground": "#3994BC1A",
|
||||
"toolbar.hoverBackground": "#323233",
|
||||
"toolbar.activeBackground": "#3C3C3D",
|
||||
"list.focusBackground": "#3994BC26",
|
||||
"toolbar.activeBackground": "#FFFFFF33",
|
||||
"list.focusBackground": "#FFFFFF22",
|
||||
"list.focusForeground": "#bfbfbf",
|
||||
"list.focusOutline": "#3994BCB3",
|
||||
"list.highlightForeground": "#48A0C7",
|
||||
@@ -139,7 +138,7 @@
|
||||
"editorSuggestWidget.border": "#2A2B2CFF",
|
||||
"editorSuggestWidget.foreground": "#bfbfbf",
|
||||
"editorSuggestWidget.highlightForeground": "#bfbfbf",
|
||||
"editorSuggestWidget.selectedBackground": "#3994BC26",
|
||||
"editorSuggestWidget.selectedBackground": "#FFFFFF26",
|
||||
"editorHoverWidget.background": "#202122",
|
||||
"editorHoverWidget.border": "#2A2B2CFF",
|
||||
"widget.border": "#2A2B2CFF",
|
||||
@@ -235,7 +234,7 @@
|
||||
"quickInputList.focusForeground": "#FFFFFF",
|
||||
"quickInputList.focusIconForeground": "#FFFFFF",
|
||||
"quickInputList.focusHighlightForeground": "#FFFFFF",
|
||||
"quickInputList.hoverBackground": "#1E1F20",
|
||||
"quickInputList.hoverBackground": "#FFFFFF14",
|
||||
"terminal.selectionBackground": "#3994BC33",
|
||||
"terminal.background": "#191A1B",
|
||||
"terminal.border": "#2A2B2CFF",
|
||||
@@ -289,7 +288,7 @@
|
||||
"agentsChatInput.foreground": "#bfbfbf",
|
||||
"agentsChatInput.border": "#333536",
|
||||
"agentsChatInput.focusBorder": "#3994BCB3",
|
||||
"agentsChatInput.placeholderForeground": "#555555",
|
||||
"agentsChatInput.placeholderForeground": "#828282",
|
||||
"agentsNewSessionButton.background": "#00000000",
|
||||
"agentsNewSessionButton.foreground": "#bfbfbf",
|
||||
"agentsNewSessionButton.border": "#333536",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"input.background": "#FFFFFF",
|
||||
"input.border": "#D8D8D866",
|
||||
"input.foreground": "#202020",
|
||||
"input.placeholderForeground": "#999999",
|
||||
"input.placeholderForeground": "#767676",
|
||||
"inputOption.activeBackground": "#0069CC26",
|
||||
"inputOption.activeForeground": "#202020",
|
||||
"inputOption.activeBorder": "#F0F1F2FF",
|
||||
@@ -58,20 +58,20 @@
|
||||
"sideBarStickyScroll.shadow": "#00000000",
|
||||
"panelStickyScroll.shadow": "#00000000",
|
||||
"listFilterWidget.shadow": "#00000000",
|
||||
"scrollbarSlider.background": "#64646480",
|
||||
"scrollbarSlider.hoverBackground": "#64646490",
|
||||
"scrollbarSlider.activeBackground": "#646464A0",
|
||||
"scrollbarSlider.background": "#646464C0",
|
||||
"scrollbarSlider.hoverBackground": "#646464D0",
|
||||
"scrollbarSlider.activeBackground": "#646464E0",
|
||||
"badge.background": "#0069CC",
|
||||
"badge.foreground": "#FFFFFF",
|
||||
"progressBar.background": "#0069CC",
|
||||
"list.activeSelectionBackground": "#0069CC1A",
|
||||
"list.activeSelectionBackground": "#00000025",
|
||||
"list.activeSelectionForeground": "#202020",
|
||||
"list.inactiveSelectionBackground": "#DADADA99",
|
||||
"list.inactiveSelectionForeground": "#202020",
|
||||
"list.hoverBackground": "#F1F1F3",
|
||||
"list.hoverBackground": "#00000014",
|
||||
"list.hoverForeground": "#202020",
|
||||
"list.dropBackground": "#0069CC15",
|
||||
"list.focusBackground": "#0069CC1A",
|
||||
"list.focusBackground": "#00000025",
|
||||
"list.focusForeground": "#202020",
|
||||
"list.focusOutline": "#0069CCFF",
|
||||
"list.highlightForeground": "#0069CC",
|
||||
@@ -144,7 +144,7 @@
|
||||
"editorSuggestWidget.border": "#E4E5E6FF",
|
||||
"editorSuggestWidget.foreground": "#202020",
|
||||
"editorSuggestWidget.highlightForeground": "#0069CC",
|
||||
"editorSuggestWidget.selectedBackground": "#0069CC26",
|
||||
"editorSuggestWidget.selectedBackground": "#00000025",
|
||||
"editorSuggestWidget.selectedForeground": "#202020",
|
||||
"editorSuggestWidget.selectedIconForeground": "#202020",
|
||||
"editorHoverWidget.background": "#FAFAFD",
|
||||
@@ -226,6 +226,7 @@
|
||||
"notificationsWarningIcon.foreground": "#B69500",
|
||||
"notificationsErrorIcon.foreground": "#ad0707",
|
||||
"notificationsInfoIcon.foreground": "#0069CC",
|
||||
"problemsWarningIcon.foreground": "#895503",
|
||||
"activityWarningBadge.foreground": "#202020",
|
||||
"activityWarningBadge.background": "#F2C94C",
|
||||
"activityErrorBadge.foreground": "#FFFFFF",
|
||||
@@ -241,7 +242,7 @@
|
||||
"quickInputList.focusForeground": "#FFFFFF",
|
||||
"quickInputList.focusIconForeground": "#FFFFFF",
|
||||
"quickInputList.focusHighlightForeground": "#FFFFFF",
|
||||
"quickInputList.hoverBackground": "#F1F1F3",
|
||||
"quickInputList.hoverBackground": "#00000014",
|
||||
"terminal.selectionBackground": "#0069CC26",
|
||||
"terminalCursor.foreground": "#202020",
|
||||
"terminalCursor.background": "#FFFFFF",
|
||||
@@ -282,9 +283,9 @@
|
||||
"charts.purple": "#652D90",
|
||||
"agentStatusIndicator.background": "#FFFFFF",
|
||||
"inlineChat.border": "#00000000",
|
||||
"minimapSlider.background": "#64646480",
|
||||
"minimapSlider.hoverBackground": "#64646490",
|
||||
"minimapSlider.activeBackground": "#646464A0",
|
||||
"minimapSlider.background": "#646464C0",
|
||||
"minimapSlider.hoverBackground": "#646464D0",
|
||||
"minimapSlider.activeBackground": "#646464E0",
|
||||
|
||||
"agents.background": "#FAFAFD",
|
||||
"agentsPanel.background": "#FFFFFF",
|
||||
@@ -295,7 +296,7 @@
|
||||
"agentsChatInput.foreground": "#202020",
|
||||
"agentsChatInput.border": "#D8D8D8",
|
||||
"agentsChatInput.focusBorder": "#0069CCFF",
|
||||
"agentsChatInput.placeholderForeground": "#999999",
|
||||
"agentsChatInput.placeholderForeground": "#767676",
|
||||
"agentsNewSessionButton.background": "#00000000",
|
||||
"agentsNewSessionButton.foreground": "#202020",
|
||||
"agentsNewSessionButton.border": "#D8D8D8",
|
||||
|
||||
@@ -293,7 +293,7 @@ import { assertNoRpc, closeAllEditors } from '../utils';
|
||||
throw new Error(`Timed out waiting for trust-harness title to update (target=${target}, last title=${tab.title})`);
|
||||
}
|
||||
|
||||
test('file:// inside a trusted workspace folder loads', async function () {
|
||||
test.skip('file:// inside a trusted workspace folder loads', async function () {
|
||||
this.timeout(30_000);
|
||||
|
||||
const folders = workspace.workspaceFolders!;
|
||||
@@ -303,7 +303,11 @@ import { assertNoRpc, closeAllEditors } from '../utils';
|
||||
assert.ok(title.startsWith('status:200'), `Expected status 200 for trusted file, got: ${title}`);
|
||||
});
|
||||
|
||||
test('file:// outside any trusted root is blocked with 403', async function () {
|
||||
// Skipped: the test runner always launches with `--disable-workspace-trust`,
|
||||
// which makes the browser view trust all `file://` requests (see
|
||||
// `trustAllFiles` in `IBrowserViewWindowConfiguration`). Re-enable once the
|
||||
// test infrastructure supports running with Workspace Trust enabled.
|
||||
test.skip('file:// outside any trusted root is blocked with 403', async function () {
|
||||
this.timeout(30_000);
|
||||
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'vscode-browser-trust-'));
|
||||
|
||||
Generated
+52
-40
@@ -30,7 +30,7 @@
|
||||
"@vscode/iconv-lite-umd": "0.7.1",
|
||||
"@vscode/native-watchdog": "^1.4.6",
|
||||
"@vscode/policy-watcher": "^1.4.0",
|
||||
"@vscode/proxy-agent": "^0.42.0",
|
||||
"@vscode/proxy-agent": "^0.43.0",
|
||||
"@vscode/ripgrep-universal": "^1.18.0",
|
||||
"@vscode/sandbox-runtime": "0.0.1",
|
||||
"@vscode/spdlog": "^0.15.8",
|
||||
@@ -77,7 +77,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.198",
|
||||
"@openai/codex": "0.142.0",
|
||||
"@playwright/cli": "^0.1.9",
|
||||
"@playwright/test": "^1.61.1",
|
||||
@@ -185,23 +185,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.187.tgz",
|
||||
"integrity": "sha512-HHkuC3he/Wi8fX/WjfS8mJr4TFPGoAd1QyxOuTwjnyOLboGkX1H+UhBYLxHX1ouFvHnYVJglB2wsuWemVQgahQ==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.198.tgz",
|
||||
"integrity": "sha512-xt469sSCyclTPtzpLAg0Aschy665GiRMgZKabSmESbGUA5/H56HcILVOiFxclXswkeMUk2fQxfHJUY9UZfiTnA==",
|
||||
"dev": true,
|
||||
"license": "SEE LICENSE IN README.md",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.187"
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.198",
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.198"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.93.0",
|
||||
@@ -210,9 +210,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.187.tgz",
|
||||
"integrity": "sha512-0DNzATzaRmjS/Q1T4T9SLP/VT67gRX7J4reYPnEdcTm91lJwjqOPkHr17+sv+7DoerZ421ZG38dPsQd/V67qQw==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.198.tgz",
|
||||
"integrity": "sha512-ZmiAybQKIKcP1qEAE/vfXvfxtKxG9CnJn98QTXC5Zxiwuy7Mllx2ALXh9dfmsf0V87CGEodlZQmMgUJotNIsUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -224,9 +224,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.187.tgz",
|
||||
"integrity": "sha512-Va3O8lTDa9IHq/DbSJwU63JJknNV3YCBh4PEznOHXJtyFoXHgRjrks4QQvN3baZm79avVq2sn+6tvpTz9CuY8A==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.198.tgz",
|
||||
"integrity": "sha512-XwH5vgN46WSwg8aC1OagNofnJpV/G1ciEu118GEKer8ZhVkq/dvK/DqShxMkb6r1jV7u5IJ7zPXu9uKliyNJAw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -238,13 +238,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.187.tgz",
|
||||
"integrity": "sha512-VEvrs3MgwckdWRNa7+2rJdyOBbCWGoRaM6OnL+/n9qi4gKlZnXZlj/90Tw9o8ttcN260Opz120/SE1fYwzu/JA==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.198.tgz",
|
||||
"integrity": "sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -252,13 +255,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.187.tgz",
|
||||
"integrity": "sha512-XQ7w2pX0mjPYUC7ayTKiHeAbePOny/ezBATxTWt5JVDZZPfljzvHA0jyXHsN8aLphbgRUfbUpdrtt5b57Bhx5g==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.198.tgz",
|
||||
"integrity": "sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -266,13 +272,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.187.tgz",
|
||||
"integrity": "sha512-s/Fmg29tzMrbdeCbseTpL3DlzfWineSQ3bDPvhdKw4jcsgLC1YgQhIkAyE8WlceUyhQyw82NAUgYoPwfGny6hQ==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.198.tgz",
|
||||
"integrity": "sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -280,13 +289,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.187.tgz",
|
||||
"integrity": "sha512-yi2/L5oztFqTDnAZl4mWOINr+r7WBot70gYUxG2jOoqdYUl6j50vG/ME605X80v+l2qBmKjSGxb5trY/6DkRzQ==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.198.tgz",
|
||||
"integrity": "sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -294,9 +306,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.187.tgz",
|
||||
"integrity": "sha512-HjEl3cDRLAWKopdlrLI54fxTVWq4lZpWA4bTTBVc9UDdDj6AmJIRHKxaCCYLQRvnoswbAUF1sJmJ8EFJ+b6lfw==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.198.tgz",
|
||||
"integrity": "sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -308,9 +320,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
|
||||
"version": "0.3.187",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.187.tgz",
|
||||
"integrity": "sha512-FnrLhoZ8y/+gkZynL12UeQ8pWKCu6B/8myyRrYMNJRZqFw4DZlPvPCywAjFZf1e1hiXCoiSK0Orl5wzKfAeQsQ==",
|
||||
"version": "0.3.198",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.198.tgz",
|
||||
"integrity": "sha512-y3HLuCCz1kDwUrhd6OnqO+d5BUpTFSzNUsPT9kf3r1vk9HYKF+eMC9eIlcOhiW2kX491kxEvuEOfqgIkGx15cg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3885,9 +3897,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/proxy-agent": {
|
||||
"version": "0.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.42.0.tgz",
|
||||
"integrity": "sha512-uFEBHiWPtBdbn+BFBVzyCMqqhdxRaRdPawLen1JZ+zM8pdKHsrVO+smmo/PbM6HgHr+MKGezDmxZ9cEHv49gEQ==",
|
||||
"version": "0.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.43.0.tgz",
|
||||
"integrity": "sha512-FkZUID6bSDSSMZb981WVGTJSxlnCfGMcNDGNJIzwnyIhVdNTLlSSyIS6K6Ks0ALs5DpS82VhNHRC3PJIqonTBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tootallnate/once": "^3.0.0",
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "code-oss-dev",
|
||||
"version": "1.128.0",
|
||||
"distro": "1ec113de3e26ba3133915ecee3b613f0b7740ede",
|
||||
"distro": "a78eb8284ce7699e371b36071b579f557406f153",
|
||||
"author": {
|
||||
"name": "Microsoft Corporation"
|
||||
},
|
||||
@@ -114,7 +114,7 @@
|
||||
"@vscode/iconv-lite-umd": "0.7.1",
|
||||
"@vscode/native-watchdog": "^1.4.6",
|
||||
"@vscode/policy-watcher": "^1.4.0",
|
||||
"@vscode/proxy-agent": "^0.42.0",
|
||||
"@vscode/proxy-agent": "^0.43.0",
|
||||
"@vscode/ripgrep-universal": "^1.18.0",
|
||||
"@vscode/sandbox-runtime": "0.0.1",
|
||||
"@vscode/spdlog": "^0.15.8",
|
||||
@@ -161,7 +161,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.187",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.198",
|
||||
"@openai/codex": "0.142.0",
|
||||
"@playwright/cli": "^0.1.9",
|
||||
"@playwright/test": "^1.61.1",
|
||||
|
||||
@@ -5,4 +5,3 @@ runtime="node"
|
||||
build_from_source="true"
|
||||
legacy-peer-deps="true"
|
||||
timeout=180000
|
||||
min-release-age="1"
|
||||
|
||||
Generated
+4
-4
@@ -18,7 +18,7 @@
|
||||
"@vscode/deviceid": "^0.1.1",
|
||||
"@vscode/iconv-lite-umd": "0.7.1",
|
||||
"@vscode/native-watchdog": "^1.4.6",
|
||||
"@vscode/proxy-agent": "^0.42.0",
|
||||
"@vscode/proxy-agent": "^0.43.0",
|
||||
"@vscode/ripgrep-universal": "^1.18.0",
|
||||
"@vscode/sandbox-runtime": "0.0.1",
|
||||
"@vscode/spdlog": "^0.15.8",
|
||||
@@ -658,9 +658,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vscode/proxy-agent": {
|
||||
"version": "0.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.42.0.tgz",
|
||||
"integrity": "sha512-uFEBHiWPtBdbn+BFBVzyCMqqhdxRaRdPawLen1JZ+zM8pdKHsrVO+smmo/PbM6HgHr+MKGezDmxZ9cEHv49gEQ==",
|
||||
"version": "0.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.43.0.tgz",
|
||||
"integrity": "sha512-FkZUID6bSDSSMZb981WVGTJSxlnCfGMcNDGNJIzwnyIhVdNTLlSSyIS6K6Ks0ALs5DpS82VhNHRC3PJIqonTBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tootallnate/once": "^3.0.0",
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"@vscode/deviceid": "^0.1.1",
|
||||
"@vscode/iconv-lite-umd": "0.7.1",
|
||||
"@vscode/native-watchdog": "^1.4.6",
|
||||
"@vscode/proxy-agent": "^0.42.0",
|
||||
"@vscode/proxy-agent": "^0.43.0",
|
||||
"@vscode/ripgrep-universal": "^1.18.0",
|
||||
"@vscode/sandbox-runtime": "0.0.1",
|
||||
"@vscode/spdlog": "^0.15.8",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export interface ISynchronizeAnimationsOptions {
|
||||
/**
|
||||
* Also synchronize animations running on descendant elements (e.g. the dots
|
||||
* of a spinner whose animations live on child nodes). Defaults to `false`.
|
||||
*/
|
||||
readonly subtree?: boolean;
|
||||
|
||||
/**
|
||||
* When provided, further narrows synchronization to CSS animations whose
|
||||
* `animation-name` is in this set. Non-keyframe animations (e.g. transitions)
|
||||
* are always skipped regardless of this option.
|
||||
*/
|
||||
readonly animationNames?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase-aligns looping CSS animations so that every animation of the same
|
||||
* duration displays the same frame at the same time, regardless of when each
|
||||
* one started.
|
||||
*
|
||||
* All CSS animations share the document's timeline, so anchoring each
|
||||
* animation's `startTime` to the same origin (`0`) forces their `currentTime`
|
||||
* to equal the timeline time — making identical animations run in lock-step.
|
||||
* Per-element `animation-delay` offsets are preserved (they are part of each
|
||||
* animation's own timing), so intentional cascades (e.g. spinner dots) still
|
||||
* work while the group as a whole stays globally in phase.
|
||||
*
|
||||
* Unlike adjusting `animation-delay`, this re-seeks animations that are already
|
||||
* running (Chromium does not reliably re-seek a running animation when its
|
||||
* `animation-delay` changes). Call it whenever an animation (re)starts or
|
||||
* resumes after being paused offscreen — e.g. from an `animationstart` handler
|
||||
* or when an element scrolls back into view.
|
||||
*
|
||||
* @param element The element whose (and optionally whose descendants') CSS
|
||||
* animations should be synchronized.
|
||||
* @param options See {@link ISynchronizeAnimationsOptions}.
|
||||
*/
|
||||
export function synchronizeCSSAnimations(element: HTMLElement, options?: ISynchronizeAnimationsOptions): void {
|
||||
if (typeof element.getAnimations !== 'function') {
|
||||
return; // Web Animations API not available; leave animations as-is.
|
||||
}
|
||||
for (const animation of element.getAnimations({ subtree: options?.subtree })) {
|
||||
// Only CSS keyframe animations carry an `animationName`; skip transitions
|
||||
// and other Web Animations so this helper strictly aligns CSS animations.
|
||||
const animationName = (animation as CSSAnimation).animationName;
|
||||
if (animationName === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (options?.animationNames && !options.animationNames.has(animationName)) {
|
||||
continue;
|
||||
}
|
||||
// Anchor to a shared origin so all animations of the same duration display
|
||||
// the same frame. Guard against the rare state where startTime is not yet
|
||||
// settable (e.g. an animation still in its pending/ready phase).
|
||||
try {
|
||||
animation.startTime = 0;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,9 @@ export interface MarkdownRenderOptions {
|
||||
|
||||
readonly actionHandler?: MarkdownActionHandler;
|
||||
|
||||
/** Rewrites parsed Markdown link and image destinations before sanitization. */
|
||||
readonly transformUri?: (href: string, kind: 'link' | 'image') => string;
|
||||
|
||||
readonly fillInIncompleteTokens?: boolean;
|
||||
|
||||
readonly sanitizerConfig?: MarkdownSanitizerConfig;
|
||||
@@ -85,26 +88,28 @@ function getLinkTitle(href: string): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
const defaultMarkedRenderers = Object.freeze({
|
||||
image: ({ href, title, text }: marked.Tokens.Image): string => {
|
||||
let dimensions: string[] = [];
|
||||
let attributes: string[] = [];
|
||||
if (href) {
|
||||
({ href, dimensions } = parseHrefAndDimensions(href));
|
||||
attributes.push(`src="${escapeDoubleQuotes(href)}"`);
|
||||
}
|
||||
if (text) {
|
||||
attributes.push(`alt="${escapeDoubleQuotes(text)}"`);
|
||||
}
|
||||
if (title) {
|
||||
attributes.push(`title="${escapeDoubleQuotes(title)}"`);
|
||||
}
|
||||
if (dimensions.length) {
|
||||
attributes = attributes.concat(dimensions);
|
||||
}
|
||||
return '<img ' + attributes.join(' ') + '>';
|
||||
},
|
||||
function renderImage({ href, title, text }: marked.Tokens.Image, transformUri?: (href: string) => string): string {
|
||||
let dimensions: string[] = [];
|
||||
let attributes: string[] = [];
|
||||
if (href) {
|
||||
({ href, dimensions } = parseHrefAndDimensions(href));
|
||||
href = transformUri?.(href) ?? href;
|
||||
attributes.push(`src="${escapeDoubleQuotes(href)}"`);
|
||||
}
|
||||
if (text) {
|
||||
attributes.push(`alt="${escapeDoubleQuotes(text)}"`);
|
||||
}
|
||||
if (title) {
|
||||
attributes.push(`title="${escapeDoubleQuotes(title)}"`);
|
||||
}
|
||||
if (dimensions.length) {
|
||||
attributes = attributes.concat(dimensions);
|
||||
}
|
||||
return '<img ' + attributes.join(' ') + '>';
|
||||
}
|
||||
|
||||
const defaultMarkedRenderers = Object.freeze({
|
||||
image: renderImage,
|
||||
paragraph(this: marked.Renderer, { tokens }: marked.Tokens.Paragraph): string {
|
||||
return `<p>${this.parser.parseInline(tokens)}</p>`;
|
||||
},
|
||||
@@ -389,8 +394,11 @@ function rewriteRenderedLinks(markdown: IMarkdownString, options: MarkdownRender
|
||||
|
||||
function createMarkdownRenderer(marked: marked.Marked, options: MarkdownRenderOptions, markdown: IMarkdownString): { renderer: marked.Renderer; codeBlocks: Promise<[string, HTMLElement]>[]; syncCodeBlocks: [string, HTMLElement][] } {
|
||||
const renderer = new marked.Renderer(options.markedOptions);
|
||||
renderer.image = defaultMarkedRenderers.image;
|
||||
renderer.link = defaultMarkedRenderers.link;
|
||||
renderer.image = token => renderImage(token, href => options.transformUri?.(href, 'image') ?? href);
|
||||
renderer.link = token => defaultMarkedRenderers.link.call(renderer, {
|
||||
...token,
|
||||
href: options.transformUri?.(token.href, 'link') ?? token.href,
|
||||
});
|
||||
renderer.paragraph = defaultMarkedRenderers.paragraph;
|
||||
|
||||
if (markdown.supportAlertSyntax) {
|
||||
@@ -816,27 +824,11 @@ function completeSingleLinePattern(token: marked.Tokens.Text | marked.Tokens.Par
|
||||
if (subtoken.type === 'text') {
|
||||
const lines = subtoken.raw.split('\n');
|
||||
const lastLine = lines[lines.length - 1];
|
||||
if (lastLine.includes('`')) {
|
||||
return completeCodespan(token);
|
||||
}
|
||||
|
||||
else if (lastLine.includes('**')) {
|
||||
return completeDoublestar(token);
|
||||
}
|
||||
|
||||
else if (lastLine.match(/\*\w/)) {
|
||||
return completeStar(token);
|
||||
}
|
||||
|
||||
else if (lastLine.match(/(^|\s)__\w/)) {
|
||||
return completeDoubleUnderscore(token);
|
||||
}
|
||||
|
||||
else if (lastLine.match(/(^|\s)_\w/)) {
|
||||
return completeUnderscore(token);
|
||||
}
|
||||
|
||||
else if (
|
||||
// An incomplete link target must be completed before emphasis/codespan. The link is the
|
||||
// innermost unfinished construct, so any emphasis marker (e.g. the `**` in `**[text](htt`)
|
||||
// belongs to an enclosing span. Completing the emphasis first would leave the link broken.
|
||||
if (
|
||||
// Text with start of link target
|
||||
hasLinkTextAndStartOfLinkTarget(lastLine) ||
|
||||
// This token doesn't have the link text, eg if it contains other markdown constructs that are in other subtokens.
|
||||
@@ -860,6 +852,26 @@ function completeSingleLinePattern(token: marked.Tokens.Text | marked.Tokens.Par
|
||||
return completeLinkTarget(token);
|
||||
}
|
||||
|
||||
else if (lastLine.includes('`')) {
|
||||
return completeCodespan(token);
|
||||
}
|
||||
|
||||
else if (lastLine.includes('**')) {
|
||||
return completeDoublestar(token);
|
||||
}
|
||||
|
||||
else if (lastLine.match(/\*\w/)) {
|
||||
return completeStar(token);
|
||||
}
|
||||
|
||||
else if (lastLine.match(/(^|\s)__\w/)) {
|
||||
return completeDoubleUnderscore(token);
|
||||
}
|
||||
|
||||
else if (lastLine.match(/(^|\s)_\w/)) {
|
||||
return completeUnderscore(token);
|
||||
}
|
||||
|
||||
// Contains the start of link text, and no following tokens contain the link target
|
||||
else if (lastLine.match(/(^|\s)\[\w*[^\]]*$/)) {
|
||||
return completeLinkText(token);
|
||||
@@ -871,7 +883,9 @@ function completeSingleLinePattern(token: marked.Tokens.Text | marked.Tokens.Par
|
||||
}
|
||||
|
||||
function hasLinkTextAndStartOfLinkTarget(str: string): boolean {
|
||||
return !!str.match(/(^|\s)\[.*\]\(\w*/);
|
||||
// The `[` may be preceded by start-of-line, whitespace, or an emphasis/strikethrough marker
|
||||
// (e.g. `**[text](htt`) so that links nested inside bold/italic/strikethrough are detected.
|
||||
return !!str.match(/(^|\s|\*|_|~)\[.*\]\(\w*/);
|
||||
}
|
||||
|
||||
function hasStartOfLinkTargetAndNoLinkText(str: string): boolean {
|
||||
|
||||
@@ -58,6 +58,13 @@ export interface IDialogOptions {
|
||||
readonly disableCloseAction?: boolean;
|
||||
readonly disableCloseButton?: boolean;
|
||||
readonly disableDefaultAction?: boolean;
|
||||
/**
|
||||
* Temporary escape hatch for dialogs that embed widgets whose popups mount
|
||||
* at window root (outside the dialog DOM). Needed because the focus trap
|
||||
* would otherwise immediately reclaim focus from context views and pickers.
|
||||
* See https://github.com/microsoft/vscode/issues/323920 for removal plan.
|
||||
*/
|
||||
readonly isExternalFocusAllowed?: (relatedTarget: HTMLElement) => boolean;
|
||||
readonly onVisibilityChange?: (window: Window, visible: boolean) => void;
|
||||
readonly buttonStyles: IButtonStyles;
|
||||
readonly checkboxStyles: ICheckboxStyles;
|
||||
@@ -484,6 +491,11 @@ export class Dialog extends Disposable {
|
||||
this._register(addDisposableListener(this.element, 'focusout', e => {
|
||||
if (!!e.relatedTarget && !!this.element) {
|
||||
if (!isAncestor(e.relatedTarget as HTMLElement, this.element)) {
|
||||
// Temporary: let focus escape for body-level popups.
|
||||
// See https://github.com/microsoft/vscode/issues/323920
|
||||
if (this.options.isExternalFocusAllowed?.(e.relatedTarget as HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
this.focusToReturn = e.relatedTarget as HTMLElement;
|
||||
|
||||
if (e.target) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { getWindow, h, onDidUnregisterWindow } from '../../dom.js';
|
||||
import { synchronizeCSSAnimations } from '../../animationSync.js';
|
||||
import { CodeWindow } from '../../window.js';
|
||||
import { IDisposable } from '../../../common/lifecycle.js';
|
||||
import './pixelSpinner.css';
|
||||
@@ -57,6 +58,15 @@ export function createPixelSpinner(parent?: HTMLElement, options?: IPixelSpinner
|
||||
|
||||
|
||||
const PAUSED_CLASS = 'monaco-pixel-spinner-paused';
|
||||
// Keyframes names used by the spinner variants (see pixelSpinner.css). The sync
|
||||
// is scoped to these so it never disturbs unrelated animations/transitions
|
||||
// (e.g. the icon cross-fade) that may run on the same subtree.
|
||||
const SPINNER_ANIMATION_NAMES = new Set([
|
||||
'monaco-pixel-spinner-dot-cycle',
|
||||
'monaco-pixel-spinner-dot-cycle-long',
|
||||
'monaco-pixel-spinner-dot-cycle-short',
|
||||
'monaco-pixel-spinner-ring-pulse',
|
||||
]);
|
||||
const observersByWindow = new Map<CodeWindow, IntersectionObserver>();
|
||||
let unregisterWindowListener: IDisposable | undefined;
|
||||
|
||||
@@ -67,6 +77,11 @@ function getObserverFor(targetWindow: CodeWindow): IntersectionObserver | undefi
|
||||
let observer = observersByWindow.get(targetWindow);
|
||||
if (!observer) {
|
||||
observer = new targetWindow.IntersectionObserver(entries => {
|
||||
// Two passes so all style writes happen before any style read: the
|
||||
// pause-class toggles below dirty style, and `getAnimations()` in the
|
||||
// sync pass flushes it. Interleaving them would force a style recalc
|
||||
// per entry instead of one for the whole batch.
|
||||
const toResync: HTMLElement[] = [];
|
||||
for (const entry of entries) {
|
||||
const target = entry.target as HTMLElement;
|
||||
if (!target.isConnected) {
|
||||
@@ -74,6 +89,16 @@ function getObserverFor(targetWindow: CodeWindow): IntersectionObserver | undefi
|
||||
continue;
|
||||
}
|
||||
target.classList.toggle(PAUSED_CLASS, !entry.isIntersecting);
|
||||
if (entry.isIntersecting) {
|
||||
toResync.push(target);
|
||||
}
|
||||
}
|
||||
// Re-sync resumed spinners to the shared timeline: while paused
|
||||
// offscreen the animation froze and its startTime drifted from
|
||||
// spinners that kept running. Anchor it back (now that it is running
|
||||
// again) so all visible spinners display the same frame.
|
||||
for (const target of toResync) {
|
||||
synchronizeCSSAnimations(target, { subtree: true, animationNames: SPINNER_ANIMATION_NAMES });
|
||||
}
|
||||
});
|
||||
observersByWindow.set(targetWindow, observer);
|
||||
@@ -101,4 +126,3 @@ function trackSpinner(root: HTMLElement): void {
|
||||
root.classList.add(PAUSED_CLASS);
|
||||
observer.observe(root);
|
||||
}
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@ function pctEncode(str: string): string {
|
||||
) {
|
||||
out += str[i];
|
||||
} else {
|
||||
out += '%' + chr.toString(16).toUpperCase();
|
||||
out += '%' + chr.toString(16).toUpperCase().padStart(2, '0');
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -52,6 +52,34 @@ suite('MarkdownRenderer', () => {
|
||||
assert.ok(anchor, 'expected <a> to be preserved when scheme is allowed');
|
||||
assert.strictEqual(anchor!.dataset.href, 'vscode-agent-host://my-host/path/to/foo.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0');
|
||||
});
|
||||
|
||||
test('Transforms parsed link targets without changing labels, titles, or code', () => {
|
||||
const markdown = { value: '`[same](file:///same)` [a[b].ts](file:///same "file:///same") ' };
|
||||
const result = store.add(renderMarkdown(markdown, {
|
||||
transformUri: href => href === 'file:///same' ? 'https://example.com/a.ts' : href,
|
||||
})).element;
|
||||
const anchor = result.querySelector('a');
|
||||
assert.deepStrictEqual(
|
||||
{
|
||||
anchorCount: result.querySelectorAll('a').length,
|
||||
text: anchor?.textContent,
|
||||
href: anchor?.dataset.href,
|
||||
title: anchor?.title,
|
||||
image: result.querySelector('img')?.src,
|
||||
imageWidth: result.querySelector('img')?.getAttribute('width'),
|
||||
imageHeight: result.querySelector('img')?.getAttribute('height'),
|
||||
},
|
||||
{
|
||||
anchorCount: 1,
|
||||
text: 'a[b].ts',
|
||||
href: 'https://example.com/a.ts',
|
||||
title: 'file:///same',
|
||||
image: 'https://example.com/a.ts',
|
||||
imageWidth: '10',
|
||||
imageHeight: '20',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
suite('Images', () => {
|
||||
@@ -818,6 +846,26 @@ suite('MarkdownRenderer', () => {
|
||||
assert.deepStrictEqual(newTokens, completeTokens);
|
||||
});
|
||||
|
||||
test('list with bold incomplete link target', () => {
|
||||
const incomplete = `- list item one
|
||||
- **[link](http://microsoft`;
|
||||
const tokens = marked.marked.lexer(incomplete);
|
||||
const newTokens = fillInIncompleteTokens(tokens);
|
||||
|
||||
const completeTokens = marked.marked.lexer(incomplete + ')**');
|
||||
assert.deepStrictEqual(newTokens, completeTokens);
|
||||
});
|
||||
|
||||
test('ordered list with bold incomplete link target', () => {
|
||||
const incomplete = `1. list item one
|
||||
2. **[link](http://microsoft`;
|
||||
const tokens = marked.marked.lexer(incomplete);
|
||||
const newTokens = fillInIncompleteTokens(tokens);
|
||||
|
||||
const completeTokens = marked.marked.lexer(incomplete + ')**');
|
||||
assert.deepStrictEqual(newTokens, completeTokens);
|
||||
});
|
||||
|
||||
test('list with incomplete subitem', () => {
|
||||
const incomplete = `1. list item one
|
||||
- `;
|
||||
@@ -1063,7 +1111,7 @@ suite('MarkdownRenderer', () => {
|
||||
assert.deepStrictEqual(newTokens, completeTokens);
|
||||
});
|
||||
|
||||
test.skip('incomplete link in list', () => {
|
||||
test('incomplete link in list', () => {
|
||||
const incomplete = '- [text';
|
||||
const tokens = marked.marked.lexer(incomplete);
|
||||
const newTokens = fillInIncompleteTokens(tokens);
|
||||
@@ -1072,6 +1120,24 @@ suite('MarkdownRenderer', () => {
|
||||
assert.deepStrictEqual(newTokens, completeTokens);
|
||||
});
|
||||
|
||||
test('incomplete link target inside bold', () => {
|
||||
const incomplete = '**[text](http://microsoft';
|
||||
const tokens = marked.marked.lexer(incomplete);
|
||||
const newTokens = fillInIncompleteTokens(tokens);
|
||||
|
||||
const completeTokens = marked.marked.lexer(incomplete + ')**');
|
||||
assert.deepStrictEqual(newTokens, completeTokens);
|
||||
});
|
||||
|
||||
test('incomplete link target with arg inside bold', () => {
|
||||
const incomplete = '**[text](http://microsoft.com "more text ';
|
||||
const tokens = marked.marked.lexer(incomplete);
|
||||
const newTokens = fillInIncompleteTokens(tokens);
|
||||
|
||||
const completeTokens = marked.marked.lexer(incomplete + '")**');
|
||||
assert.deepStrictEqual(newTokens, completeTokens);
|
||||
});
|
||||
|
||||
test('square brace between letters', () => {
|
||||
const incomplete = 'a[b';
|
||||
const tokens = marked.marked.lexer(incomplete);
|
||||
|
||||
@@ -103,6 +103,14 @@ suite('UriTemplate', () => {
|
||||
testResolution('{hello}', variables, 'Hello%20World%21');
|
||||
});
|
||||
|
||||
test('control characters are percent-encoded with two hex digits', () => {
|
||||
// Code points below 0x10 must be zero-padded (e.g. %09, not %9) so the
|
||||
// output is a valid percent-encoding that decodeURIComponent accepts.
|
||||
testResolution('{x}', { x: 'a\tb' }, 'a%09b');
|
||||
testResolution('{x}', { x: '\n' }, '%0A');
|
||||
testResolution('{x}', { x: '\r' }, '%0D');
|
||||
});
|
||||
|
||||
test('Level 2 - Reserved expansion', () => {
|
||||
// Test cases from RFC 6570 Section 1.2
|
||||
const variables = {
|
||||
|
||||
@@ -1178,7 +1178,7 @@ export class CodeApplication extends Disposable {
|
||||
// `chat.agentHost.enabled` resolves to `true` there (honoring experiment
|
||||
// overrides + policy + web), which the main process cannot observe since
|
||||
// experiment overrides are never persisted to `settings.json`.
|
||||
const agentHostStarter = new ElectronAgentHostStarter(this.configurationService, this.environmentMainService, this.lifecycleMainService, this.logService);
|
||||
const agentHostStarter = new ElectronAgentHostStarter({ machineId, sqmId, devDeviceId }, this.configurationService, this.environmentMainService, this.lifecycleMainService, this.logService);
|
||||
this._register(new AgentHostProcessManager(agentHostStarter, this.logService, this.loggerService));
|
||||
|
||||
// External terminal
|
||||
|
||||
@@ -218,6 +218,7 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject<
|
||||
const instantiationService = this._register(this._instantiationService.createChild(new ServiceCollection([IContextKeyService, this._contextKeyService])));
|
||||
this._register(instantiationService.createInstance(MenuWorkbenchToolBar, this._elements.actions, MenuId.MultiDiffEditorFileToolbar, {
|
||||
actionRunner: this._register(new ActionRunnerWithContext(() => (this._viewModel.get()?.modifiedUri ?? this._viewModel.get()?.originalUri))),
|
||||
highlightToggledItems: true,
|
||||
menuOptions: {
|
||||
shouldForwardArgs: true,
|
||||
},
|
||||
|
||||
@@ -62,6 +62,18 @@ export class MultiDiffEditorViewModel extends Disposable {
|
||||
});
|
||||
}
|
||||
|
||||
public collapse(item: DocumentDiffItemViewModel): void {
|
||||
transaction(tx => {
|
||||
item.collapsed.set(true, tx);
|
||||
});
|
||||
}
|
||||
|
||||
public expand(item: DocumentDiffItemViewModel): void {
|
||||
transaction(tx => {
|
||||
item.collapsed.set(false, tx);
|
||||
});
|
||||
}
|
||||
|
||||
public get contextKeys(): Record<string, ContextKeyValue> | undefined {
|
||||
return this.model.contextKeys;
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export class ContentHoverController extends Disposable implements IEditorContrib
|
||||
}
|
||||
|
||||
private _isMouseOnContentHoverWidget(mouseEvent: IPartialEditorMouseEvent): boolean {
|
||||
if (!this._contentWidget) {
|
||||
if (!this._contentWidget || !this._contentWidget.getDomNode().isConnected) {
|
||||
return false;
|
||||
}
|
||||
return isMousePositionWithinElement(this._contentWidget.getDomNode(), mouseEvent.event.posx, mouseEvent.event.posy);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as strings from '../../../../base/common/strings.js';
|
||||
import { EditOperation, ISingleEditOperation } from '../../../common/core/editOperation.js';
|
||||
import { Position } from '../../../common/core/position.js';
|
||||
import { Selection } from '../../../common/core/selection.js';
|
||||
@@ -40,13 +39,6 @@ export class InsertFinalNewLineCommand implements ICommand {
|
||||
*/
|
||||
export function insertFinalNewLine(model: ITextModel): ISingleEditOperation | undefined {
|
||||
const lineCount = model.getLineCount();
|
||||
const lastLine = model.getLineContent(lineCount);
|
||||
const lastLineIsEmptyOrWhitespace = strings.lastNonWhitespaceIndex(lastLine) === -1;
|
||||
|
||||
if (!lineCount || lastLineIsEmptyOrWhitespace) {
|
||||
return;
|
||||
}
|
||||
|
||||
return EditOperation.insert(
|
||||
new Position(lineCount, model.getLineMaxColumn(lineCount)),
|
||||
model.getEOL()
|
||||
|
||||
@@ -49,6 +49,7 @@ export const enum AccessibleViewProviderId {
|
||||
SessionsChat = 'sessionsChat',
|
||||
SessionsChanges = 'sessionsChanges',
|
||||
Survey = 'survey',
|
||||
Automations = 'automations',
|
||||
}
|
||||
|
||||
export const enum AccessibleViewType {
|
||||
|
||||
@@ -100,6 +100,11 @@ export class MenuId {
|
||||
static readonly EmptyEditorGroupContext = new MenuId('EmptyEditorGroupContext');
|
||||
static readonly EditorGroupWatermarkToolbar = new MenuId('EditorGroupWatermarkToolbar');
|
||||
static readonly EditorTabsBarContext = new MenuId('EditorTabsBarContext');
|
||||
/**
|
||||
* Menu whose actions populate the editor tab bar's "+" (Add Tab) dropdown.
|
||||
* The button is rendered by core and is shown only when this menu has actions.
|
||||
*/
|
||||
static readonly EditorTabsBarAddTab = new MenuId('EditorTabsBarAddTab');
|
||||
static readonly EditorTabsBarShowTabsSubmenu = new MenuId('EditorTabsBarShowTabsSubmenu');
|
||||
static readonly EditorTabsBarShowTabsZenModeSubmenu = new MenuId('EditorTabsBarShowTabsZenModeSubmenu');
|
||||
static readonly EditorActionsPositionSubmenu = new MenuId('EditorActionsPositionSubmenu');
|
||||
@@ -265,6 +270,7 @@ export class MenuId {
|
||||
static readonly ChatInputSecondary = new MenuId('ChatInputSecondary');
|
||||
static readonly ChatInputStatus = new MenuId('ChatInputStatus');
|
||||
static readonly ChatInputSide = new MenuId('ChatInputSide');
|
||||
static readonly AutomationsDialogInput = new MenuId('AutomationsDialogInput');
|
||||
static readonly ChatModePicker = new MenuId('ChatModePicker');
|
||||
static readonly ChatEditingWidgetToolbar = new MenuId('ChatEditingWidgetToolbar');
|
||||
static readonly ChatEditingSessionChangesToolbar = new MenuId('ChatEditingSessionChangesToolbar');
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Event } from '../../../base/common/event.js';
|
||||
import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js';
|
||||
import { IRequestService } from '../../request/common/request.js';
|
||||
|
||||
/**
|
||||
* IPC channel name used for in-process agent-host → renderer reverse proxy
|
||||
* resolution RPCs. The renderer registers a server channel under this name on
|
||||
* its `MessagePortClient`; the agent host reaches it via
|
||||
* `server.getChannel(name, c => c.ctx === clientId)` on its
|
||||
* `UtilityProcessServer`.
|
||||
*
|
||||
* Mirrors {@link AGENT_HOST_CLIENT_BYOK_LM_CHANNEL} for the reverse BYOK bridge.
|
||||
*/
|
||||
export const AGENT_HOST_CLIENT_PROXY_CHANNEL = 'agentHostClientProxy';
|
||||
|
||||
/**
|
||||
* Node end of the proxy-resolution bridge: `resolveProxy()` ships the target
|
||||
* URL to the renderer and resolves with the *raw* result of VS Code's
|
||||
* `IRequestService.resolveProxy` (the Electron session PAC-style string, e.g.
|
||||
* `PROXY host:port` / `DIRECT`). The node side feeds this into
|
||||
* `@vscode/proxy-agent`'s `resolveProxyURL` to derive the final proxy URL.
|
||||
*/
|
||||
export interface IAgentHostClientProxyConnection {
|
||||
resolveProxy(url: string): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an {@link IChannel} (obtained from the agent host's
|
||||
* `UtilityProcessServer.getChannel`) into an {@link IAgentHostClientProxyConnection}.
|
||||
*/
|
||||
export function createAgentHostClientProxyConnection(channel: IChannel): IAgentHostClientProxyConnection {
|
||||
return {
|
||||
resolveProxy: (url) => channel.call('resolveProxy', { url }) as Promise<string | undefined>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side channel for in-process reverse proxy-resolution RPCs from the
|
||||
* local agent host. Thin adapter — forwards `resolveProxy` calls to the
|
||||
* renderer's {@link IRequestService}, which resolves the proxy through the
|
||||
* Electron session (system proxy settings, PAC scripts, etc.). The raw result
|
||||
* is returned verbatim; the node side derives the proxy URL from it.
|
||||
*/
|
||||
export class AgentHostClientProxyChannel implements IServerChannel {
|
||||
|
||||
constructor(
|
||||
@IRequestService private readonly _requestService: IRequestService,
|
||||
) { }
|
||||
|
||||
listen<T>(_ctx: unknown, event: string): Event<T> {
|
||||
throw new Error(`No event '${event}' on AgentHostClientProxyChannel`);
|
||||
}
|
||||
|
||||
async call<T>(_ctx: unknown, command: string, arg?: unknown): Promise<T> {
|
||||
switch (command) {
|
||||
case 'resolveProxy': {
|
||||
const { url } = arg as { url: string };
|
||||
const proxy = await this._requestService.resolveProxy(url);
|
||||
return proxy as T;
|
||||
}
|
||||
}
|
||||
throw new Error(`Unknown command '${command}' on AgentHostClientProxyChannel`);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,19 @@ import { ISessionFileDiff, ISessionGitState } from './state/sessionState.js';
|
||||
*/
|
||||
export const META_DIFF_BASE_BRANCH = 'agentHost.diffBaseBranch';
|
||||
|
||||
/**
|
||||
* Resolves the Branch Changes base-branch **name** from its two sources, in
|
||||
* precedence order: the agent-persisted {@link META_DIFF_BASE_BRANCH} metadata
|
||||
* value, then the session git state's detected base branch. Returns `undefined`
|
||||
* when neither is available (callers then anchor the diff at `HEAD`).
|
||||
*
|
||||
* Shared by {@link IAgentHostChangesetService} and the review service so both
|
||||
* pick the same base branch.
|
||||
*/
|
||||
export function resolveDiffBaseBranchName(persistedBaseBranch: string | undefined, sessionGitStateBaseBranch: string | undefined): string | undefined {
|
||||
return persistedBaseBranch ?? sessionGitStateBaseBranch;
|
||||
}
|
||||
|
||||
/**
|
||||
* The well-known SHA-1 of git's empty tree, used as a fallback when a
|
||||
* repository has no commits (no `HEAD` to read into the temp index).
|
||||
@@ -161,6 +174,18 @@ export interface IAgentHostGitService {
|
||||
*/
|
||||
computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise<readonly ISessionFileDiff[] | undefined>;
|
||||
|
||||
/**
|
||||
* Resolves the commit-ish the **Branch Changes** baseline is measured from:
|
||||
* the merge-base of `HEAD` and `baseBranch` (preferring the
|
||||
* `origin/<baseBranch>` remote-tracking ref when it exists), falling back to
|
||||
* `HEAD`, then to the empty-tree object for a repo with no commits. Returns
|
||||
* `undefined` only when {@link workingDirectory} is not a git work tree.
|
||||
*
|
||||
* Shared by {@link computeSessionFileDiffs} (which anchors the Branch Changes
|
||||
* diff here) and the review service, so both agree on the exact baseline.
|
||||
*/
|
||||
resolveBranchBaselineCommit(workingDirectory: URI, baseBranch?: string): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Reads a single git blob via `git show <ref>:<repoRelativePath>` from
|
||||
* the given working directory. Returns `undefined` when the blob does
|
||||
@@ -202,6 +227,28 @@ export interface IAgentHostGitService {
|
||||
*/
|
||||
revParse(repositoryRoot: URI, expression: string): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Builds a new tree from `baseTreeOid` in which the single repo-relative
|
||||
* `path` is replaced by its content (blob + mode) from `sourceTreeOid`, or
|
||||
* removed when the path is absent in `sourceTreeOid`. All other paths are
|
||||
* copied verbatim from `baseTreeOid`. Uses a throwaway `GIT_INDEX_FILE` so
|
||||
* the user's real index is untouched. Returns the new tree OID, or
|
||||
* `undefined` on git failure.
|
||||
*
|
||||
* File-level building block for review (see `IAgentHostReviewService`): to
|
||||
* mark a file reviewed, overlay it from the working-tree snapshot tree; to
|
||||
* unmark, overlay it from the baseline tree.
|
||||
*/
|
||||
overlayPathIntoTree(repositoryRoot: URI, baseTreeOid: string, path: string, sourceTreeOid: string): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Returns the repo-relative paths that differ between two tree-ish (commit
|
||||
* or tree) objects via `git diff --name-only --no-renames -z`. Rename
|
||||
* detection is off so a rename shows as delete(old) + add(new). Returns
|
||||
* `undefined` on git failure (e.g. not a git work tree).
|
||||
*/
|
||||
diffTreePaths(repositoryRoot: URI, fromTreeish: string, toTreeish: string): Promise<string[] | undefined>;
|
||||
|
||||
/**
|
||||
* Computes per-file diffs between two refs (typically two consecutive
|
||||
* checkpoint refs) by shelling out to
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import type { URI as ProtocolURI } from './state/sessionState.js';
|
||||
import { createDecorator } from '../../instantiation/common/instantiation.js';
|
||||
|
||||
export const IAgentHostReviewService = createDecorator<IAgentHostReviewService>('agentHostReviewService');
|
||||
|
||||
/**
|
||||
* Returns the canonical name for a session's synthetic **reviewed** ref.
|
||||
* Lives under the same `refs/agents/<sid>/…` namespace as checkpoint refs so
|
||||
* the two coexist safely and never surface to the user as branches/tags.
|
||||
*/
|
||||
export function buildReviewedRefName(sanitizedSessionId: string): string {
|
||||
return `refs/agents/${sanitizedSessionId}/reviewed`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which files in a session's **Branch Changes** the user has reviewed,
|
||||
* as a session-private synthetic git ref (`refs/agents/<sid>/reviewed`) whose
|
||||
* tree snapshots the reviewed content. A file is reviewed when its content in
|
||||
* the reviewed tree matches the current working tree; re-editing a reviewed
|
||||
* file therefore auto-unreviews it.
|
||||
*
|
||||
* All operations are keyed on the Branch Changes baseline (the merge-base of
|
||||
* `HEAD` and the session's base branch). The `baseBranch` argument is the
|
||||
* already-resolved base-branch **name** (see `resolveDiffBaseBranchName`),
|
||||
* shared with the changeset service so both agree on the baseline.
|
||||
*
|
||||
* Operations are no-ops when the working directory is not inside a git
|
||||
* repository; a future milestone will add a non-git fallback (see the
|
||||
* DB-backed reviewed-file store on `ISessionDatabase`).
|
||||
*/
|
||||
export interface IAgentHostReviewService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/**
|
||||
* Marks a single file reviewed at its current working-tree content by
|
||||
* overlaying that content into the reviewed tree and advancing the
|
||||
* reviewed ref. No-op when the file is already reviewed at that content.
|
||||
*/
|
||||
markFileReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise<void>;
|
||||
|
||||
/**
|
||||
* Marks a single file as unreviewed by resetting its entry in the
|
||||
* reviewed tree back to the baseline content and advancing the reviewed
|
||||
* ref. No-op when the file is not currently reviewed.
|
||||
*/
|
||||
markFileUnreviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise<void>;
|
||||
|
||||
/**
|
||||
* Returns the set of reviewed repo-relative paths within the current Branch
|
||||
* Changes: the changed files whose reviewed-tree content matches the
|
||||
* working tree. Empty when nothing is reviewed or the directory is not a
|
||||
* git work tree.
|
||||
*/
|
||||
getReviewedPaths(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise<ReadonlySet<string>>;
|
||||
|
||||
/**
|
||||
* Copies the reviewed ref from `sourceSessionUri` to `targetSessionUri` so a
|
||||
* forked session starts with the parent's review progress. Points the
|
||||
* target's reviewed ref at the same commit as the source's (git objects are
|
||||
* shared within the repository). No-op when the source has no reviewed ref or
|
||||
* the directory is not a git work tree.
|
||||
*/
|
||||
copyReviewedRef(sourceSession: ProtocolURI, targetSession: ProtocolURI, workingDirectory: URI): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A no-op {@link IAgentHostReviewService} used as the default for the optional
|
||||
* `_reviewService` parameter on `AgentService` so existing test callsites keep
|
||||
* compiling without forced fixture updates.
|
||||
*/
|
||||
export const NULL_REVIEW_SERVICE: IAgentHostReviewService = {
|
||||
_serviceBrand: undefined,
|
||||
markFileReviewed: async () => { },
|
||||
markFileUnreviewed: async () => { },
|
||||
getReviewedPaths: async () => new Set(),
|
||||
copyReviewedRef: async () => { },
|
||||
};
|
||||
@@ -57,6 +57,22 @@ function isRemoteAgentHostSessionTypeForAuthority(sessionType: string, connectio
|
||||
return !!connectionAuthority && sessionType.startsWith(remoteAgentHostSessionTypeAuthorityPrefix(connectionAuthority));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the harness/provider suffix from a remote agent host session type.
|
||||
*
|
||||
* Remote session types are formatted as `remote-{authority}-{provider}`. The
|
||||
* authority may contain `-`, but provider names do not, so the harness is the
|
||||
* final `-`-delimited segment. Returns `undefined` for non-remote session types.
|
||||
*/
|
||||
export function parseRemoteAgentHostHarness(sessionType: string): string | undefined {
|
||||
if (!isRemoteAgentHostSessionType(sessionType)) {
|
||||
return undefined;
|
||||
}
|
||||
const lastDash = sessionType.lastIndexOf('-');
|
||||
const harness = sessionType.slice(lastDash + 1);
|
||||
return harness || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the connection authority from a remote agent host session type when the provider is known.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Environment variables used to forward the host's resolved telemetry
|
||||
* identifiers into the agent host process.
|
||||
*
|
||||
* The agent host runs in its own utility process and would otherwise compute
|
||||
* its own `machineId`/`devDeviceId` live from the MAC address / device-id store
|
||||
* on every launch. That can diverge from the workbench's persisted, state-backed
|
||||
* identifiers (e.g. when `state.json` was seeded by imaging/migration, or when
|
||||
* the "first valid MAC" changes), breaking per-user joins across event sources.
|
||||
*
|
||||
* To keep the identifiers consistent, the local starter
|
||||
* (`ElectronAgentHostStarter`, which runs in the main process where these are
|
||||
* already resolved) forwards them via these env vars, and
|
||||
* `createAgentHostTelemetryService` prefers them over recomputing.
|
||||
*/
|
||||
export const AgentHostMachineIdEnvKey = 'VSCODE_AGENT_HOST_MACHINE_ID';
|
||||
export const AgentHostSqmIdEnvKey = 'VSCODE_AGENT_HOST_SQM_ID';
|
||||
export const AgentHostDevDeviceIdEnvKey = 'VSCODE_AGENT_HOST_DEV_DEVICE_ID';
|
||||
|
||||
export interface IAgentHostForwardedTelemetryIds {
|
||||
readonly machineId: string;
|
||||
readonly sqmId: string;
|
||||
readonly devDeviceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the env var bag that forwards the resolved telemetry identifiers to
|
||||
* the agent host process. Empty identifiers are omitted so the host falls back
|
||||
* to computing them itself.
|
||||
*/
|
||||
export function buildAgentHostTelemetryIdEnv(ids: IAgentHostForwardedTelemetryIds): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
if (ids.machineId) {
|
||||
env[AgentHostMachineIdEnvKey] = ids.machineId;
|
||||
}
|
||||
if (ids.sqmId) {
|
||||
env[AgentHostSqmIdEnvKey] = ids.sqmId;
|
||||
}
|
||||
if (ids.devDeviceId) {
|
||||
env[AgentHostDevDeviceIdEnvKey] = ids.devDeviceId;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import type { IRemoteWatchHandle } from './agentHostFileSystemProvider.js';
|
||||
import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js';
|
||||
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js';
|
||||
import { ProtectedResourceMetadata, type Changeset, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition, ChangesSummary } from './state/protocol/state.js';
|
||||
import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction } from './state/sessionActions.js';
|
||||
import type { ActionEnvelope, AuthRequiredParams, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction } from './state/sessionActions.js';
|
||||
import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js';
|
||||
import { ComponentToState, ChatInputResponseKind, SessionStatus, StateComponents, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, type AgentCapabilities, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type ChatInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js';
|
||||
|
||||
@@ -1481,6 +1481,15 @@ export interface IAgent {
|
||||
*/
|
||||
readonly onDidCustomizationsChange?: Event<void>;
|
||||
|
||||
/**
|
||||
* Fires when this agent needs the client to (re-)authenticate a
|
||||
* protected resource — for example after a runtime transport-mode flip
|
||||
* makes a previously-unneeded credential required. The host stamps the
|
||||
* root channel and forwards it verbatim as an `auth/required`
|
||||
* notification; clients respond via {@link authenticate}.
|
||||
*/
|
||||
readonly onDidRequireAuth?: Event<Omit<AuthRequiredParams, 'channel'>>;
|
||||
|
||||
/**
|
||||
* Returns the host-owned customizations this agent currently exposes.
|
||||
*
|
||||
@@ -1509,11 +1518,16 @@ export interface IAgent {
|
||||
handleAuthenticationToken?(params: AuthenticateParams): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Truncate a session's history. If `turnId` is provided, keeps turns up to
|
||||
* Truncate a chat's history. If `turnId` is provided, keeps turns up to
|
||||
* and including that turn. If omitted, all turns are removed.
|
||||
*
|
||||
* `chat` identifies which chat to truncate: the session's default chat
|
||||
* (addressed by the session's default chat URI) or a peer (non-default)
|
||||
* chat, which has its own backing.
|
||||
*
|
||||
* Optional — not all providers support truncation.
|
||||
*/
|
||||
truncateSession?(session: URI, turnId?: string): Promise<void>;
|
||||
truncateSession?(session: URI, turnId: string | undefined, chat: URI): Promise<void>;
|
||||
|
||||
/**
|
||||
* Notifies the provider that a session's archived state has changed.
|
||||
|
||||
@@ -294,7 +294,18 @@ export function parseCompareTurnsChangesetUri(uri: URI): { sessionUri: URI; orig
|
||||
*/
|
||||
export function buildDefaultChangesetCatalog(sessionUri: URI, gitState?: ISessionGitState): Changeset[] {
|
||||
if (!gitState) {
|
||||
return [];
|
||||
return [{
|
||||
label: sessionChangesetLabel(),
|
||||
description: sessionChangesetDescription(),
|
||||
uriTemplate: buildSessionChangesetUri(sessionUri),
|
||||
changeKind: ChangesetKind.Session
|
||||
},
|
||||
{
|
||||
label: thisTurnChangesetLabel(),
|
||||
description: thisTurnChangesetDescription(),
|
||||
uriTemplate: buildTurnChangesetUriTemplate(sessionUri),
|
||||
changeKind: ChangesetKind.Turn
|
||||
}] satisfies Changeset[];
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -330,5 +341,5 @@ export function buildDefaultChangesetCatalog(sessionUri: URI, gitState?: ISessio
|
||||
uriTemplate: buildCompareTurnsChangesetUriTemplate(sessionUri),
|
||||
changeKind: ChangesetKind.Compare
|
||||
}
|
||||
];
|
||||
] satisfies Changeset[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Mutable } from '../../../../base/common/types.js';
|
||||
import { ChangesetFile } from '../state/sessionState.js';
|
||||
|
||||
interface IChangesetFileMeta {
|
||||
readonly reviewed?: boolean;
|
||||
}
|
||||
|
||||
export function readChangesetFileMeta(source: ChangesetFile): IChangesetFileMeta | undefined {
|
||||
const meta = source._meta;
|
||||
if (!meta) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: Mutable<IChangesetFileMeta> = {};
|
||||
if (typeof meta['reviewed'] === 'boolean') { result.reviewed = meta['reviewed']; }
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -60,8 +60,45 @@ export interface IFileEditContent {
|
||||
afterContent?: Uint8Array;
|
||||
}
|
||||
|
||||
// ---- Reviewed-file types ------------------------------------------------
|
||||
|
||||
/**
|
||||
* A record of a file having been reviewed by the user at a specific content
|
||||
* nonce. Returned by {@link ISessionDatabase.getReviewedFiles} and
|
||||
* {@link ISessionDatabase.getReviewedFilesForUri}.
|
||||
*/
|
||||
export interface IReviewedFileRecord {
|
||||
/** The reviewed file. */
|
||||
uri: URI;
|
||||
/** Content version/hash captured at review time. */
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
// ---- Session database ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* A host-injected ("local") turn: a completed protocol `Turn` the agent SDK
|
||||
* never saw — e.g. the `/rename` acknowledgement or a `!command` terminal run.
|
||||
* These are persisted separately from SDK turns so they survive reload, and are
|
||||
* interleaved back into the SDK-derived turns on restore.
|
||||
*/
|
||||
export interface ILocalTurnRecord {
|
||||
/** The local turn's id (matches the payload `Turn.id`). */
|
||||
turnId: string;
|
||||
/** The chat this local turn belongs to (its channel URI string). */
|
||||
chatUri: string;
|
||||
/**
|
||||
* Id of the preceding concrete (SDK-backed) turn this local turn is
|
||||
* anchored after, or `undefined` when it precedes any real turn.
|
||||
*/
|
||||
anchorTurnId: string | undefined;
|
||||
/** Monotonic ordering among local turns (used to interleave on restore). */
|
||||
seq: number;
|
||||
/** JSON-serialized protocol `Turn`. */
|
||||
payload: string;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A disposable handle to a per-session SQLite database backed by
|
||||
* `@vscode/sqlite3`.
|
||||
@@ -150,6 +187,25 @@ export interface ISessionDatabase extends IDisposable {
|
||||
*/
|
||||
deleteAllTurns(): Promise<void>;
|
||||
|
||||
// ---- Local (host-injected) turns -------------------------------------
|
||||
|
||||
/**
|
||||
* Persist a host-injected local turn (e.g. `/rename` or `!command`).
|
||||
* Replaces any existing record with the same `turnId`.
|
||||
*/
|
||||
insertLocalTurn(record: ILocalTurnRecord): Promise<void>;
|
||||
|
||||
/**
|
||||
* Retrieve all persisted local turns in this session, in `seq` order.
|
||||
* Callers filter by {@link ILocalTurnRecord.chatUri} for a given chat.
|
||||
*/
|
||||
getLocalTurns(): Promise<ILocalTurnRecord[]>;
|
||||
|
||||
/**
|
||||
* Delete the local turns with the given ids. Ids not present are ignored.
|
||||
*/
|
||||
deleteLocalTurns(turnIds: readonly string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Store a file-edit snapshot (metadata + content) for a tool invocation
|
||||
* within a turn.
|
||||
@@ -220,6 +276,36 @@ export interface ISessionDatabase extends IDisposable {
|
||||
*/
|
||||
remapTurnIds(mapping: ReadonlyMap<string, string>): Promise<void>;
|
||||
|
||||
// ---- Reviewed files --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Mark a file (identified by URI + content nonce) as reviewed by the user.
|
||||
* Idempotent — re-marking the same `(uri, nonce)` pair is a no-op.
|
||||
*/
|
||||
markFileReviewed(uri: URI, nonce: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove the reviewed-file entry for the given URI + content nonce.
|
||||
* No-op if no such entry exists.
|
||||
*/
|
||||
unmarkFileReviewed(uri: URI, nonce: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Return every reviewed-file entry in this session, in insertion order.
|
||||
*/
|
||||
getReviewedFiles(): Promise<IReviewedFileRecord[]>;
|
||||
|
||||
/**
|
||||
* Return all reviewed-file entries for a specific URI (one per reviewed
|
||||
* content nonce), in insertion order.
|
||||
*/
|
||||
getReviewedFilesForUri(uri: URI): Promise<IReviewedFileRecord[]>;
|
||||
|
||||
/**
|
||||
* Return whether the given file has been reviewed at the given content nonce.
|
||||
*/
|
||||
isFileReviewed(uri: URI, nonce: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Creates a safe, consistent copy of the database at the given path
|
||||
* using SQLite's `VACUUM INTO` command.
|
||||
|
||||
@@ -30,6 +30,7 @@ import { revive } from '../../../base/common/marshalling.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AgentHostClientResourceChannel } from '../common/agentHostClientResourceChannel.js';
|
||||
import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../common/agentHostClientByokLmChannel.js';
|
||||
import { AGENT_HOST_CLIENT_PROXY_CHANNEL, AgentHostClientProxyChannel } from '../common/agentHostClientProxyChannel.js';
|
||||
import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js';
|
||||
import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js';
|
||||
import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js';
|
||||
@@ -439,9 +440,10 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos
|
||||
/**
|
||||
* Register the reverse-RPC server channels every in-process renderer exposes to
|
||||
* the agent host's {@link UtilityProcessServer}: the filesystem resource bridge
|
||||
* ({@link AGENT_HOST_CLIENT_RESOURCE_CHANNEL}) and the BYOK language-model
|
||||
* bridge ({@link AGENT_HOST_CLIENT_BYOK_LM_CHANNEL}). The agent host reaches
|
||||
* these via `server.getChannel(name, c => c.ctx === clientId)`.
|
||||
* ({@link AGENT_HOST_CLIENT_RESOURCE_CHANNEL}), the proxy-resolution bridge
|
||||
* ({@link AGENT_HOST_CLIENT_PROXY_CHANNEL}), and the BYOK language-model bridge
|
||||
* ({@link AGENT_HOST_CLIENT_BYOK_LM_CHANNEL}). The agent host reaches these via
|
||||
* `server.getChannel(name, c => c.ctx === clientId)`.
|
||||
*/
|
||||
export function registerAgentHostClientChannels(
|
||||
client: IChannelServer,
|
||||
@@ -454,6 +456,10 @@ export function registerAgentHostClientChannels(
|
||||
// registers an authority on its AgentHostClientFileSystemProvider that calls
|
||||
// back through this channel.
|
||||
client.registerChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, instantiationService.createInstance(AgentHostClientResourceChannel, ahpLogger));
|
||||
// Serve proxy-resolution reverse-RPCs from the renderer's request service so
|
||||
// the agent host resolves proxies through VS Code's Electron session (system
|
||||
// settings / PAC scripts) rather than guessing from environment variables.
|
||||
client.registerChannel(AGENT_HOST_CLIENT_PROXY_CHANNEL, instantiationService.createInstance(AgentHostClientProxyChannel));
|
||||
// Serve BYOK language-model reverse-RPCs from the renderer LM API, gated
|
||||
// behind `chat.agentHost.byokModels.enabled`. When disabled, the node-side
|
||||
// proxy + registry are also skipped, so the channel would never be called.
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getResolvedShellEnv } from '../../shell/node/shellEnv.js';
|
||||
import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js';
|
||||
import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js';
|
||||
import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js';
|
||||
import { buildAgentHostTelemetryIdEnv, IAgentHostForwardedTelemetryIds } from '../common/agentHostTelemetryEnv.js';
|
||||
import { AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, AgentHostOTelPolicyIpcChannel, buildAgentHostOTelEnv, buildAgentSdkEnv, IAgentHostOTelSettings, sanitizeAgentHostOTelPolicySettings } from '../common/agentService.js';
|
||||
import { deepClone } from '../../../base/common/objects.js';
|
||||
import '../common/agentHost.config.contribution.js';
|
||||
@@ -44,6 +45,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt
|
||||
private _otelPolicyFromRenderer: IAgentHostOTelSettings | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
private readonly _telemetryIds: IAgentHostForwardedTelemetryIds,
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@IEnvironmentMainService private readonly _environmentMainService: IEnvironmentMainService,
|
||||
@ILifecycleMainService private readonly _lifecycleMainService: ILifecycleMainService,
|
||||
@@ -134,6 +136,11 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt
|
||||
args.push('--disable-telemetry');
|
||||
}
|
||||
|
||||
// Forward the host's resolved telemetry identifiers so the agent host
|
||||
// reuses the same persisted machineId/sqmId/devDeviceId instead of
|
||||
// recomputing them live (which can diverge). See `agentHostTelemetryEnv`.
|
||||
const telemetryIdEnv = buildAgentHostTelemetryIdEnv(this._telemetryIds);
|
||||
|
||||
this.utilityProcess.start({
|
||||
type: 'agentHost',
|
||||
name: 'agent-host',
|
||||
@@ -148,6 +155,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt
|
||||
VSCODE_VERBOSE_LOGGING: 'true',
|
||||
...sdkEnv,
|
||||
...otelEnv,
|
||||
...telemetryIdEnv,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* The leading character that marks a chat message as a terminal command.
|
||||
*/
|
||||
export const BANG_COMMAND_PREFIX = '!';
|
||||
|
||||
/**
|
||||
* Parses a leading `!<command>` at the very start of `prompt`.
|
||||
*
|
||||
* Like {@link parseRenameCommand}, the marker must be at position 0 (no leading
|
||||
* whitespace). A lone `!` or `!` followed only by whitespace is not treated as
|
||||
* a bang command — the caller should forward such messages normally.
|
||||
*
|
||||
* Returns the trimmed command string when the prompt is a bang command, or
|
||||
* `undefined` when it is not.
|
||||
*/
|
||||
export function parseBangCommand(prompt: string): string | undefined {
|
||||
if (!prompt.startsWith(BANG_COMMAND_PREFIX)) {
|
||||
return undefined;
|
||||
}
|
||||
const command = prompt.slice(BANG_COMMAND_PREFIX.length).trim();
|
||||
return command.length > 0 ? command : undefined;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from '../common/state/sessionState.js';
|
||||
import { AgentHostStateManager } from './agentHostStateManager.js';
|
||||
import { IAgentConfigurationService } from './agentConfigurationService.js';
|
||||
import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../common/agentHostGitService.js';
|
||||
import { IAgentHostGitService, META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName } from '../common/agentHostGitService.js';
|
||||
import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js';
|
||||
import { NodeWorkerDiffComputeService } from './diffComputeService.js';
|
||||
import { computeSessionDiffs, computeTurnDiffs, computeUnionedDiffs, type IIncrementalDiffOptions, type ISessionDiffSource } from './sessionDiffAggregator.js';
|
||||
@@ -41,6 +41,8 @@ import { META_CHECKPOINT_WORKING_DIR } from './agentHostCheckpointService.js';
|
||||
import { IAgentHostChangesetService, IPersistedChangesetMetadata, IRestoredChangesetDiffs, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS, StaticChangesetKind } from '../common/agentHostChangesetService.js';
|
||||
import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js';
|
||||
import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js';
|
||||
import { IAgentHostReviewService } from '../common/agentHostReviewService.js';
|
||||
import { relativePath } from '../../../base/common/resources.js';
|
||||
|
||||
function staticChangesetUri(session: ProtocolURI, kind: StaticChangesetKind): ProtocolURI {
|
||||
return kind === 'branch'
|
||||
@@ -163,6 +165,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
|
||||
@IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
|
||||
@IAgentHostChangesetOperationService private readonly _changesetOperationService: IAgentHostChangesetOperationService,
|
||||
@IAgentHostChangesetSubscriptionService private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService,
|
||||
@IAgentHostReviewService private readonly _reviewService: IAgentHostReviewService,
|
||||
) {
|
||||
super();
|
||||
this._diffComputeService = this._register(new NodeWorkerDiffComputeService(this._logService));
|
||||
@@ -808,7 +811,10 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
|
||||
}
|
||||
}
|
||||
|
||||
this._publishChangesetDiffs(session, changesetUri, diffs);
|
||||
const reviewed = kind === ChangesetKind.Branch
|
||||
? await this._computeReviewedInfo(session, ref.object)
|
||||
: undefined;
|
||||
this._publishChangesetDiffs(session, changesetUri, diffs, reviewed);
|
||||
|
||||
// Persist the file list so a subsequent `listSessions` /
|
||||
// `restoreSession` can reseed the changeset before the first
|
||||
@@ -881,7 +887,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
|
||||
* (fileSet, fileRemoved) and moves the changeset to `ready` once the
|
||||
* fresh file list has been applied.
|
||||
*/
|
||||
private _publishChangesetDiffs(session: ProtocolURI, changesetUri: ProtocolURI, diffs: readonly ISessionFileDiff[]): void {
|
||||
private _publishChangesetDiffs(session: ProtocolURI, changesetUri: ProtocolURI, diffs: readonly ISessionFileDiff[], reviewed?: { readonly repoRoot: URI; readonly paths: ReadonlySet<string> }): void {
|
||||
// Get the available operations for this changeset. This call assumes that at this point
|
||||
// the git state of the session is up-to-date as it is being used to determine the available
|
||||
// operations. Long term this should be replaced with a more robust mechanism.
|
||||
@@ -893,7 +899,15 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
files.push({ id, edit });
|
||||
if (reviewed) {
|
||||
// Surface per-file review status for the Branch changeset. The
|
||||
// file id is a `file:` URI under the repository root, so the
|
||||
// repo-relative path keys into the reviewed-paths set.
|
||||
const relPath = relativePath(reviewed.repoRoot, URI.parse(id));
|
||||
files.push({ id, edit, _meta: { reviewed: relPath ? reviewed.paths.has(relPath) : false } });
|
||||
} else {
|
||||
files.push({ id, edit });
|
||||
}
|
||||
}
|
||||
|
||||
this._stateManager.dispatchServerAction(changesetUri, {
|
||||
@@ -1032,12 +1046,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
|
||||
}
|
||||
|
||||
// Branch
|
||||
const persistedBaseBranch = await db.getMetadata(META_DIFF_BASE_BRANCH);
|
||||
const gitStateBaseBranch = readSessionGitState(this._stateManager.getSessionState(session)?._meta)?.baseBranchName;
|
||||
const baseBranch = persistedBaseBranch ?? gitStateBaseBranch;
|
||||
if (!persistedBaseBranch && gitStateBaseBranch) {
|
||||
this._logService.debug(`[AgentHostChangesetService] Using _meta.git base branch fallback for Branch Changes in ${session}: ${gitStateBaseBranch}`);
|
||||
}
|
||||
const baseBranch = await this._resolveBranchBaseBranch(session, db);
|
||||
|
||||
try {
|
||||
return await this._gitService.computeSessionFileDiffs(workingDirectoryUri, {
|
||||
@@ -1050,6 +1059,49 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Branch Changes base branch, reused by the diff computation
|
||||
* and the review-status lookup so both are keyed on the same baseline.
|
||||
*/
|
||||
private async _resolveBranchBaseBranch(session: ProtocolURI, db: ISessionDatabase): Promise<string | undefined> {
|
||||
const persistedBaseBranch = await db.getMetadata(META_DIFF_BASE_BRANCH);
|
||||
const gitStateBaseBranch = readSessionGitState(this._stateManager.getSessionState(session)?._meta)?.baseBranchName;
|
||||
if (!persistedBaseBranch && gitStateBaseBranch) {
|
||||
this._logService.debug(`[AgentHostChangesetService] Using _meta.git base branch fallback for Branch Changes in ${session}: ${gitStateBaseBranch}`);
|
||||
}
|
||||
return resolveDiffBaseBranchName(persistedBaseBranch, gitStateBaseBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the reviewed-paths overlay for the Branch changeset: the
|
||||
* repository root (used to key file ids to repo-relative paths) and the set
|
||||
* of reviewed repo-relative paths. Returns `undefined` when the session has
|
||||
* no git working directory (review status is then simply omitted).
|
||||
*/
|
||||
private async _computeReviewedInfo(session: ProtocolURI, db: ISessionDatabase): Promise<{ readonly repoRoot: URI; readonly paths: ReadonlySet<string> } | undefined> {
|
||||
const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory;
|
||||
if (!workingDirectory) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let workingDirectoryUri: URI;
|
||||
try {
|
||||
workingDirectoryUri = URI.parse(workingDirectory);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const repoRoot = await this._gitService.getRepositoryRoot(workingDirectoryUri);
|
||||
if (!repoRoot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const baseBranch = await this._resolveBranchBaseBranch(session, db);
|
||||
const paths = await this._reviewService.getReviewedPaths(session, workingDirectoryUri, baseBranch);
|
||||
|
||||
return { repoRoot, paths };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a session metadata key/value pair to the session database.
|
||||
* Counterpart in `agentSideEffects.ts` (`AgentSideEffects._persistSessionFlag`):
|
||||
|
||||
@@ -216,24 +216,8 @@ export class AgentHostGitService implements IAgentHostGitService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Resolve the merge-base commit. With a base branch, prefer the
|
||||
// corresponding origin/<base> remote-tracking ref when it exists so
|
||||
// branch changes match a PR-style comparison even if the local base
|
||||
// branch is stale. Without a usable base, fall back to HEAD itself,
|
||||
// which surfaces uncommitted work but no committed-on-branch work -
|
||||
// the best we can do without context. For empty repos with no HEAD,
|
||||
// fall back to the well-known empty-tree object.
|
||||
let mergeBaseCommit: string | undefined;
|
||||
if (options.baseBranch) {
|
||||
const baseBranch = await this._resolveRemoteTrackingBranch(repositoryRoot, options.baseBranch) ?? options.baseBranch;
|
||||
mergeBaseCommit = (await this._runGit(repositoryRoot, ['merge-base', 'HEAD', baseBranch]))?.trim();
|
||||
}
|
||||
if (!mergeBaseCommit) {
|
||||
mergeBaseCommit = (await this._runGit(repositoryRoot, ['rev-parse', 'HEAD']))?.trim();
|
||||
}
|
||||
if (!mergeBaseCommit) {
|
||||
mergeBaseCommit = EMPTY_TREE_OBJECT;
|
||||
}
|
||||
// Resolve the merge-base commit the Branch Changes diff is anchored on.
|
||||
const mergeBaseCommit = await this._resolveBranchMergeBaseCommit(repositoryRoot, options.baseBranch);
|
||||
|
||||
// Detect whether the working tree has any untracked files. If so we
|
||||
// have to use the temp-index trick so the untracked content is
|
||||
@@ -260,6 +244,38 @@ export class AgentHostGitService implements IAgentHostGitService {
|
||||
return parseGitDiffRawNumstat(rawDiffOutput, repositoryRoot, options.sessionUri, mergeBaseCommit);
|
||||
}
|
||||
|
||||
async resolveBranchBaselineCommit(workingDirectory: URI, baseBranch?: string): Promise<string | undefined> {
|
||||
const repositoryRoot = await this.getRepositoryRoot(workingDirectory);
|
||||
if (!repositoryRoot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this._resolveBranchMergeBaseCommit(repositoryRoot, baseBranch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the merge-base commit-ish the Branch Changes baseline is anchored
|
||||
* on. With a base branch, prefers the corresponding `origin/<base>`
|
||||
* remote-tracking ref when it exists so branch changes match a PR-style
|
||||
* comparison even if the local base branch is stale. Without a usable base,
|
||||
* falls back to `HEAD` (surfaces uncommitted work but no committed-on-branch
|
||||
* work). For empty repos with no `HEAD`, falls back to the empty-tree object.
|
||||
* Always resolves to a commit-ish (never `undefined`) once the repository
|
||||
* root is known.
|
||||
*/
|
||||
private async _resolveBranchMergeBaseCommit(repositoryRoot: URI, baseBranch?: string): Promise<string> {
|
||||
let mergeBaseCommit: string | undefined;
|
||||
if (baseBranch) {
|
||||
const resolvedBase = await this._resolveRemoteTrackingBranch(repositoryRoot, baseBranch) ?? baseBranch;
|
||||
mergeBaseCommit = (await this._runGit(repositoryRoot, ['merge-base', 'HEAD', resolvedBase]))?.trim();
|
||||
}
|
||||
if (!mergeBaseCommit) {
|
||||
mergeBaseCommit = (await this._runGit(repositoryRoot, ['rev-parse', 'HEAD']))?.trim();
|
||||
}
|
||||
|
||||
return mergeBaseCommit ?? EMPTY_TREE_OBJECT;
|
||||
}
|
||||
|
||||
private async _runWithTempIndex(repositoryRoot: URI, mergeBaseCommit: string, changedPaths: readonly string[]): Promise<string | undefined> {
|
||||
// Build a throwaway index so we can stage the changed working tree
|
||||
// paths (including untracked files) without disturbing the user's real
|
||||
@@ -405,6 +421,58 @@ export class AgentHostGitService implements IAgentHostGitService {
|
||||
return out?.trim() || undefined;
|
||||
}
|
||||
|
||||
async overlayPathIntoTree(repositoryRoot: URI, baseTreeOid: string, path: string, sourceTreeOid: string): Promise<string | undefined> {
|
||||
// Build a throwaway index seeded from `baseTreeOid`, replace/remove the
|
||||
// single `path` using `sourceTreeOid`, and write the result back out as
|
||||
// a new tree. The user's real index is never touched (mirrors the
|
||||
// temp-index technique used by `captureWorkingTreeAsTree`).
|
||||
const tempDir = URI.joinPath(this._environmentService.tmpDir, `agent-host-review-overlay-${generateUuid()}`);
|
||||
await this._fileService.createFolder(tempDir);
|
||||
const indexFile = URI.joinPath(tempDir, 'index').fsPath;
|
||||
const env: Record<string, string> = { GIT_INDEX_FILE: indexFile, COMMAND_HOOK_LOCK: '1' };
|
||||
|
||||
try {
|
||||
const readTreeOut = await this._runGit(repositoryRoot, ['read-tree', baseTreeOid], { env, throwOnError: false });
|
||||
if (readTreeOut === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Resolve the source blob (mode + oid) for `path`. `-z` avoids
|
||||
// path quoting; an empty result means the path is absent in the
|
||||
// source tree, so the overlay removes it from the base.
|
||||
const lsTreeOut = await this._runGit(repositoryRoot, ['ls-tree', '-z', sourceTreeOid, '--', path], { env });
|
||||
const entry = parseSingleLsTreeEntry(lsTreeOut);
|
||||
if (entry) {
|
||||
const updateIndexOut = await this._runGit(repositoryRoot, ['update-index', '--add', '--cacheinfo', `${entry.mode},${entry.oid},${path}`], { env, throwOnError: false });
|
||||
if (updateIndexOut === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
// `--force-remove` tolerates the path already being absent from
|
||||
// the index, so removing an untracked/added path is a no-op.
|
||||
const updateIndexOut = await this._runGit(repositoryRoot, ['update-index', '--force-remove', '--', path], { env, throwOnError: false });
|
||||
if (updateIndexOut === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const writeTreeOut = await this._runGit(repositoryRoot, ['write-tree'], { env });
|
||||
return writeTreeOut?.trim();
|
||||
} finally {
|
||||
try {
|
||||
await this._fileService.del(tempDir, { recursive: true, useTrash: false });
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
async diffTreePaths(repositoryRoot: URI, fromTreeish: string, toTreeish: string): Promise<string[] | undefined> {
|
||||
const out = await this._runGit(repositoryRoot, ['diff', '--name-only', '--no-renames', '-z', fromTreeish, toTreeish, '--']);
|
||||
if (out === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return out.split('\x00').filter(Boolean);
|
||||
}
|
||||
|
||||
async computeFileDiffsBetweenRefs(workingDirectory: URI, options: { readonly sessionUri: string; readonly fromRef: string; readonly toRef: string }): Promise<readonly ISessionFileDiff[] | undefined> {
|
||||
const repositoryRoot = await this.getRepositoryRoot(workingDirectory);
|
||||
if (!repositoryRoot) {
|
||||
@@ -626,6 +694,30 @@ export function parseChangedPaths(output: string | undefined, includeStatus: (st
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses NUL-terminated `git ls-tree -z <tree> -- <path>` output for a single
|
||||
* path and returns its `{ mode, oid }`, or `undefined` when the path is absent
|
||||
* from the tree (empty output). Each entry has the form
|
||||
* `<mode> SP <type> SP <oid> TAB <path> NUL`; we only need the mode and oid.
|
||||
*
|
||||
* Exported for tests.
|
||||
*/
|
||||
export function parseSingleLsTreeEntry(output: string | undefined): { mode: string; oid: string } | undefined {
|
||||
if (!output) {
|
||||
return undefined;
|
||||
}
|
||||
const entry = output.split('\x00')[0];
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
const tabIndex = entry.indexOf('\t');
|
||||
const meta = (tabIndex === -1 ? entry : entry.substring(0, tabIndex)).split(' ');
|
||||
if (meta.length < 3) {
|
||||
return undefined;
|
||||
}
|
||||
return { mode: meta[0], oid: meta[2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses combined `--raw --numstat -z` output produced by
|
||||
* {@link IAgentHostGitService.computeSessionFileDiffs} and converts each
|
||||
|
||||
@@ -91,8 +91,13 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi
|
||||
}
|
||||
|
||||
async refreshSessionGitState(sessionKey: string, workingDirectory: URI | undefined): Promise<void> {
|
||||
const sessionState = this._stateManager.getSessionState(sessionKey);
|
||||
if (sessionState?.lifecycle === SessionLifecycle.Creating) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workingDirectory) {
|
||||
const workingDirectoryStr = this._stateManager.getSessionState(sessionKey)?.workingDirectory;
|
||||
const workingDirectoryStr = sessionState?.workingDirectory;
|
||||
if (workingDirectoryStr) {
|
||||
workingDirectory = URI.parse(workingDirectoryStr);
|
||||
}
|
||||
@@ -107,21 +112,19 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi
|
||||
this._logService.trace(`[AgentHostGitStateService][refreshSessionGitState] Refreshing git state for ${sessionKey}, ${workingDirectory?.fsPath}`);
|
||||
|
||||
const gitState = await this._gitService.getSessionGitState(workingDirectory);
|
||||
if (!gitState) {
|
||||
return;
|
||||
}
|
||||
if (gitState) {
|
||||
const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta;
|
||||
if (!objectEquals(readSessionGitState(currentMeta), gitState)) {
|
||||
// Update the session's git state
|
||||
await this._setSessionGitState(sessionKey, gitState);
|
||||
|
||||
const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta;
|
||||
if (!objectEquals(readSessionGitState(currentMeta), gitState)) {
|
||||
// Update the session's git state
|
||||
await this._setSessionGitState(sessionKey, gitState);
|
||||
|
||||
// Update the session's GitHub state
|
||||
if (gitState.githubOwner && gitState.githubRepo) {
|
||||
await this.setSessionGitHubState(sessionKey, {
|
||||
owner: gitState.githubOwner,
|
||||
repo: gitState.githubRepo
|
||||
} satisfies ISessionGitHubState);
|
||||
// Update the session's GitHub state
|
||||
if (gitState.githubOwner && gitState.githubRepo) {
|
||||
await this.setSessionGitHubState(sessionKey, {
|
||||
owner: gitState.githubOwner,
|
||||
repo: gitState.githubRepo
|
||||
} satisfies ISessionGitHubState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import type { IReference } from '../../../base/common/lifecycle.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import type { ILocalTurnRecord, ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js';
|
||||
import type { Turn } from '../common/state/sessionState.js';
|
||||
|
||||
/**
|
||||
* Tracks host-injected ("local") turns — completed protocol turns the agent SDK
|
||||
* never saw, such as the `/rename` acknowledgement or a `!command` terminal run.
|
||||
*
|
||||
* These turns exist only in the agent host: they are never forwarded to the
|
||||
* agent SDK, so they are absent from the SDK transcript that
|
||||
* {@link AgentService} replays on restore. This registry persists them (so they
|
||||
* survive reload) and remembers, for each, the id of the preceding concrete
|
||||
* (SDK-backed) turn — the *anchor* — so that fork/truncate operations targeting
|
||||
* a local turn can be redirected to the concrete SDK message before it.
|
||||
*
|
||||
* Everything is scoped to a **chat** (its channel URI): a session's default
|
||||
* chat and each of its peer chats are handled identically. Persistence lives in
|
||||
* the owning session's database (one per session, shared across its chats),
|
||||
* discriminated by {@link ILocalTurnRecord.chatUri}.
|
||||
*/
|
||||
export class AgentHostLocalTurns {
|
||||
|
||||
/** chat URI → (localTurnId → { anchorTurnId, seq }). */
|
||||
private readonly _byChat = new Map<string, Map<string, { readonly anchorTurnId: string | undefined; readonly seq: number }>>();
|
||||
/** session URI → highest `seq` assigned so far (seq is session-global for stable ordering). */
|
||||
private readonly _seqBySession = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly _sessionDataService: ISessionDataService,
|
||||
private readonly _logService: ILogService,
|
||||
) { }
|
||||
|
||||
/** Whether `turnId` is a known host-injected local turn in `chat`. */
|
||||
isLocal(chat: string, turnId: string): boolean {
|
||||
return this._byChat.get(chat)?.has(turnId) ?? false;
|
||||
}
|
||||
|
||||
/** All known local turn ids for `chat`. */
|
||||
getLocalTurnIds(chat: string): string[] {
|
||||
const map = this._byChat.get(chat);
|
||||
return map ? [...map.keys()] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `turnId` to the concrete (SDK-backed) turn a fork/truncate should
|
||||
* operate on within `chat`. For a local turn this is its anchor (the
|
||||
* preceding real turn, or `undefined` when it precedes any real turn); for a
|
||||
* concrete turn it is the turn itself.
|
||||
*/
|
||||
resolveConcreteTurnId(chat: string, turnId: string): string | undefined {
|
||||
const entry = this._byChat.get(chat)?.get(turnId);
|
||||
return entry ? entry.anchorTurnId : turnId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a local turn and remember it in memory. `anchorTurnId` is the id
|
||||
* of the preceding concrete turn in `chat` (or `undefined` when there is
|
||||
* none). `session` identifies the database to persist into.
|
||||
*/
|
||||
record(session: string, chat: string, turn: Turn, anchorTurnId: string | undefined): void {
|
||||
const seq = (this._seqBySession.get(session) ?? 0) + 1;
|
||||
this._noteInMemory(session, chat, turn.id, anchorTurnId, seq);
|
||||
const record: ILocalTurnRecord = { turnId: turn.id, chatUri: chat, anchorTurnId, seq, payload: JSON.stringify(turn) };
|
||||
let ref: IReference<ISessionDatabase>;
|
||||
try {
|
||||
ref = this._sessionDataService.openDatabase(URI.parse(session));
|
||||
} catch (err) {
|
||||
this._logService.warn(`[AgentHostLocalTurns] Failed to open database to persist local turn ${turn.id}`, err);
|
||||
return;
|
||||
}
|
||||
ref.object.insertLocalTurn(record).catch(err => {
|
||||
this._logService.warn(`[AgentHostLocalTurns] Failed to persist local turn ${turn.id}`, err);
|
||||
}).finally(() => ref.dispose());
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads persisted local turns for `session`, populating the in-memory index
|
||||
* (keyed by each record's chat), and returns the records for `chat` in
|
||||
* `seq` order so the caller can interleave them into that chat's SDK-derived
|
||||
* turns during restore.
|
||||
*/
|
||||
async loadForChat(session: string, chat: string): Promise<ILocalTurnRecord[]> {
|
||||
const records = await this._load(session);
|
||||
return records.filter(r => r.chatUri === chat);
|
||||
}
|
||||
|
||||
/** Note a local turn in memory only (used by fork seeding). */
|
||||
noteInMemory(session: string, chat: string, turnId: string, anchorTurnId: string | undefined, seq: number): void {
|
||||
this._noteInMemory(session, chat, turnId, anchorTurnId, seq);
|
||||
}
|
||||
|
||||
/** Delete the given local turns from memory and the session database. */
|
||||
deleteLocals(session: string, turnIds: readonly string[]): void {
|
||||
if (turnIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const idSet = new Set(turnIds);
|
||||
for (const map of this._byChat.values()) {
|
||||
for (const id of idSet) {
|
||||
map.delete(id);
|
||||
}
|
||||
}
|
||||
let ref: IReference<ISessionDatabase>;
|
||||
try {
|
||||
ref = this._sessionDataService.openDatabase(URI.parse(session));
|
||||
} catch (err) {
|
||||
this._logService.warn(`[AgentHostLocalTurns] Failed to open database to delete local turns for ${session}`, err);
|
||||
return;
|
||||
}
|
||||
ref.object.deleteLocalTurns(turnIds).catch(err => {
|
||||
this._logService.warn(`[AgentHostLocalTurns] Failed to delete local turns for ${session}`, err);
|
||||
}).finally(() => ref.dispose());
|
||||
}
|
||||
|
||||
/** Drop all in-memory state for a chat. */
|
||||
forgetChat(chat: string): void {
|
||||
this._byChat.delete(chat);
|
||||
}
|
||||
|
||||
private async _load(session: string): Promise<ILocalTurnRecord[]> {
|
||||
const ref = this._sessionDataService.tryOpenDatabase?.(URI.parse(session));
|
||||
if (!ref) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const db = await ref;
|
||||
if (!db) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const records = await db.object.getLocalTurns();
|
||||
for (const r of records) {
|
||||
this._noteInMemory(session, r.chatUri, r.turnId, r.anchorTurnId, r.seq);
|
||||
}
|
||||
return records;
|
||||
} finally {
|
||||
db.dispose();
|
||||
}
|
||||
} catch (err) {
|
||||
this._logService.warn(`[AgentHostLocalTurns] Failed to load local turns for ${session}`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private _noteInMemory(session: string, chat: string, turnId: string, anchorTurnId: string | undefined, seq: number): void {
|
||||
let map = this._byChat.get(chat);
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
this._byChat.set(chat, map);
|
||||
}
|
||||
map.set(turnId, { anchorTurnId, seq });
|
||||
this._seqBySession.set(session, Math.max(this._seqBySession.get(session) ?? 0, seq));
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { CodexAgent, CodexSdkPackage } from './codex/codexAgent.js';
|
||||
import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js';
|
||||
import { ByokLmProxyService, IByokLmProxyService } from './copilot/byokLmProxyService.js';
|
||||
import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from './byokLmBridgeRegistry.js';
|
||||
import { AgentHostProxyResolver, IAgentHostProxyResolver } from './agentHostProxyResolver.js';
|
||||
import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js';
|
||||
import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js';
|
||||
import { AgentHostOTelService } from './otel/agentHostOTelService.js';
|
||||
@@ -68,6 +69,7 @@ import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFile
|
||||
import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js';
|
||||
import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, createAgentHostClientResourceConnection } from '../common/agentHostClientResourceChannel.js';
|
||||
import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, createAgentHostClientByokLmConnection } from '../common/agentHostClientByokLmChannel.js';
|
||||
import { AGENT_HOST_CLIENT_PROXY_CHANNEL, createAgentHostClientProxyConnection } from '../common/agentHostClientProxyChannel.js';
|
||||
import { IAgentPluginManager } from '../common/agentPluginManager.js';
|
||||
import { AgentPluginManager } from './agentPluginManager.js';
|
||||
import { AgentHostGitService } from './agentHostGitService.js';
|
||||
@@ -139,6 +141,7 @@ async function startAgentHost(): Promise<void> {
|
||||
// after the block) can forward agent-SDK download progress to clients.
|
||||
let sdkDownloadProgress: Event<IAgentSdkDownloadProgress> | undefined;
|
||||
let byokLmBridgeRegistry: ByokLmBridgeRegistry;
|
||||
let proxyResolver: AgentHostProxyResolver | undefined;
|
||||
// Gate BYOK *use* behind the opt-in `chat.agentHost.byokModels.enabled`
|
||||
// setting, forwarded from the renderer as an env var. The proxy and bridge
|
||||
// registry are always constructed below (so the session launcher can inject
|
||||
@@ -195,11 +198,13 @@ async function startAgentHost(): Promise<void> {
|
||||
// stays empty and the proxy never binds when the feature is off.
|
||||
byokLmBridgeRegistry = new ByokLmBridgeRegistry();
|
||||
diServices.set(IByokLmBridgeRegistry, byokLmBridgeRegistry);
|
||||
proxyResolver = instantiationService.createInstance(AgentHostProxyResolver);
|
||||
diServices.set(IAgentHostProxyResolver, proxyResolver);
|
||||
const byokLmProxyService = disposables.add(instantiationService.createInstance(ByokLmProxyService));
|
||||
diServices.set(IByokLmProxyService, byokLmProxyService);
|
||||
const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService));
|
||||
diServices.set(IAgentHostOTelService, agentHostOTelService);
|
||||
agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService);
|
||||
agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService, undefined);
|
||||
diServices.set(IAgentService, agentService);
|
||||
const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService);
|
||||
diServices.set(IAgentPluginManager, pluginManager);
|
||||
@@ -296,6 +301,8 @@ async function startAgentHost(): Promise<void> {
|
||||
const getChannel = (channelName: string) => server.getChannel(channelName, c => c.ctx === clientId);
|
||||
const fsConnection = createAgentHostClientResourceConnection(getChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL));
|
||||
connectionStore.add(clientFileSystemProvider.registerAuthority(clientId, fsConnection));
|
||||
const proxyConnection = createAgentHostClientProxyConnection(getChannel(AGENT_HOST_CLIENT_PROXY_CHANNEL));
|
||||
connectionStore.add(proxyResolver.register(clientId, proxyConnection));
|
||||
// BYOK bridge is gated: only wire it when the feature is enabled, so
|
||||
// the registry stays empty (and the launcher synthesizes no BYOK
|
||||
// providers/models) when `chat.agentHost.byokModels.enabled` is off.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { LogLevel as ProxyLogLevel, ProxyAgentParams, ProxySupportSetting, createProxyResolver, loadSystemCertificates } from '@vscode/proxy-agent';
|
||||
import { IDisposable, toDisposable } from '../../../base/common/lifecycle.js';
|
||||
import { IConfigurationService } from '../../configuration/common/configuration.js';
|
||||
import { createDecorator } from '../../instantiation/common/instantiation.js';
|
||||
import { ILogService, LogLevel } from '../../log/common/log.js';
|
||||
import { systemCertificatesNodeDefault } from '../../request/common/request.js';
|
||||
import { IAgentHostClientProxyConnection } from '../common/agentHostClientProxyChannel.js';
|
||||
|
||||
export const IAgentHostProxyResolver = createDecorator<IAgentHostProxyResolver>('agentHostProxyResolver');
|
||||
|
||||
/**
|
||||
* Node-side registry of renderer {@link IAgentHostClientProxyConnection}s keyed
|
||||
* by client id. Populated by the agent host's connection lifecycle (one entry
|
||||
* per connected renderer) and consumed by {@link CopilotAgent} to resolve the
|
||||
* CAPI proxy through VS Code's Electron session before spawning the Copilot SDK.
|
||||
*
|
||||
* Proxy configuration is a property of the machine, not of a particular window,
|
||||
* so any connected renderer can serve the lookup; the resolver calls the first
|
||||
* available connection and falls through to the next on failure.
|
||||
*/
|
||||
export interface IAgentHostProxyResolver {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
/** Register a renderer connection. Disposing the result removes it. */
|
||||
register(clientId: string, connection: IAgentHostClientProxyConnection): IDisposable;
|
||||
|
||||
/**
|
||||
* Resolve the proxy URL for `url` (e.g. `http://host:port`), or `undefined`
|
||||
* for a direct connection. Reuses `@vscode/proxy-agent`'s `resolveProxyURL`
|
||||
* so the same precedence as the rest of VS Code applies: `http.noProxy` →
|
||||
* `http.proxy` setting → `HTTP(S)_PROXY` env vars → the host proxy resolution
|
||||
* that runs in VS Code (Electron session) via the reverse channel.
|
||||
*/
|
||||
resolveProxy(url: string): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
export class AgentHostProxyResolver implements IAgentHostProxyResolver {
|
||||
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly _connections = new Map<string, IAgentHostClientProxyConnection>();
|
||||
private _resolveProxyURL: ((url: string) => Promise<string | undefined>) | undefined;
|
||||
|
||||
constructor(
|
||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
) { }
|
||||
|
||||
register(clientId: string, connection: IAgentHostClientProxyConnection): IDisposable {
|
||||
this._connections.set(clientId, connection);
|
||||
return toDisposable(() => {
|
||||
if (this._connections.get(clientId) === connection) {
|
||||
this._connections.delete(clientId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resolveProxy(url: string): Promise<string | undefined> {
|
||||
return this._getResolveProxyURL()(url);
|
||||
}
|
||||
|
||||
private _getResolveProxyURL(): (url: string) => Promise<string | undefined> {
|
||||
if (!this._resolveProxyURL) {
|
||||
// Mirror `workbench/api/node/proxyResolver.ts`.
|
||||
const config = <T>(key: string): T | undefined => this._configurationService.getValue<T>(key);
|
||||
const systemCertificatesV2 = () => config<boolean>('http.experimental.systemCertificatesV2') ?? false;
|
||||
const systemCertificates = () => !!config<boolean>('http.systemCertificates');
|
||||
const params: ProxyAgentParams = {
|
||||
// The host proxy resolution runs in VS Code: reverse-call a connected
|
||||
// renderer, whose IRequestService.resolveProxy hits the Electron
|
||||
// session (system settings / PAC scripts).
|
||||
resolveProxy: (url) => this._hostResolveProxy(url),
|
||||
getProxyURL: () => config<string>('http.proxy'),
|
||||
getProxySupport: () => config<ProxySupportSetting>('http.proxySupport') || 'off',
|
||||
getNoProxyConfig: () => config<string[]>('http.noProxy') || [],
|
||||
isAdditionalFetchSupportEnabled: () => config<boolean>('http.fetchAdditionalSupport') ?? true,
|
||||
isWebSocketPatchEnabled: () => config<boolean>('http.webSocketAdditionalSupport') ?? true,
|
||||
addCertificatesV1: () => !systemCertificatesV2() && systemCertificates(),
|
||||
addCertificatesV2: () => systemCertificatesV2() && systemCertificates(),
|
||||
loadSystemCertificatesFromNode: () => config<boolean>('http.systemCertificatesNode') ?? systemCertificatesNodeDefault,
|
||||
loadAdditionalCertificates: async () => loadSystemCertificates({
|
||||
loadSystemCertificatesFromNode: () => config<boolean>('http.systemCertificatesNode') ?? systemCertificatesNodeDefault,
|
||||
log: this._logService,
|
||||
}),
|
||||
log: this._logService,
|
||||
getLogLevel: () => {
|
||||
switch (this._logService.getLevel()) {
|
||||
case LogLevel.Trace: return ProxyLogLevel.Trace;
|
||||
case LogLevel.Debug: return ProxyLogLevel.Debug;
|
||||
case LogLevel.Info: return ProxyLogLevel.Info;
|
||||
case LogLevel.Warning: return ProxyLogLevel.Warning;
|
||||
case LogLevel.Error: return ProxyLogLevel.Error;
|
||||
case LogLevel.Off: return ProxyLogLevel.Off;
|
||||
default: return ProxyLogLevel.Info;
|
||||
}
|
||||
},
|
||||
proxyResolveTelemetry: () => { },
|
||||
// Only the local agent host wires the reverse proxy channel
|
||||
// and we want to look up the client's proxy settings only
|
||||
// when the agent host is local (i.e., on the same machine as
|
||||
// the client).
|
||||
isUseHostProxyEnabled: () => this._connections.size > 0,
|
||||
getNetworkInterfaceCheckInterval: () => (config<number>('http.experimental.networkInterfaceCheckInterval') ?? 300) * 1000,
|
||||
env: process.env,
|
||||
};
|
||||
this._resolveProxyURL = createProxyResolver(params).resolveProxyURL;
|
||||
}
|
||||
return this._resolveProxyURL;
|
||||
}
|
||||
|
||||
private async _hostResolveProxy(url: string): Promise<string | undefined> {
|
||||
for (const connection of this._connections.values()) {
|
||||
try {
|
||||
return await connection.resolveProxy(url);
|
||||
} catch {
|
||||
// This renderer could not serve the lookup; try the next one.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { generateUuid } from '../../../base/common/uuid.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { ICommonProperties } from '../../telemetry/common/telemetry.js';
|
||||
|
||||
/**
|
||||
* Public GitHub Copilot telemetry ingestion keys. These are instrumentation keys, not
|
||||
* secrets; the iKey selects the destination hydro table:
|
||||
* - standard -> `copilot_v0_copilot_event`
|
||||
* - enhanced -> `copilot_v0_restricted_copilot_event`
|
||||
*/
|
||||
const GH_STANDARD_IKEY = '7d7048df-6dd0-4048-bb23-b716c1461f8f';
|
||||
const GH_ENHANCED_IKEY = '3fdd7f28-937a-48c8-9a21-ba337db23bd1';
|
||||
|
||||
/**
|
||||
* Fallback Copilot telemetry endpoint (the dotcom value of the CAPI token's
|
||||
* `endpoints.telemetry`, with the `/telemetry` path the Copilot CLI/runtime appends).
|
||||
* Used until {@link IAgentHostRestrictedTelemetry.setRestrictedTelemetryEndpoint} supplies
|
||||
* the user's discovered endpoint (dotcom, GHE, or proxy). Accepts unauthenticated POSTs.
|
||||
*/
|
||||
const GH_TELEMETRY_URL = 'https://copilot-telemetry.githubusercontent.com/telemetry';
|
||||
|
||||
/** Event names are namespaced by client category; the CTS name filter requires this. */
|
||||
const NAMESPACE = 'copilot-chat';
|
||||
|
||||
export type TelemetryProps = Record<string, string | undefined>;
|
||||
export type TelemetryMeasurements = Record<string, number | undefined>;
|
||||
|
||||
/** The subset of the global `fetch` used to POST envelopes; injectable so tests avoid live network calls. */
|
||||
type FetchFn = typeof globalThis.fetch;
|
||||
|
||||
/**
|
||||
* App Insights caps a single property value at ~8192 chars. Long values are split across
|
||||
* numbered keys (`key`, `key_02`, `key_03`, …) so the Copilot Telemetry Service reassembles
|
||||
* them, mirroring the Copilot extension's `multiplexProperties` so events look identical on the
|
||||
* wire and downstream.
|
||||
*/
|
||||
const MAX_PROPERTY_LENGTH = 8192;
|
||||
const MAX_CONCATENATED_PROPERTIES = 50;
|
||||
|
||||
export function multiplexProperties(properties: TelemetryProps): TelemetryProps {
|
||||
const newProperties: TelemetryProps = { ...properties };
|
||||
for (const key in properties) {
|
||||
const value = properties[key];
|
||||
let remaining = value?.length ?? 0;
|
||||
if (remaining > MAX_PROPERTY_LENGTH) {
|
||||
let lastStartIndex = 0;
|
||||
let count = 0;
|
||||
while (remaining > 0 && count < MAX_CONCATENATED_PROPERTIES) {
|
||||
count += 1;
|
||||
let propertyName = key;
|
||||
if (count > 1) {
|
||||
propertyName = key + '_' + (count < 10 ? '0' : '') + count;
|
||||
}
|
||||
let offsetIndex = lastStartIndex + MAX_PROPERTY_LENGTH;
|
||||
if (remaining < MAX_PROPERTY_LENGTH) {
|
||||
offsetIndex = lastStartIndex + remaining;
|
||||
}
|
||||
newProperties[propertyName] = value!.slice(lastStartIndex, offsetIndex);
|
||||
remaining -= MAX_PROPERTY_LENGTH;
|
||||
lastStartIndex += MAX_PROPERTY_LENGTH;
|
||||
}
|
||||
}
|
||||
}
|
||||
return newProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* The restricted telemetry surface the agent host exposes, mirroring the Copilot extension's
|
||||
* `ITelemetryService` restricted methods so agent-host code can emit the same GH/MSFT events.
|
||||
*/
|
||||
export interface IAgentHostRestrictedTelemetry {
|
||||
/** GH standard (non-restricted) telemetry -> `copilot_v0_copilot_event`. */
|
||||
sendGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void;
|
||||
/** GH enhanced/restricted telemetry (prompts, tools, etc.) -> `copilot_v0_restricted_copilot_event`. */
|
||||
sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void;
|
||||
/** MSFT-internal telemetry -> Aria/Collector++ (internal-only table). No-op without an internal key. */
|
||||
sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void;
|
||||
/** Sets the Copilot user tracking id (`copilot_trackingId`) carried on every subsequent event. */
|
||||
setCopilotTrackingId(trackingId: string | undefined): void;
|
||||
/** Overrides the POST endpoint with the user's CAPI `endpoints.telemetry`; falsy restores the default. */
|
||||
setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void;
|
||||
/** Enables enhanced GH telemetry once the token opts in (`rt=1`); off by default and on flip/logout. */
|
||||
setRestrictedTelemetryEnabled(enabled: boolean): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits GitHub Copilot restricted/enhanced telemetry from the agent-host process by POSTing
|
||||
* Application-Insights envelopes to the Copilot telemetry endpoint (the same wire format the
|
||||
* Copilot extension uses). Fire-and-forget; failures are logged, never thrown.
|
||||
*/
|
||||
export class AgentHostRestrictedTelemetrySender implements IAgentHostRestrictedTelemetry {
|
||||
|
||||
private readonly _commonProps: TelemetryProps;
|
||||
|
||||
/**
|
||||
* Whether the current Copilot token opts into enhanced/restricted telemetry (`rt=1`). Off by
|
||||
* default so the sole writer to the restricted table never emits for public users — a hard
|
||||
* safety boundary that holds even if the enclosing service's gate is bypassed. Mirrors the
|
||||
* Copilot extension, which only creates the restricted reporter for opted-in users.
|
||||
*/
|
||||
private _restrictedTelemetryEnabled = false;
|
||||
|
||||
constructor(
|
||||
commonProperties: ICommonProperties,
|
||||
private readonly _logService: ILogService,
|
||||
private _endpointUrl: string = GH_TELEMETRY_URL,
|
||||
private readonly _internalSink?: (eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements) => void,
|
||||
private readonly _fetchFn: FetchFn = globalThis.fetch,
|
||||
) {
|
||||
// Map the resolved common properties onto the GH property names the hydro schema reads.
|
||||
this._commonProps = {
|
||||
client_machineid: asString(commonProperties['common.machineId']),
|
||||
client_deviceid: asString(commonProperties['common.devDeviceId']),
|
||||
client_sessionid: asString(commonProperties['sessionID']),
|
||||
common_os: asString(commonProperties['common.nodePlatform']) ?? process.platform,
|
||||
editor_version: asString(commonProperties['version']),
|
||||
};
|
||||
}
|
||||
|
||||
sendGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void {
|
||||
this._post(GH_STANDARD_IKEY, eventName, properties, measurements);
|
||||
}
|
||||
|
||||
sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void {
|
||||
// Hard safety boundary: enhanced/restricted telemetry is the pipeline that may carry prompt
|
||||
// and tool content, so the only writer to the restricted table refuses to emit unless the
|
||||
// user's token opted in (`rt=1`). This holds even if a caller reaches the sender without the
|
||||
// service-level `rt`/telemetry-level gate.
|
||||
if (!this._restrictedTelemetryEnabled) {
|
||||
return;
|
||||
}
|
||||
this._post(GH_ENHANCED_IKEY, eventName, properties, measurements);
|
||||
}
|
||||
|
||||
sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void {
|
||||
// Internal MSFT telemetry lands in the Aria/Collector++ pipeline via a dedicated key,
|
||||
// which is not present in the agent-host product config. Route to the optional sink when
|
||||
// wired; otherwise trace so the event is at least visible in the agent-host log.
|
||||
if (this._internalSink) {
|
||||
this._internalSink(eventName, properties, measurements);
|
||||
return;
|
||||
}
|
||||
this._logService.trace(`[ahp-restricted] internal MSFT event (not sent, no internal key): ${eventName}`);
|
||||
}
|
||||
|
||||
setCopilotTrackingId(trackingId: string | undefined): void {
|
||||
// `copilot_trackingId` is the Copilot token's `tid` claim: a stable per-user id (one user
|
||||
// per agent-host process). The Copilot Telemetry Service reads it into the
|
||||
// `copilot_tracking_id` column, matching the Copilot extension.
|
||||
this._commonProps.copilot_trackingId = trackingId || undefined;
|
||||
}
|
||||
|
||||
setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void {
|
||||
// The user's telemetry host comes from the CAPI `endpoints.telemetry` discovery; fall back
|
||||
// to the dotcom default when it is unknown so events are never sent to an empty URL.
|
||||
this._endpointUrl = endpointUrl || GH_TELEMETRY_URL;
|
||||
}
|
||||
|
||||
setRestrictedTelemetryEnabled(enabled: boolean): void {
|
||||
this._restrictedTelemetryEnabled = enabled;
|
||||
}
|
||||
|
||||
private _post(iKey: string, eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void {
|
||||
const name = eventName.includes('/') ? eventName : `${NAMESPACE}/${eventName}`;
|
||||
const envelope = {
|
||||
ver: 1,
|
||||
name: `Microsoft.ApplicationInsights.${iKey.replace(/-/g, '')}.Event`,
|
||||
time: new Date().toISOString(),
|
||||
sampleRate: 100,
|
||||
seq: '',
|
||||
iKey,
|
||||
tags: { 'ai.operation.id': generateUuid() },
|
||||
data: {
|
||||
baseType: 'EventData',
|
||||
baseData: {
|
||||
name,
|
||||
// `unique_id` is a fresh per-event id (its hydro column is read by the Copilot
|
||||
// Telemetry Service from the snake_case `unique_id` property, NOT `uniqueId`),
|
||||
// mirroring the Copilot extension so each emitted event stays individually
|
||||
// addressable. Placed first so explicit properties still win on collision.
|
||||
properties: { unique_id: generateUuid(), ...this._commonProps, ...properties },
|
||||
measurements: measurements ?? {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
this._logService.trace(`[ahp-restricted] emit ${name} (iKey ${iKey.slice(0, 8)})`);
|
||||
|
||||
if (typeof this._fetchFn !== 'function') {
|
||||
this._logService.warn('[ahp-restricted] global fetch unavailable; telemetry not sent');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fire-and-forget: post the event and move on. Delivery/robustness is intentionally kept
|
||||
// simple here — failures are logged, not retried (a retry loop would only mask local
|
||||
// telemetry-blocking resolvers, which do not exist in production).
|
||||
this._fetchFn(this._endpointUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-json-stream' },
|
||||
body: JSON.stringify(envelope),
|
||||
}).then(res => {
|
||||
if (!res.ok) {
|
||||
this._logService.warn(`[ahp-restricted] ${name} rejected: HTTP ${res.status}`);
|
||||
}
|
||||
}).catch(err => {
|
||||
this._logService.warn(`[ahp-restricted] ${name} POST failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function asString(value: string | boolean | undefined): string | undefined {
|
||||
return typeof value === 'string' ? value : value === undefined ? undefined : String(value);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { basename } from '../../../base/common/resources.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { localize } from '../../../nls.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js';
|
||||
import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js';
|
||||
import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js';
|
||||
import { META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName } from '../common/agentHostGitService.js';
|
||||
import { IAgentHostReviewService } from '../common/agentHostReviewService.js';
|
||||
import { ISessionDataService } from '../common/sessionDataService.js';
|
||||
import { ChangesetOperationTargetKind, type InvokeChangesetOperationParams, type InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
|
||||
import { readSessionGitState, type SessionState } from '../common/state/sessionState.js';
|
||||
import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js';
|
||||
|
||||
/**
|
||||
* Handles the `mark-as-reviewed` and `mark-as-unreviewed` resource-scoped
|
||||
* changeset operations for the **Branch Changes** changeset. A single instance
|
||||
* handles one direction — marking a file as reviewed or clearing that mark —
|
||||
* selected by the `_reviewed` flag.
|
||||
*
|
||||
* The reviewed state is owned by {@link IAgentHostReviewService}, which tracks
|
||||
* it as a session-private synthetic git ref. This handler resolves the session's
|
||||
* working directory + base branch and delegates to that service.
|
||||
*/
|
||||
export class AgentHostReviewFileOperationHandler implements IChangesetOperationHandler {
|
||||
|
||||
public static readonly OPERATION_MARK_AS_REVIEWED = 'mark-as-reviewed';
|
||||
public static readonly OPERATION_MARK_AS_UNREVIEWED = 'mark-as-unreviewed';
|
||||
|
||||
constructor(
|
||||
private readonly _reviewed: boolean,
|
||||
private readonly _getSessionState: (sessionKey: string) => SessionState | undefined,
|
||||
@IAgentHostReviewService private readonly _reviewService: IAgentHostReviewService,
|
||||
@IAgentHostChangesetService private readonly _changesetService: IAgentHostChangesetService,
|
||||
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
) { }
|
||||
|
||||
private get _operationId(): string {
|
||||
return this._reviewed
|
||||
? AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED
|
||||
: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED;
|
||||
}
|
||||
|
||||
async invoke(params: InvokeChangesetOperationParams, token: CancellationToken): Promise<InvokeChangesetOperationResult> {
|
||||
const parsed = parseChangesetUri(params.channel);
|
||||
if (!parsed || parsed.kind !== ChangesetKind.Branch) {
|
||||
throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not a branch 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}`);
|
||||
}
|
||||
|
||||
if (params.target?.kind !== ChangesetOperationTargetKind.Resource) {
|
||||
throw new ProtocolError(
|
||||
JsonRpcErrorCodes.InvalidParams,
|
||||
`Operation '${this._operationId}' requires a resource target.`);
|
||||
}
|
||||
|
||||
const workingDirectoryStr = sessionState.workingDirectory;
|
||||
if (!workingDirectoryStr) {
|
||||
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`);
|
||||
}
|
||||
|
||||
const workingDirectory = URI.parse(workingDirectoryStr);
|
||||
const resource = URI.parse(params.target.resource);
|
||||
const baseBranch = await this._resolveBaseBranch(sessionUri, sessionState);
|
||||
|
||||
try {
|
||||
if (this._reviewed) {
|
||||
this._logService.info(`[AgentHostReviewFileOperationHandler] Marking '${resource.fsPath}' as reviewed for session ${sessionUri}`);
|
||||
await this._reviewService.markFileReviewed(sessionUri, workingDirectory, baseBranch, resource);
|
||||
this._changesetService.refreshBranchChangeset(sessionUri);
|
||||
|
||||
return { message: { markdown: localize('agentHost.changeset.reviewFile.marked', "Marked `{0}` as reviewed.", basename(resource)) } };
|
||||
}
|
||||
|
||||
this._logService.info(`[AgentHostReviewFileOperationHandler] Removing reviewed mark for '${resource.fsPath}' in session ${sessionUri}`);
|
||||
await this._reviewService.markFileUnreviewed(sessionUri, workingDirectory, baseBranch, resource);
|
||||
this._changesetService.refreshBranchChangeset(sessionUri);
|
||||
|
||||
return { message: { markdown: localize('agentHost.changeset.reviewFile.unmarked', "Removed the reviewed mark from `{0}`.", basename(resource)) } };
|
||||
} catch (err) {
|
||||
this._throwIfCancelled(token);
|
||||
throw new ProtocolError(
|
||||
JsonRpcErrorCodes.InternalError,
|
||||
`Failed to update reviewed state: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Branch Changes base branch the same way the changeset service
|
||||
* does, so review status is keyed on the same baseline the diff uses.
|
||||
*/
|
||||
private async _resolveBaseBranch(sessionUri: string, sessionState: SessionState): Promise<string | undefined> {
|
||||
const databaseRef = this._sessionDataService.openDatabase(URI.parse(sessionUri));
|
||||
try {
|
||||
const persistedBaseBranch = await databaseRef.object.getMetadata(META_DIFF_BASE_BRANCH);
|
||||
const gitStateBaseBranch = readSessionGitState(sessionState._meta)?.baseBranchName;
|
||||
return resolveDiffBaseBranchName(persistedBaseBranch, gitStateBaseBranch);
|
||||
} finally {
|
||||
databaseRef.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private _throwIfCancelled(token: CancellationToken): void {
|
||||
if (token.isCancellationRequested) {
|
||||
throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.reviewFile.cancelled', "Review file operation was cancelled."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
|
||||
import { localize } from '../../../nls.js';
|
||||
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
|
||||
import { ChangesetKind } from '../common/changesetUri.js';
|
||||
import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js';
|
||||
import { ChangesetOperationScope, ChangesetOperationStatus, type ChangesetOperation } from '../common/state/sessionState.js';
|
||||
import { AgentHostReviewFileOperationHandler } from './agentHostReviewFileOperationHandler.js';
|
||||
import { AgentHostStateManager } from './agentHostStateManager.js';
|
||||
|
||||
/**
|
||||
* Contributes the `mark-as-reviewed` / `mark-as-unreviewed` resource-scoped
|
||||
* operations for the **Branch Changes** changeset, backed by
|
||||
* {@link AgentHostReviewFileOperationHandler}.
|
||||
*/
|
||||
export class AgentHostReviewOperationContribution extends Disposable implements IChangesetOperationContribution {
|
||||
|
||||
constructor(
|
||||
private readonly _stateManager: AgentHostStateManager,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
registerHandlers(registry: IChangesetOperationRegistry): IDisposable {
|
||||
const store = new DisposableStore();
|
||||
const getSessionState = (sessionKey: string) => this._stateManager.getSessionState(sessionKey);
|
||||
const markHandler = this._instantiationService.createInstance(AgentHostReviewFileOperationHandler, true, getSessionState);
|
||||
const unmarkHandler = this._instantiationService.createInstance(AgentHostReviewFileOperationHandler, false, getSessionState);
|
||||
store.add(registry.registerChangesetOperationHandler(AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, markHandler));
|
||||
store.add(registry.registerChangesetOperationHandler(AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED, unmarkHandler));
|
||||
return store;
|
||||
}
|
||||
|
||||
getOperations({ changesetKind }: IChangesetOperationContext): ChangesetOperation[] {
|
||||
if (changesetKind !== ChangesetKind.Branch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED,
|
||||
label: localize('agentHost.changeset.markAsReviewed', "Mark as Reviewed"),
|
||||
icon: 'check',
|
||||
group: 'review',
|
||||
scopes: [ChangesetOperationScope.Resource],
|
||||
status: ChangesetOperationStatus.Idle,
|
||||
},
|
||||
{
|
||||
id: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED,
|
||||
label: localize('agentHost.changeset.markAsUnreviewed', "Mark as Unreviewed"),
|
||||
icon: 'check',
|
||||
group: 'review',
|
||||
scopes: [ChangesetOperationScope.Resource],
|
||||
status: ChangesetOperationStatus.Idle,
|
||||
},
|
||||
] satisfies ChangesetOperation[];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { SequencerByKey } from '../../../base/common/async.js';
|
||||
import { Disposable } from '../../../base/common/lifecycle.js';
|
||||
import { relativePath } from '../../../base/common/resources.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { AgentSession } from '../common/agentService.js';
|
||||
import type { URI as ProtocolURI } from '../common/state/sessionState.js';
|
||||
import { EMPTY_TREE_OBJECT, IAgentHostGitService } from '../common/agentHostGitService.js';
|
||||
import { buildReviewedRefName, IAgentHostReviewService } from '../common/agentHostReviewService.js';
|
||||
import { ISessionDataService } from '../common/sessionDataService.js';
|
||||
import { AgentHostStateManager } from './agentHostStateManager.js';
|
||||
|
||||
/**
|
||||
* Resolved git context shared by the review operations: the repository root,
|
||||
* the Branch Changes baseline tree, and the current reviewed ref/tree.
|
||||
*/
|
||||
interface IReviewContext {
|
||||
readonly repoRoot: URI;
|
||||
/** Tree object of the baseline. */
|
||||
readonly baselineTree: string;
|
||||
/** Name of the session's reviewed ref. */
|
||||
readonly reviewedRef: string;
|
||||
/** Current reviewed commit, or `undefined` when the ref does not exist yet. */
|
||||
readonly reviewedCommit: string | undefined;
|
||||
/** Current reviewed tree; equals `baselineTree` when the ref does not exist. */
|
||||
readonly reviewedTree: string;
|
||||
}
|
||||
|
||||
export class AgentHostReviewService extends Disposable implements IAgentHostReviewService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
/**
|
||||
* Serializes mark/unmark/read per session so back-to-back mutations don't
|
||||
* race on the reviewed ref rebuild and reads observe a consistent ref.
|
||||
*/
|
||||
private readonly _sequencer = new SequencerByKey<string>();
|
||||
|
||||
constructor(
|
||||
private readonly _stateManager: AgentHostStateManager,
|
||||
@IAgentHostGitService private readonly _gitService: IAgentHostGitService,
|
||||
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
) {
|
||||
super();
|
||||
|
||||
// When a session's data directory is about to be deleted, delete the
|
||||
// reviewed ref we created for it. The working directory needed to
|
||||
// resolve the repository root is supplied by the event (resolved from
|
||||
// live session state) so we don't persist our own copy.
|
||||
this._register(this._sessionDataService.onWillDeleteSessionData(e => {
|
||||
e.waitUntil(this.disposeSessionData(e.session.toString()));
|
||||
}));
|
||||
}
|
||||
|
||||
markFileReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise<void> {
|
||||
return this._sequencer.queue(session, () => this._setReviewed(session, workingDirectory, baseBranch, resource, true));
|
||||
}
|
||||
|
||||
markFileUnreviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise<void> {
|
||||
return this._sequencer.queue(session, () => this._setReviewed(session, workingDirectory, baseBranch, resource, false));
|
||||
}
|
||||
|
||||
getReviewedPaths(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise<ReadonlySet<string>> {
|
||||
return this._sequencer.queue(session, () => this._getReviewedPaths(session, workingDirectory, baseBranch));
|
||||
}
|
||||
|
||||
copyReviewedRef(sourceSession: ProtocolURI, targetSession: ProtocolURI, workingDirectory: URI): Promise<void> {
|
||||
return this._sequencer.queue(targetSession, () => this._copyReviewedRef(sourceSession, targetSession, workingDirectory));
|
||||
}
|
||||
|
||||
private async _copyReviewedRef(sourceSession: ProtocolURI, targetSession: ProtocolURI, workingDirectory: URI): Promise<void> {
|
||||
const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
|
||||
if (!repoRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceRef = buildReviewedRefName(this._sanitizedSessionId(sourceSession));
|
||||
const sourceCommit = await this._gitService.revParse(repoRoot, sourceRef);
|
||||
if (!sourceCommit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRef = buildReviewedRefName(this._sanitizedSessionId(targetSession));
|
||||
await this._gitService.updateRef(repoRoot, targetRef, sourceCommit);
|
||||
this._logService.trace(`[AgentHostReview][_copyReviewedRef] Copied reviewed ref ${sourceRef} -> ${targetRef} for fork`);
|
||||
}
|
||||
|
||||
private async _setReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI, reviewed: boolean): Promise<void> {
|
||||
const context = await this._resolveContext(session, workingDirectory, baseBranch);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = relativePath(context.repoRoot, resource);
|
||||
if (!path) {
|
||||
this._logService.warn(`[AgentHostReview][_setReviewed] '${resource.toString()}' is not under the repository root '${context.repoRoot.toString()}'; skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// To mark a file reviewed, overlay its current working-tree content into
|
||||
// the reviewed tree; to unmark, reset it to the baseline content.
|
||||
let source: string | undefined;
|
||||
if (reviewed) {
|
||||
source = await this._gitService.captureWorkingTreeAsTree(workingDirectory);
|
||||
} else {
|
||||
source = context.baselineTree;
|
||||
}
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTree = await this._gitService.overlayPathIntoTree(context.repoRoot, context.reviewedTree, path, source);
|
||||
if (!newTree) {
|
||||
return;
|
||||
}
|
||||
if (newTree === context.reviewedTree) {
|
||||
// No change (already reviewed / already unreviewed).
|
||||
// Don't grow the reviewed ref chain with a no-op
|
||||
// commit.
|
||||
return;
|
||||
}
|
||||
|
||||
// The reviewed ref is a session-private chain disconnected from the
|
||||
// real git history (mirroring the checkpoint baseline): the first
|
||||
// commit is a parentless root, and subsequent commits chain onto the
|
||||
// prior reviewed commit.
|
||||
const message = `review: ${reviewed ? 'mark' : 'unmark'} ${path}`;
|
||||
const commit = await this._gitService.commitTree(context.repoRoot, newTree, context.reviewedCommit, message);
|
||||
if (!commit) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._gitService.updateRef(context.repoRoot, context.reviewedRef, commit);
|
||||
|
||||
this._logService.trace(`[AgentHostReview][_setReviewed] ${message} for ${session.toString()} -> ${context.reviewedRef}@${commit}`);
|
||||
}
|
||||
|
||||
private async _getReviewedPaths(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise<ReadonlySet<string>> {
|
||||
const context = await this._resolveContext(session, workingDirectory, baseBranch);
|
||||
if (!context?.reviewedCommit) {
|
||||
// No reviewed ref yet means
|
||||
// nothing has been reviewed.
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const workingTree = await this._gitService.captureWorkingTreeAsTree(workingDirectory);
|
||||
if (!workingTree) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
// Changed = files that differ between the baseline and the working tree
|
||||
// (the Branch Changes universe). Unreviewed = files that still differ
|
||||
// between the reviewed tree and the working tree. Reviewed is the
|
||||
// difference: changed files whose reviewed content already matches the
|
||||
// working tree.
|
||||
const [changed, unreviewed] = await Promise.all([
|
||||
this._gitService.diffTreePaths(context.repoRoot, context.baselineTree, workingTree),
|
||||
this._gitService.diffTreePaths(context.repoRoot, context.reviewedTree, workingTree),
|
||||
]);
|
||||
if (!changed) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const unreviewedSet = new Set(unreviewed ?? []);
|
||||
return new Set(changed.filter(path => !unreviewedSet.has(path)));
|
||||
}
|
||||
|
||||
private async _resolveContext(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise<IReviewContext | undefined> {
|
||||
const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory);
|
||||
if (!repoRoot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const baselineCommit = await this._gitService.resolveBranchBaselineCommit(workingDirectory, baseBranch);
|
||||
if (!baselineCommit) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const baselineTree = baselineCommit !== EMPTY_TREE_OBJECT
|
||||
? await this._gitService.revParse(repoRoot, `${baselineCommit}^{tree}`)
|
||||
: EMPTY_TREE_OBJECT;
|
||||
if (!baselineTree) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const reviewedRef = buildReviewedRefName(this._sanitizedSessionId(session));
|
||||
const reviewedCommit = await this._gitService.revParse(repoRoot, reviewedRef);
|
||||
const reviewedTree = reviewedCommit
|
||||
? await this._gitService.revParse(repoRoot, `${reviewedCommit}^{tree}`) ?? baselineTree
|
||||
: baselineTree;
|
||||
|
||||
return { repoRoot, baselineTree, reviewedRef, reviewedCommit, reviewedTree };
|
||||
}
|
||||
|
||||
async disposeSessionData(session: ProtocolURI): Promise<void> {
|
||||
await this._sequencer.queue(session, () => this._disposeSessionData(session));
|
||||
}
|
||||
|
||||
private async _disposeSessionData(session: ProtocolURI): Promise<void> {
|
||||
const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory;
|
||||
if (!workingDirectory) {
|
||||
// No working directory means we can't resolve the repository root
|
||||
// (session was never git-backed, or its working directory is gone).
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = await this._gitService.getRepositoryRoot(URI.parse(workingDirectory));
|
||||
if (!repoRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const reviewedRef = buildReviewedRefName(this._sanitizedSessionId(session));
|
||||
await this._gitService.deleteRefs(repoRoot, [reviewedRef]);
|
||||
|
||||
this._logService.trace(`[AgentHostReview][_disposeSessionData] Deleted reviewed ref for ${session}`);
|
||||
} catch (err) {
|
||||
this._logService.warn(`[AgentHostReview][_disposeSessionData] Failed to dispose reviewed ref for ${session}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
private _sanitizedSessionId(session: ProtocolURI): string {
|
||||
return AgentSession.id(session).replace(/[^a-zA-Z0-9_.-]/g, '-');
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { InstantiationService } from '../../instantiation/common/instantiationSe
|
||||
import { ServiceCollection } from '../../instantiation/common/serviceCollection.js';
|
||||
import { registerAgentHostNetworkServices } from './agentHostBootstrap.js';
|
||||
import { CopilotAgent } from './copilot/copilotAgent.js';
|
||||
import { AgentHostProxyResolver, IAgentHostProxyResolver } from './agentHostProxyResolver.js';
|
||||
import { IByokLmBridgeRegistry, NullByokLmBridgeRegistry } from './byokLmBridgeRegistry.js';
|
||||
import { IByokLmProxyService, NullByokLmProxyService } from './copilot/byokLmProxyService.js';
|
||||
import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator } from './copilot/copilotBranchNameGenerator.js';
|
||||
@@ -247,7 +248,7 @@ async function main(): Promise<void> {
|
||||
diServices.set(IAgentHostCheckpointService, checkpointService);
|
||||
|
||||
// Create the agent service (owns AgentHostStateManager + AgentSideEffects internally)
|
||||
const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService);
|
||||
const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService, undefined);
|
||||
disposables.add(agentService);
|
||||
diServices.set(IAgentService, agentService);
|
||||
|
||||
@@ -293,6 +294,7 @@ async function main(): Promise<void> {
|
||||
// to satisfy CopilotAgent / CopilotSessionLauncher DI.
|
||||
diServices.set(IByokLmBridgeRegistry, new NullByokLmBridgeRegistry());
|
||||
diServices.set(IByokLmProxyService, new NullByokLmProxyService());
|
||||
diServices.set(IAgentHostProxyResolver, instantiationService.createInstance(AgentHostProxyResolver));
|
||||
const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent));
|
||||
agentService.registerProvider(copilotAgent);
|
||||
log('CopilotAgent registered');
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Disposable } from '../../../base/common/lifecycle.js';
|
||||
import { equals } from '../../../base/common/objects.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { TelemetryLevel } from '../../telemetry/common/telemetry.js';
|
||||
import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type ProgressParams } from '../common/state/sessionActions.js';
|
||||
import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type AuthRequiredParams, type ProgressParams } from '../common/state/sessionActions.js';
|
||||
import type { IStateSnapshot } from '../common/state/sessionProtocol.js';
|
||||
import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer } from '../common/state/sessionReducers.js';
|
||||
import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js';
|
||||
@@ -1414,4 +1414,20 @@ export class AgentHostStateManager extends Disposable {
|
||||
...progress,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an `auth/required` notification on the root channel, asking the
|
||||
* client to obtain a fresh token and push it via `authenticate`. Rides the
|
||||
* same {@link onDidEmitNotification} path as {@link emitProgress}, so both
|
||||
* local (IPC proxy) and remote (WebSocket) renderers receive it. Used for
|
||||
* host-level auth requirements (e.g. an agent whose transport flip makes a
|
||||
* credential newly required) rather than a per-session one.
|
||||
*/
|
||||
emitAuthRequired(params: Omit<AuthRequiredParams, 'channel'>): void {
|
||||
this._onDidEmitNotification.fire({
|
||||
type: 'auth/required',
|
||||
channel: ROOT_STATE_URI,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
import type { LanguageModelToolInvokedClassification, LanguageModelToolInvokedEvent } from '../../telemetry/common/languageModelToolTelemetry.js';
|
||||
import type { ITelemetryService } from '../../telemetry/common/telemetry.js';
|
||||
import { AgentSession } from '../common/agentService.js';
|
||||
import type { MessageAttachment } from '../common/state/protocol/state.js';
|
||||
import { isAhpChatChannel, isSubagentSession, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat } from '../common/state/sessionState.js';
|
||||
import type { MessageAttachment, SessionInputRequestKind, ToolDefinition } from '../common/state/protocol/state.js';
|
||||
import { isAhpChatChannel, isSubagentChatUri, isSubagentSession, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat } from '../common/state/sessionState.js';
|
||||
import type { ToolInvokedResult } from './agentHostToolCallTracker.js';
|
||||
import { multiplexProperties, type IAgentHostRestrictedTelemetry } from './agentHostRestrictedTelemetry.js';
|
||||
|
||||
export type AgentHostUserMessageSentSource = 'direct' | 'queued';
|
||||
|
||||
@@ -81,10 +82,84 @@ export interface IAgentHostToolInvokedReport {
|
||||
invocationTimeMs: number;
|
||||
}
|
||||
|
||||
export interface IAgentHostToolCallStalledEvent {
|
||||
provider: string;
|
||||
agentSessionId: string;
|
||||
isSubagentSession: boolean;
|
||||
blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution;
|
||||
toolId: string;
|
||||
toolSourceKind: string;
|
||||
stalledTimeMs: number;
|
||||
}
|
||||
|
||||
export type IAgentHostToolCallStalledClassification = {
|
||||
provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the stalled agent host tool call.' };
|
||||
agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' };
|
||||
isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the stalled tool call belongs to a subagent session.' };
|
||||
blockerKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the tool call is waiting for confirmation or client execution.' };
|
||||
toolId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The identifier of the stalled tool.' };
|
||||
toolSourceKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the stalled tool is provided by the agent host, an MCP server, or a client.' };
|
||||
stalledTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds that the tool call has remained blocked.' };
|
||||
owner: 'roblourens';
|
||||
comment: 'Tracks agent host tool calls that remain blocked beyond the stall threshold.';
|
||||
};
|
||||
|
||||
export interface IAgentHostToolCallStalledReport {
|
||||
provider: string;
|
||||
session: string;
|
||||
blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution;
|
||||
toolId: string;
|
||||
toolSourceKind: string;
|
||||
stalledTimeMs: number;
|
||||
}
|
||||
|
||||
export interface IAgentHostStalledToolCallCompletedEvent {
|
||||
provider: string;
|
||||
agentSessionId: string;
|
||||
isSubagentSession: boolean;
|
||||
blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution;
|
||||
toolId: string;
|
||||
toolSourceKind: string;
|
||||
result: ToolInvokedResult;
|
||||
totalTimeMs: number;
|
||||
timeAfterStallMs: number;
|
||||
}
|
||||
|
||||
export type IAgentHostStalledToolCallCompletedClassification = {
|
||||
provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the completed agent host tool call.' };
|
||||
agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' };
|
||||
isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the completed tool call belongs to a subagent session.' };
|
||||
blockerKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the tool call had stalled waiting for confirmation or client execution.' };
|
||||
toolId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The identifier of the completed tool.' };
|
||||
toolSourceKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the completed tool is provided by the agent host, an MCP server, or a client.' };
|
||||
result: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the stalled tool call eventually completed successfully, with an error, or through user cancellation.' };
|
||||
totalTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total time in milliseconds from tool call start to completion.' };
|
||||
timeAfterStallMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds from the stall report to tool call completion.' };
|
||||
owner: 'roblourens';
|
||||
comment: 'Tracks agent host tool calls that complete after previously exceeding the stall threshold.';
|
||||
};
|
||||
|
||||
export interface IAgentHostStalledToolCallCompletedReport {
|
||||
provider: string;
|
||||
session: string;
|
||||
blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution;
|
||||
toolId: string;
|
||||
toolSourceKind: string;
|
||||
result: ToolInvokedResult;
|
||||
totalTimeMs: number;
|
||||
timeAfterStallMs: number;
|
||||
}
|
||||
|
||||
export class AgentHostTelemetryReporter {
|
||||
|
||||
constructor(private readonly _telemetryService: ITelemetryService) { }
|
||||
|
||||
/** The restricted GH/MSFT telemetry surface, present when the agent-host telemetry service is wired. */
|
||||
private get _restricted(): IAgentHostRestrictedTelemetry | undefined {
|
||||
const ts = this._telemetryService as Partial<IAgentHostRestrictedTelemetry>;
|
||||
return typeof ts.sendEnhancedGHTelemetryEvent === 'function' ? ts as IAgentHostRestrictedTelemetry : undefined;
|
||||
}
|
||||
|
||||
userMessageSent(provider: string, session: string, sessionState: ISessionWithDefaultChat | undefined, source: AgentHostUserMessageSentSource, attachments: readonly MessageAttachment[] | undefined): void {
|
||||
const attachmentCount = attachments?.length ?? 0;
|
||||
const activeClients = sessionState?.activeClients ?? [];
|
||||
@@ -104,6 +179,32 @@ export class AgentHostTelemetryReporter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the Copilot extension's enhanced GH `request.options.tools` event for the agent-host
|
||||
* flow. The extension emits it per LLM request from its model fetcher; the agent host observes
|
||||
* the equivalent boundary when an `assistant.message` arrives (one per model call). The
|
||||
* extension populates `headerRequestId` with the client-minted `x-request-id`, which the SDK
|
||||
* does not surface on success; we keep the same field name (so science queries are undisturbed)
|
||||
* but fill it with the model call's `x-copilot-service-request-id`, the per-call id the SDK does
|
||||
* expose. `messagesJson` is the raw tool definitions offered for the call, multiplexed across
|
||||
* ~8192-char chunks like the extension, so it lands identically downstream.
|
||||
*
|
||||
* @param session Session URI string; its id becomes `conversationId`.
|
||||
* @param serviceRequestId The model call's `x-copilot-service-request-id`, mapped to the extension's `headerRequestId`. No-ops when absent (e.g. providers that don't surface it).
|
||||
* @param tools The tool definitions offered to the model for this call.
|
||||
*/
|
||||
assistantMessageReceived(session: string, serviceRequestId: string | undefined, tools: readonly ToolDefinition[]): void {
|
||||
const restricted = this._restricted;
|
||||
if (!restricted || !serviceRequestId || tools.length === 0) {
|
||||
return;
|
||||
}
|
||||
restricted.sendEnhancedGHTelemetryEvent('request.options.tools', multiplexProperties({
|
||||
headerRequestId: serviceRequestId,
|
||||
conversationId: AgentSession.id(session),
|
||||
messagesJson: JSON.stringify(tools),
|
||||
}));
|
||||
}
|
||||
|
||||
turnCompleted(report: IAgentHostTurnCompletedReport): void {
|
||||
const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session;
|
||||
this._telemetryService.publicLog2<IAgentHostTurnCompletedEvent, IAgentHostTurnCompletedClassification>('agentHost.turnCompleted', {
|
||||
@@ -132,4 +233,32 @@ export class AgentHostTelemetryReporter {
|
||||
provider: report.provider,
|
||||
});
|
||||
}
|
||||
|
||||
toolCallStalled(report: IAgentHostToolCallStalledReport): void {
|
||||
const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session;
|
||||
this._telemetryService.publicLog2<IAgentHostToolCallStalledEvent, IAgentHostToolCallStalledClassification>('agentHost.toolCallStalled', {
|
||||
provider: report.provider,
|
||||
agentSessionId: AgentSession.id(session),
|
||||
isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session),
|
||||
blockerKind: report.blockerKind,
|
||||
toolId: report.toolId,
|
||||
toolSourceKind: report.toolSourceKind,
|
||||
stalledTimeMs: report.stalledTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
stalledToolCallCompleted(report: IAgentHostStalledToolCallCompletedReport): void {
|
||||
const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session;
|
||||
this._telemetryService.publicLog2<IAgentHostStalledToolCallCompletedEvent, IAgentHostStalledToolCallCompletedClassification>('agentHost.stalledToolCallCompleted', {
|
||||
provider: report.provider,
|
||||
agentSessionId: AgentSession.id(session),
|
||||
isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session),
|
||||
blockerKind: report.blockerKind,
|
||||
toolId: report.toolId,
|
||||
toolSourceKind: report.toolSourceKind,
|
||||
result: report.result,
|
||||
totalTimeMs: report.totalTimeMs,
|
||||
timeAfterStallMs: report.timeAfterStallMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { TelemetryLogAppender } from '../../telemetry/common/telemetryLogAppende
|
||||
import { TelemetryService } from '../../telemetry/common/telemetryService.js';
|
||||
import { getPiiPathsFromEnvironment, isInternalTelemetry, isLoggingOnly, NullTelemetryService, supportsTelemetry, type ITelemetryAppender } from '../../telemetry/common/telemetryUtils.js';
|
||||
import { AgentHostTelemetryLevelConfigKey, agentHostConfigValueToTelemetryLevel } from '../common/agentHostSchema.js';
|
||||
import { AgentHostDevDeviceIdEnvKey, AgentHostMachineIdEnvKey, AgentHostSqmIdEnvKey } from '../common/agentHostTelemetryEnv.js';
|
||||
import { AgentHostRestrictedTelemetrySender, IAgentHostRestrictedTelemetry, TelemetryMeasurements, TelemetryProps } from './agentHostRestrictedTelemetry.js';
|
||||
|
||||
export interface IAgentHostTelemetryServiceOptions {
|
||||
readonly environmentService: INativeEnvironmentService;
|
||||
@@ -32,7 +34,7 @@ export interface IAgentHostTelemetryServiceOptions {
|
||||
readonly disableTelemetry?: boolean;
|
||||
}
|
||||
|
||||
export interface IAgentHostTelemetryService extends ITelemetryService {
|
||||
export interface IAgentHostTelemetryService extends ITelemetryService, IAgentHostRestrictedTelemetry {
|
||||
updateTelemetryLevel(telemetryLevel: TelemetryLevel): void;
|
||||
}
|
||||
|
||||
@@ -41,7 +43,17 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT
|
||||
|
||||
private _telemetryLevel = TelemetryLevel.USAGE;
|
||||
|
||||
constructor(private readonly _delegate: ITelemetryService) {
|
||||
/**
|
||||
* Whether the current Copilot token opts into enhanced/restricted telemetry (`rt=1`). Defaults
|
||||
* to `false` so nothing restricted is sent until an authenticated token confirms the opt-in,
|
||||
* keeping public users off the enhanced pipeline the way the Copilot extension does.
|
||||
*/
|
||||
private _restrictedTelemetryEnabled = false;
|
||||
|
||||
constructor(
|
||||
private readonly _delegate: ITelemetryService,
|
||||
private readonly _restricted?: IAgentHostRestrictedTelemetry,
|
||||
) {
|
||||
super();
|
||||
if (isDisposable(_delegate)) {
|
||||
this._register(_delegate);
|
||||
@@ -108,6 +120,42 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT
|
||||
this._delegate.publicLogError2(eventName, data);
|
||||
}
|
||||
|
||||
sendGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void {
|
||||
if (this.telemetryLevel < TelemetryLevel.USAGE) {
|
||||
return;
|
||||
}
|
||||
this._restricted?.sendGHTelemetryEvent(eventName, properties, measurements);
|
||||
}
|
||||
|
||||
sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void {
|
||||
if (this.telemetryLevel < TelemetryLevel.USAGE || !this._restrictedTelemetryEnabled) {
|
||||
return;
|
||||
}
|
||||
this._restricted?.sendEnhancedGHTelemetryEvent(eventName, properties, measurements);
|
||||
}
|
||||
|
||||
sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void {
|
||||
if (this.telemetryLevel < TelemetryLevel.USAGE) {
|
||||
return;
|
||||
}
|
||||
this._restricted?.sendInternalMSFTTelemetryEvent(eventName, properties, measurements);
|
||||
}
|
||||
|
||||
setCopilotTrackingId(trackingId: string | undefined): void {
|
||||
this._restricted?.setCopilotTrackingId(trackingId);
|
||||
}
|
||||
|
||||
setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void {
|
||||
this._restricted?.setRestrictedTelemetryEndpoint(endpointUrl);
|
||||
}
|
||||
|
||||
setRestrictedTelemetryEnabled(enabled: boolean): void {
|
||||
this._restrictedTelemetryEnabled = enabled;
|
||||
// Mirror onto the sender so the restricted-table writer enforces the same `rt` gate
|
||||
// independently (defense in depth), matching the extension's opted-in-only reporter.
|
||||
this._restricted?.setRestrictedTelemetryEnabled(enabled);
|
||||
}
|
||||
|
||||
setExperimentProperty(name: string, value: string): void {
|
||||
this._delegate.setExperimentProperty(name, value);
|
||||
}
|
||||
@@ -130,7 +178,7 @@ export function updateAgentHostTelemetryLevelFromConfig(telemetryService: ITelem
|
||||
telemetryService.updateTelemetryLevel(telemetryLevelValue);
|
||||
}
|
||||
|
||||
function isAgentHostTelemetryService(telemetryService: ITelemetryService): telemetryService is IAgentHostTelemetryService {
|
||||
export function isAgentHostTelemetryService(telemetryService: ITelemetryService): telemetryService is IAgentHostTelemetryService {
|
||||
return typeof (telemetryService as IAgentHostTelemetryService).updateTelemetryLevel === 'function';
|
||||
}
|
||||
|
||||
@@ -153,18 +201,26 @@ export async function createAgentHostTelemetryService(options: IAgentHostTelemet
|
||||
appenders.push(collectorAppender);
|
||||
}
|
||||
|
||||
// Prefer the host-forwarded identifiers (see `agentHostTelemetryEnv`) so the
|
||||
// agent host reports the same persisted machineId/sqmId/devDeviceId as the
|
||||
// workbench. Fall back to computing them live when not provided (e.g. the
|
||||
// remote/server agent host, which does not forward them).
|
||||
const [machineId, sqmId, devDeviceId] = await Promise.all([
|
||||
getMachineId(error => logService.error(error)),
|
||||
getSqmMachineId(error => logService.error(error)),
|
||||
getDevDeviceId(error => logService.error(error)),
|
||||
process.env[AgentHostMachineIdEnvKey] || getMachineId(error => logService.error(error)),
|
||||
process.env[AgentHostSqmIdEnvKey] || getSqmMachineId(error => logService.error(error)),
|
||||
process.env[AgentHostDevDeviceIdEnvKey] || getDevDeviceId(error => logService.error(error)),
|
||||
]);
|
||||
|
||||
const commonProperties = resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, machineId, sqmId, devDeviceId, internalTelemetry, productService.date);
|
||||
|
||||
const telemetryService = new TelemetryService({
|
||||
appenders,
|
||||
sendErrorTelemetry: true,
|
||||
commonProperties: resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, machineId, sqmId, devDeviceId, internalTelemetry, productService.date),
|
||||
commonProperties,
|
||||
piiPaths: getPiiPathsFromEnvironment(environmentService),
|
||||
}, configurationService, productService);
|
||||
|
||||
return disposables.add(new AgentHostTelemetryService(telemetryService));
|
||||
const restricted = new AgentHostRestrictedTelemetrySender(commonProperties, logService);
|
||||
|
||||
return disposables.add(new AgentHostTelemetryService(telemetryService, restricted));
|
||||
}
|
||||
|
||||
@@ -3,12 +3,19 @@
|
||||
* 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 } from '../../../base/common/lifecycle.js';
|
||||
import { StopWatch } from '../../../base/common/stopwatch.js';
|
||||
import type { SessionToolClientExecutionRequest, SessionToolConfirmationRequest } from '../common/state/protocol/state.js';
|
||||
import { ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../common/state/sessionState.js';
|
||||
import type { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js';
|
||||
|
||||
export type ToolInvokedResult = 'success' | 'error' | 'userCancelled';
|
||||
|
||||
const TOOL_CALL_STALL_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
|
||||
type ToolCallBlockerRequest = SessionToolConfirmationRequest | SessionToolClientExecutionRequest;
|
||||
|
||||
/**
|
||||
* Maps a completed tool call's result to the telemetry result bucket. Mirrors
|
||||
* the derivation previously done inline in `CopilotAgentSession`: a denied,
|
||||
@@ -57,25 +64,34 @@ interface IToolCallTiming {
|
||||
readonly toolSourceKind: string;
|
||||
}
|
||||
|
||||
interface IStalledToolCall {
|
||||
readonly blockerKind: ToolCallBlockerRequest['kind'];
|
||||
readonly completionStopWatch: StopWatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks per-tool-call timing for agent host sessions and reports a
|
||||
* `languageModelToolInvoked` event via the provided
|
||||
* {@link AgentHostTelemetryReporter} when a tool call completes.
|
||||
* Tracks completed and stalled tool calls for agent host sessions.
|
||||
*
|
||||
* Lifecycle per tool call:
|
||||
* 1. {@link toolCallStarted} — begins a stopwatch and records the tool's
|
||||
* name and source kind (only the start action carries these)
|
||||
* 2. {@link toolCallCompleted} — emits the telemetry event and clears state
|
||||
* 3. {@link toolCallBlocked} / {@link toolCallUnblocked} — emits once when a
|
||||
* confirmation or client execution remains unresolved past the threshold
|
||||
*
|
||||
* In-flight tool calls that never complete (e.g. the turn is cancelled mid
|
||||
* tool call) are dropped via {@link clearSession} / {@link clear} so the
|
||||
* tracking map cannot leak.
|
||||
*/
|
||||
export class AgentHostToolCallTracker {
|
||||
export class AgentHostToolCallTracker extends Disposable {
|
||||
|
||||
private readonly _toolCalls = new Map<string, IToolCallTiming>();
|
||||
private readonly _toolCallStallTimers = this._register(new DisposableMap<string>());
|
||||
private readonly _stalledToolCalls = new Map<string, IStalledToolCall>();
|
||||
|
||||
constructor(private readonly _reporter: AgentHostTelemetryReporter) { }
|
||||
constructor(private readonly _reporter: AgentHostTelemetryReporter) {
|
||||
super();
|
||||
}
|
||||
|
||||
toolCallStarted(provider: string, session: string, toolCallId: string, toolName: string, contributor: ToolCallContributor | undefined): void {
|
||||
this._toolCalls.set(this._key(session, toolCallId), {
|
||||
@@ -97,15 +113,58 @@ export class AgentHostToolCallTracker {
|
||||
return;
|
||||
}
|
||||
this._toolCalls.delete(key);
|
||||
const resultBucket = deriveToolInvokedResult(result);
|
||||
const totalTimeMs = timing.stopWatch.elapsed();
|
||||
|
||||
this._reporter.toolInvoked({
|
||||
provider: timing.provider,
|
||||
session: timing.session,
|
||||
toolId: timing.toolId,
|
||||
toolSourceKind: timing.toolSourceKind,
|
||||
result: deriveToolInvokedResult(result),
|
||||
invocationTimeMs: timing.stopWatch.elapsed(),
|
||||
result: resultBucket,
|
||||
invocationTimeMs: totalTimeMs,
|
||||
});
|
||||
|
||||
const stalled = this._stalledToolCalls.get(key);
|
||||
if (stalled) {
|
||||
this._stalledToolCalls.delete(key);
|
||||
this._reporter.stalledToolCallCompleted({
|
||||
provider: timing.provider,
|
||||
session: timing.session,
|
||||
blockerKind: stalled.blockerKind,
|
||||
toolId: timing.toolId,
|
||||
toolSourceKind: timing.toolSourceKind,
|
||||
result: resultBucket,
|
||||
totalTimeMs,
|
||||
timeAfterStallMs: stalled.completionStopWatch.elapsed(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toolCallBlocked(provider: string, session: string, request: ToolCallBlockerRequest): void {
|
||||
const key = this._key(session, request.id);
|
||||
const toolCallKey = this._key(session, request.toolCall.toolCallId);
|
||||
if (this._toolCallStallTimers.has(key) || this._stalledToolCalls.has(toolCallKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stopWatch = StopWatch.create(true);
|
||||
this._toolCallStallTimers.set(key, disposableTimeout(() => {
|
||||
const stalledTimeMs = stopWatch.elapsed();
|
||||
this._stalledToolCalls.set(toolCallKey, { blockerKind: request.kind, completionStopWatch: StopWatch.create(true) });
|
||||
this._reporter.toolCallStalled({
|
||||
provider,
|
||||
session,
|
||||
blockerKind: request.kind,
|
||||
toolId: request.toolCall.toolName,
|
||||
toolSourceKind: toolSourceKindFromContributor(request.toolCall.contributor),
|
||||
stalledTimeMs,
|
||||
});
|
||||
}, TOOL_CALL_STALL_THRESHOLD_MS));
|
||||
}
|
||||
|
||||
toolCallUnblocked(session: string, requestId: string): void {
|
||||
this._toolCallStallTimers.deleteAndDispose(this._key(session, requestId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,10 +179,22 @@ export class AgentHostToolCallTracker {
|
||||
this._toolCalls.delete(key);
|
||||
}
|
||||
}
|
||||
for (const key of this._toolCallStallTimers.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this._toolCallStallTimers.deleteAndDispose(key);
|
||||
}
|
||||
}
|
||||
for (const key of this._stalledToolCalls.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this._stalledToolCalls.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this._toolCalls.clear();
|
||||
this._toolCallStallTimers.clearAndDisposeAll();
|
||||
this._stalledToolCalls.clear();
|
||||
}
|
||||
|
||||
private _key(session: string, toolCallId: string): string {
|
||||
|
||||
@@ -32,17 +32,19 @@ import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../comm
|
||||
import { ISessionGitHubState, ISessionGitState, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionWorkspaceless, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
|
||||
import { IProductService } from '../../product/common/productService.js';
|
||||
import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js';
|
||||
import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js';
|
||||
import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js';
|
||||
import { ISessionDbUriFields, parseSessionDbUri } from './shared/fileEditTracker.js';
|
||||
import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js';
|
||||
import { AgentHostStateManager } from './agentHostStateManager.js';
|
||||
import { IAgentHostGitService } from '../common/agentHostGitService.js';
|
||||
import { AgentSideEffects } from './agentSideEffects.js';
|
||||
import { AgentHostLocalTurns } from './agentHostLocalTurns.js';
|
||||
import { AgentServerToolHost } from './shared/agentServerToolHost.js';
|
||||
import { serverToolGroups } from './shared/serverToolGroups.js';
|
||||
import { AgentHostChangesetService } from './agentHostChangesetService.js';
|
||||
import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js';
|
||||
import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../common/agentHostCheckpointService.js';
|
||||
import { IAgentHostReviewService } from '../common/agentHostReviewService.js';
|
||||
import { AgentHostChangesetCoordinator } from './agentHostChangesetCoordinator.js';
|
||||
import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js';
|
||||
import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvider.js';
|
||||
@@ -66,8 +68,10 @@ import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_G
|
||||
import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js';
|
||||
import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js';
|
||||
import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js';
|
||||
import { AgentHostReviewOperationContribution } from './agentHostReviewOperationProvider.js';
|
||||
import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js';
|
||||
import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js';
|
||||
import { AgentHostReviewService } from './agentHostReviewService.js';
|
||||
|
||||
/**
|
||||
* Grace period before an empty, unsubscribed session is garbage-collected
|
||||
@@ -194,6 +198,8 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
private readonly _gitStateService: IAgentHostGitStateService;
|
||||
/** Manages PTY-backed terminals for the agent host protocol. */
|
||||
private readonly _terminalManager: AgentHostTerminalManager;
|
||||
/** Persists host-injected `/rename` / `!command` turns for restore & fork/truncate. */
|
||||
private readonly _localTurns: AgentHostLocalTurns;
|
||||
/** Server-side host for the agent host's server tools. */
|
||||
private readonly _serverToolHost: AgentServerToolHost;
|
||||
private readonly _configurationService: IAgentConfigurationService;
|
||||
@@ -330,6 +336,10 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
this._changesetOperationService = this._register(instantiationService.createInstance(AgentHostChangesetOperationService, this._stateManager));
|
||||
services.set(IAgentHostChangesetOperationService, this._changesetOperationService);
|
||||
|
||||
// The changes review service is responsible for managing review/unreview state for changeset changes.
|
||||
const reviewService = this._register(instantiationService.createInstance(AgentHostReviewService, this._stateManager));
|
||||
services.set(IAgentHostReviewService, reviewService);
|
||||
|
||||
// The changeset service is responsible for computing, publishing, and persisting changesets.
|
||||
this._changesets = this._register(instantiationService.createInstance(AgentHostChangesetService, this._stateManager));
|
||||
services.set(IAgentHostChangesetService, this._changesets);
|
||||
@@ -344,6 +354,7 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution, this._stateManager)));
|
||||
this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution, this._stateManager)));
|
||||
this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution, this._stateManager)));
|
||||
this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostReviewOperationContribution, this._stateManager)));
|
||||
|
||||
this._completions = this._register(instantiationService.createInstance(AgentHostCompletions));
|
||||
// Built-in generic provider: completes files in the session's workspace folder.
|
||||
@@ -360,9 +371,20 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
),
|
||||
));
|
||||
|
||||
// Terminal management — the terminal manager listens to the state
|
||||
// manager's action stream and dispatches PTY output back through it.
|
||||
// Created before AgentSideEffects and registered in the local scope so
|
||||
// AgentSideEffects can consume it via DI (for inline `!command`
|
||||
// execution).
|
||||
this._terminalManager = this._register(instantiationService.createInstance(AgentHostTerminalManager, this._stateManager));
|
||||
services.set(IAgentHostTerminalManager, this._terminalManager);
|
||||
|
||||
this._localTurns = new AgentHostLocalTurns(this._sessionDataService, this._logService);
|
||||
|
||||
this._sideEffects = this._register(instantiationService.createInstance(AgentSideEffects, this._stateManager, {
|
||||
getAgent: session => this._findProviderForSession(session),
|
||||
sessionDataService: this._sessionDataService,
|
||||
localTurns: this._localTurns,
|
||||
agents: this._agents,
|
||||
copilotApiService: effectiveCopilotApiService,
|
||||
getGitHubCopilotToken: () => {
|
||||
@@ -381,10 +403,6 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
},
|
||||
}));
|
||||
|
||||
// Terminal management — the terminal manager listens to the state
|
||||
// manager's action stream and dispatches PTY output back through it.
|
||||
this._terminalManager = this._register(instantiationService.createInstance(AgentHostTerminalManager, this._stateManager));
|
||||
|
||||
// Server-side tools, executed in-process against each session's own
|
||||
// state. Tool groups are contributed here at startup (feedback today) and
|
||||
// handed to providers that support them during registration (see
|
||||
@@ -581,6 +599,13 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
summary: liveSummary.title || s.summary,
|
||||
status: liveSummary.status,
|
||||
activity: liveSummary.activity,
|
||||
modifiedTime: Date.parse(liveSummary.modifiedAt),
|
||||
project: liveSummary.project
|
||||
? { uri: URI.parse(liveSummary.project.uri), displayName: liveSummary.project.displayName }
|
||||
: s.project,
|
||||
workingDirectory: typeof liveSummary.workingDirectory === 'string'
|
||||
? URI.parse(liveSummary.workingDirectory)
|
||||
: s.workingDirectory,
|
||||
changes: liveSummary.changes ?? s.changes,
|
||||
changesets: this._stateManager.getSessionState(s.session.toString())?.changesets ?? s.changesets,
|
||||
...(_meta !== undefined ? { _meta } : {}),
|
||||
@@ -659,9 +684,15 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
for (const t of sourceTurns) {
|
||||
turnIdMapping.set(t.id, generateUuid());
|
||||
}
|
||||
// The SDK fork boundary must be a concrete (SDK-backed) turn.
|
||||
// When the client forked at a host-injected local turn
|
||||
// (`/rename` / `!command`), redirect the agent to the preceding
|
||||
// concrete turn while still seeding the local turns up to the
|
||||
// fork point into the new session's protocol state below.
|
||||
const concreteForkTurnId = this._localTurns.resolveConcreteTurnId(buildDefaultChatUri(config.fork.session).toString(), config.fork.turnId);
|
||||
config = {
|
||||
...config,
|
||||
fork: { ...config.fork, turnIdMapping },
|
||||
fork: { ...config.fork, turnIdMapping, ...(concreteForkTurnId !== undefined ? { turnId: concreteForkTurnId } : {}) },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -727,10 +758,18 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
// the source session's turns so the client sees the forked history.
|
||||
if (config?.fork) {
|
||||
const sourceState = this._stateManager.getSessionState(config.fork.session.toString());
|
||||
const sourceChatUri = buildDefaultChatUri(config.fork.session).toString();
|
||||
const newChatUri = buildDefaultChatUri(session).toString();
|
||||
let sourceTurns: Turn[] = [];
|
||||
if (sourceState && config.fork.turnIdMapping) {
|
||||
sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1)
|
||||
.map(t => ({ ...t, id: config!.fork!.turnIdMapping!.get(t.id) ?? generateUuid() }));
|
||||
const originalSlice = sourceState.turns.slice(0, config.fork.turnIndex + 1);
|
||||
const mapping = config.fork.turnIdMapping;
|
||||
sourceTurns = originalSlice.map(t => ({ ...t, id: mapping.get(t.id) ?? generateUuid() }));
|
||||
// Re-persist forked local turns (`/rename`, `!command`) under the
|
||||
// new session's default chat. `record` (keyed by turn id)
|
||||
// overwrites any rows a DB copy carried with the SOURCE chat URI,
|
||||
// and seeds the in-memory index for same-process fork/truncate.
|
||||
this._persistForkedLocalTurns(session.toString(), sourceChatUri, newChatUri, originalSlice, sourceTurns, mapping);
|
||||
}
|
||||
|
||||
// Prefix the forked session's title so consumers (sidebar, chat
|
||||
@@ -825,8 +864,12 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
let createOptions = options;
|
||||
if (options?.fork) {
|
||||
const sourceKey = options.fork.source.toString();
|
||||
const sourceState = this._stateManager.getChatState(sourceKey)
|
||||
?? this._stateManager.getDefaultChatState(sourceKey);
|
||||
const peerState = this._stateManager.getChatState(sourceKey);
|
||||
const sourceState = peerState ?? this._stateManager.getDefaultChatState(sourceKey);
|
||||
// Canonical chat URI the source's local turns are keyed by: when the
|
||||
// source was found as a peer chat it is `sourceKey`; otherwise it was
|
||||
// addressed by session URI and its default chat URI is canonical.
|
||||
const sourceChatUri = peerState ? sourceKey : buildDefaultChatUri(sourceKey);
|
||||
const sourceTurns = sourceState?.turns ?? [];
|
||||
const forkIndex = sourceTurns.findIndex(t => t.id === options.fork!.turnId);
|
||||
if (forkIndex < 0) {
|
||||
@@ -842,12 +885,22 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
}
|
||||
forkedTurns = slice.map(t => ({ ...t, id: turnIdMapping.get(t.id) ?? generateUuid() }));
|
||||
|
||||
// Carry forked host-injected local turns (`/rename`, `!command`)
|
||||
// into the new chat so they survive reload and anchor future
|
||||
// fork/truncate.
|
||||
this._persistForkedLocalTurns(sessionKey, sourceChatUri, chat.toString(), slice, forkedTurns, turnIdMapping);
|
||||
|
||||
const forkedTitlePrefix = localize('agentHost.forkedTitlePrefix', "Forked: ");
|
||||
forkedSourceTitle = sourceState?.title || this._stateManager.getSessionState(sessionKey)?.title;
|
||||
forkedTitle = forkedSourceTitle
|
||||
? (forkedSourceTitle.startsWith(forkedTitlePrefix) ? forkedSourceTitle : `${forkedTitlePrefix}${forkedSourceTitle}`)
|
||||
: localize('agentHost.forkedChatFallback', "Forked Chat");
|
||||
createOptions = { ...options, fork: { ...options.fork, turnIdMapping } };
|
||||
// The SDK fork boundary must be a concrete (SDK-backed) turn. When
|
||||
// the client forked at a host-injected local turn, redirect the
|
||||
// agent to the preceding concrete turn (the local turns are still
|
||||
// seeded into the new chat's protocol state above).
|
||||
const concreteForkTurnId = this._localTurns.resolveConcreteTurnId(sourceChatUri, options.fork.turnId);
|
||||
createOptions = { ...options, fork: { ...options.fork, turnIdMapping, ...(concreteForkTurnId !== undefined ? { turnId: concreteForkTurnId } : {}) } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,6 +978,74 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
return provider.chats.getMessages(chat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges persisted host-injected local turns (`/rename`, `!command`) for
|
||||
* `chatUri` back into that chat's SDK-derived `turns`, positioned after
|
||||
* their anchor turn (the concrete turn they were recorded after). Locals
|
||||
* anchored before any real turn are prepended; locals whose anchor is absent
|
||||
* from the SDK turns (e.g. truncated away) are dropped. Also seeds the
|
||||
* in-memory local-turn index so fork/truncate resolve correctly before the
|
||||
* next reload.
|
||||
*/
|
||||
private async _interleaveLocalTurns(sessionStr: string, chatUri: string, turns: readonly Turn[]): Promise<Turn[]> {
|
||||
const records = await this._localTurns.loadForChat(sessionStr, chatUri);
|
||||
if (records.length === 0) {
|
||||
return [...turns];
|
||||
}
|
||||
const knownIds = new Set(turns.map(t => t.id));
|
||||
const byAnchor = new Map<string, Turn[]>();
|
||||
const head: Turn[] = [];
|
||||
for (const record of records) {
|
||||
let turn: Turn;
|
||||
try {
|
||||
turn = JSON.parse(record.payload) as Turn;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (record.anchorTurnId === undefined) {
|
||||
head.push(turn);
|
||||
} else if (knownIds.has(record.anchorTurnId)) {
|
||||
const list = byAnchor.get(record.anchorTurnId) ?? [];
|
||||
list.push(turn);
|
||||
byAnchor.set(record.anchorTurnId, list);
|
||||
}
|
||||
// else: orphaned (anchor truncated away) → drop.
|
||||
}
|
||||
const merged: Turn[] = [...head];
|
||||
for (const turn of turns) {
|
||||
merged.push(turn);
|
||||
const locals = byAnchor.get(turn.id);
|
||||
if (locals) {
|
||||
merged.push(...locals);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-persists forked host-injected local turns (`/rename`, `!command`) into
|
||||
* a newly forked chat so they survive reload and anchor future
|
||||
* fork/truncate. `originalSlice[i]` and `forkedTurns[i]` are the source turn
|
||||
* and its remapped copy (same length, 1:1); `mapping` is the old→new turn id
|
||||
* map used to remap each local turn's anchor. `persistSession` owns the
|
||||
* destination database; `sourceChatUri` / `newChatUri` key the source and
|
||||
* destination local-turn indexes.
|
||||
*
|
||||
* Shared by the {@link createSession} (default-chat) and {@link createChat}
|
||||
* (peer-chat) fork paths.
|
||||
*/
|
||||
private _persistForkedLocalTurns(persistSession: string, sourceChatUri: string, newChatUri: string, originalSlice: readonly Turn[], forkedTurns: readonly Turn[], mapping: ReadonlyMap<string, string>): void {
|
||||
for (let i = 0; i < originalSlice.length; i++) {
|
||||
const original = originalSlice[i];
|
||||
if (!this._localTurns.isLocal(sourceChatUri, original.id)) {
|
||||
continue;
|
||||
}
|
||||
const originalAnchor = this._localTurns.resolveConcreteTurnId(sourceChatUri, original.id);
|
||||
const newAnchor = originalAnchor !== undefined ? mapping.get(originalAnchor) : undefined;
|
||||
this._localTurns.record(persistSession, newChatUri, forkedTurns[i], newAnchor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or fork) the peer chat `chat` within `session`. `chat` is
|
||||
* always a peer URI here (the default chat is created implicitly with
|
||||
@@ -1856,7 +1977,8 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
this._getChatDraft(session, defaultChatUri),
|
||||
this._readPersistedChatTitle(session, defaultChatUri),
|
||||
]);
|
||||
this._stateManager.restoreSession(summary, [...turns], { draft: defaultDraft, defaultChatTitle });
|
||||
const mergedTurns = await this._interleaveLocalTurns(sessionStr, defaultChatUri.toString(), turns);
|
||||
this._stateManager.restoreSession(summary, mergedTurns, { draft: defaultDraft, defaultChatTitle });
|
||||
|
||||
const promises: Promise<unknown>[] = [];
|
||||
// Eagerly register subagent child sessions discovered in the event log
|
||||
@@ -2034,7 +2156,8 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
this._readPersistedChatTitle(session, chatUri),
|
||||
this._getChatDraft(session, chatUri),
|
||||
]);
|
||||
return { chatUri, title, turns: [...turns], draft, providerData: entry.providerData };
|
||||
const mergedTurns = await this._interleaveLocalTurns(session.toString(), chatUri.toString(), turns);
|
||||
return { chatUri, title, turns: mergedTurns, draft, providerData: entry.providerData };
|
||||
}));
|
||||
for (const item of restored) {
|
||||
if (!item) {
|
||||
@@ -2820,6 +2943,10 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
const title = subagentContent?.title ?? 'Subagent';
|
||||
|
||||
const subagentNow = new Date().toISOString();
|
||||
// Local turns for a subagent chat are persisted in the parent session's
|
||||
// database (its chat URI resolves to the parent session), keyed by the
|
||||
// subagent chat URI.
|
||||
const mergedChildTurns = await this._interleaveLocalTurns(parentSession.toString(), subagentUri, childTurns);
|
||||
this._stateManager.restoreSession(
|
||||
{
|
||||
resource: subagentUri,
|
||||
@@ -2830,7 +2957,7 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
modifiedAt: subagentNow,
|
||||
...(parentState?.project ? { project: parentState.project } : {}),
|
||||
},
|
||||
[...childTurns],
|
||||
mergedChildTurns,
|
||||
);
|
||||
this._logService.info(`[AgentService] Restored subagent session: ${subagentUri} with ${childTurns.length} turn(s)`);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { autorun, IObservable, IReader } from '../../../base/common/observable.j
|
||||
import { hasKey } from '../../../base/common/types.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { generateUuid } from '../../../base/common/uuid.js';
|
||||
import { localize } from '../../../nls.js';
|
||||
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
|
||||
import { ILogService } from '../../log/common/log.js';
|
||||
import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js';
|
||||
@@ -47,16 +46,19 @@ import {
|
||||
type ToolResultContent,
|
||||
type Turn
|
||||
} from '../common/state/sessionState.js';
|
||||
import { parseRenameCommand } from './agentHostRenameCommand.js';
|
||||
import { AgentHostLocalTurns } from './agentHostLocalTurns.js';
|
||||
import { AgentHostSessionTitleController } from './agentHostSessionTitleController.js';
|
||||
import { AgentHostStateManager } from './agentHostStateManager.js';
|
||||
import { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js';
|
||||
import { AgentHostToolCallTracker } from './agentHostToolCallTracker.js';
|
||||
import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js';
|
||||
import { AgentHostTurnTracker } from './agentHostTurnTracker.js';
|
||||
import { AgentHostLocalCommands } from './localCommands/localChatCommand.js';
|
||||
import './localCommands/localChatCommands.contribution.js';
|
||||
import { SessionPermissionManager } from './sessionPermissions.js';
|
||||
import type { ICopilotApiService } from './shared/copilotApiService.js';
|
||||
import { stripProxyErrorMarker, toChatErrorMeta, tryParseForwardedChatError } from './shared/forwardedChatError.js';
|
||||
import { persistSessionMetadata } from './shared/persistSessionMetadata.js';
|
||||
|
||||
/**
|
||||
* Options for constructing an {@link AgentSideEffects} instance.
|
||||
@@ -68,6 +70,8 @@ export interface IAgentSideEffectsOptions {
|
||||
readonly agents: IObservable<readonly IAgent[]>;
|
||||
/** Session data service for cleaning up per-session data on disposal. */
|
||||
readonly sessionDataService: ISessionDataService;
|
||||
/** Registry that persists host-injected `/rename` and `!command` turns. */
|
||||
readonly localTurns: AgentHostLocalTurns;
|
||||
/** Get the GitHub token used for Copilot utility title generation. */
|
||||
readonly getGitHubCopilotToken?: () => string | undefined;
|
||||
/** CAPI service used for Copilot utility title generation. */
|
||||
@@ -111,6 +115,9 @@ export class AgentSideEffects extends Disposable {
|
||||
|
||||
private readonly _permissionManager: SessionPermissionManager;
|
||||
|
||||
/** Registry-driven dispatcher for host-handled `/rename` / `!command` etc. */
|
||||
private readonly _localCommands: AgentHostLocalCommands;
|
||||
|
||||
private readonly _subagentChats = new NKeyMap<ISubagentSessionRef, [ProtocolURI, string]>();
|
||||
|
||||
/**
|
||||
@@ -141,8 +148,17 @@ export class AgentSideEffects extends Disposable {
|
||||
super();
|
||||
this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService);
|
||||
this._turnTracker = new AgentHostTurnTracker(this._telemetryReporter);
|
||||
this._toolCallTracker = new AgentHostToolCallTracker(this._telemetryReporter);
|
||||
this._toolCallTracker = this._register(new AgentHostToolCallTracker(this._telemetryReporter));
|
||||
this._permissionManager = this._register(instantiationService.createInstance(SessionPermissionManager, this._stateManager));
|
||||
this._localCommands = this._register(instantiationService.createInstance(
|
||||
AgentHostLocalCommands,
|
||||
this._stateManager,
|
||||
this._options.localTurns,
|
||||
// Draining the queue re-enters agent lookup / telemetry / sendMessage,
|
||||
// which is this class's responsibility, so the dispatcher hands the
|
||||
// turn back here once it has completed a host-handled command.
|
||||
(turnChannel: ProtocolURI) => this._tryConsumeNextQueuedMessage(turnChannel),
|
||||
));
|
||||
this._titleController = this._register(instantiationService.createInstance(AgentHostSessionTitleController, this._stateManager, {
|
||||
sessionDataService: this._options.sessionDataService,
|
||||
getGitHubCopilotToken: this._options.getGitHubCopilotToken,
|
||||
@@ -334,10 +350,17 @@ export class AgentSideEffects extends Disposable {
|
||||
return;
|
||||
}
|
||||
this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededSet, request });
|
||||
if (request.kind !== SessionInputRequestKind.ChatInput) {
|
||||
const agent = this._options.getAgent(sessionUri);
|
||||
if (agent) {
|
||||
this._toolCallTracker.toolCallBlocked(agent.id, chatUri, request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _removeSessionInputNeeded(chatUri: ProtocolURI, id: string): void {
|
||||
const sessionUri = parseRequiredSessionUriFromChatUri(chatUri);
|
||||
this._toolCallTracker.toolCallUnblocked(chatUri, id);
|
||||
if (!this._stateManager.getSessionState(sessionUri)?.inputNeeded?.some(r => r.id === id)) {
|
||||
return;
|
||||
}
|
||||
@@ -348,7 +371,7 @@ export class AgentSideEffects extends Disposable {
|
||||
const sessionUri = parseRequiredSessionUriFromChatUri(chatUri);
|
||||
for (const request of this._stateManager.getSessionState(sessionUri)?.inputNeeded ?? []) {
|
||||
if (request.chat === chatUri) {
|
||||
this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededRemoved, id: request.id });
|
||||
this._removeSessionInputNeeded(chatUri, request.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,6 +417,9 @@ export class AgentSideEffects extends Disposable {
|
||||
this._publishSessionCustomizationsForAgent(agent);
|
||||
}));
|
||||
}
|
||||
if (agent.onDidRequireAuth) {
|
||||
disposables.add(agent.onDidRequireAuth(e => this._stateManager.emitAuthRequired(e)));
|
||||
}
|
||||
return disposables;
|
||||
}
|
||||
|
||||
@@ -938,12 +964,10 @@ export class AgentSideEffects extends Disposable {
|
||||
// Per-turn streaming part tracking is owned by the agent
|
||||
// (e.g. CopilotAgentSession) and reset on its `send()` call.
|
||||
|
||||
// `/rename [title]` is a generic, agent-agnostic slash command:
|
||||
// it is intercepted here and redirected to a title change rather
|
||||
// than forwarded to the agent SDK. Mirrors the per-agent text-side
|
||||
// dispatch (`parseLeadingSlashCommand` in CopilotAgentSession), but
|
||||
// applies to every session type.
|
||||
if (this._tryHandleRenameCommand(channel, action.turnId, action.message.text)) {
|
||||
// Generic, agent-agnostic host commands (`/rename`, `!command`,
|
||||
// …) are intercepted here and handled by the local-command
|
||||
// dispatcher rather than forwarded to the agent SDK.
|
||||
if (this._localCommands.tryHandle({ turnChannel: channel, turnId: action.turnId, text: action.message.text })) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1054,9 +1078,23 @@ export class AgentSideEffects extends Disposable {
|
||||
throw new Error(`ChatTruncated must be handled on an AHP chat channel: ${channel}`);
|
||||
}
|
||||
const agent = this._options.getAgent(sessionChannel);
|
||||
agent?.truncateSession?.(URI.parse(sessionChannel), action.turnId).catch(err => {
|
||||
// When the truncation boundary is a host-injected local turn
|
||||
// (`/rename` / `!command`), redirect the SDK truncation to the
|
||||
// preceding concrete turn so the agent keeps everything up to
|
||||
// the real message before it.
|
||||
const sdkTurnId = action.turnId !== undefined
|
||||
? this._options.localTurns.resolveConcreteTurnId(chatChannel, action.turnId)
|
||||
: action.turnId;
|
||||
// Route to the chat being truncated: the default chat (addressed
|
||||
// by the session) or a peer chat with its own backing.
|
||||
agent?.truncateSession?.(URI.parse(sessionChannel), sdkTurnId, URI.parse(chatChannel)).catch(err => {
|
||||
this._logService.error('[AgentSideEffects] truncateSession failed', err);
|
||||
});
|
||||
// Drop persisted local turns that no longer survive in the
|
||||
// (already-truncated) chat state.
|
||||
const survivingIds = new Set((this._stateManager.getChatState(chatChannel)?.turns ?? []).map(t => t.id));
|
||||
const removed = this._options.localTurns.getLocalTurnIds(chatChannel).filter(id => !survivingIds.has(id));
|
||||
this._options.localTurns.deleteLocals(sessionChannel, removed);
|
||||
this._changesets.onSessionTruncated(sessionChannel);
|
||||
break;
|
||||
}
|
||||
@@ -1139,85 +1177,13 @@ export class AgentSideEffects extends Disposable {
|
||||
this._titleController.generateForkedTitle(channel, chatChannel, turns, fallbackTitle, sourceTitle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the generic `/rename [title]` slash command. When `text` is a
|
||||
* rename command it is redirected to a {@link ActionType.SessionTitleChanged}
|
||||
* action (when a non-empty title is supplied) and the just-started turn is
|
||||
* immediately completed, so the command is never forwarded to the agent SDK.
|
||||
*
|
||||
* @returns `true` when the message was a rename command and was handled here
|
||||
* (the caller MUST NOT forward it to the agent), `false` otherwise.
|
||||
*/
|
||||
private _tryHandleRenameCommand(channel: ProtocolURI, turnId: string, text: string): boolean {
|
||||
const title = parseRenameCommand(text);
|
||||
if (title === undefined) {
|
||||
return false;
|
||||
}
|
||||
const isAdditional = (uri: ProtocolURI | undefined): uri is ProtocolURI =>
|
||||
!!uri && isAhpChatChannel(uri) && !isDefaultChatUri(uri);
|
||||
const chatTarget = isAdditional(channel) ? channel : undefined;
|
||||
const sessionChannel = chatTarget ? parseRequiredSessionUriFromChatUri(chatTarget) : (isAhpChatChannel(channel) ? parseRequiredSessionUriFromChatUri(channel) : channel);
|
||||
// The just-opened turn lives wherever the message was dispatched.
|
||||
const turnTarget = chatTarget ?? channel;
|
||||
if (title.length > 0) {
|
||||
if (chatTarget) {
|
||||
// Rename only this chat, independently of the session title.
|
||||
this._stateManager.updateChatTitle(sessionChannel, chatTarget, title);
|
||||
this._persistSessionFlag(sessionChannel, `customChatTitle:${chatTarget}`, title);
|
||||
} else {
|
||||
this._stateManager.dispatchServerAction(sessionChannel, {
|
||||
type: ActionType.SessionTitleChanged,
|
||||
title,
|
||||
});
|
||||
// Server-dispatched actions bypass `handleAction`, so persist the
|
||||
// new title here directly (the client-dispatched rename path relies
|
||||
// on the `SessionTitleChanged` case in `handleAction` instead).
|
||||
this._persistSessionFlag(sessionChannel, 'customTitle', title);
|
||||
}
|
||||
// Acknowledge the rename with a brief response so the turn has
|
||||
// visible content in the transcript.
|
||||
this._stateManager.dispatchServerAction(turnTarget, {
|
||||
type: ActionType.ChatResponsePart,
|
||||
turnId,
|
||||
part: {
|
||||
kind: ResponsePartKind.Markdown,
|
||||
id: generateUuid(),
|
||||
content: localize('agentHostRename.renamed', "Renamed: {0}", title),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Close out the turn that the reducer opened for this message so the
|
||||
// session returns to idle instead of waiting on an agent response.
|
||||
this._stateManager.dispatchServerAction(turnTarget, {
|
||||
type: ActionType.ChatTurnComplete,
|
||||
turnId,
|
||||
});
|
||||
// This turn was completed via a direct server dispatch rather than
|
||||
// `_runTurnCompleteSideEffects`, so drain any messages queued behind
|
||||
// the rename ourselves; otherwise they would stall until the next
|
||||
// unrelated state change re-triggers consumption.
|
||||
this._tryConsumeNextQueuedMessage(turnTarget);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a session metadata key/value pair to the session database.
|
||||
* Used for fields the host needs to remember across restarts (custom
|
||||
* title, isRead/isArchived flags, merged config values).
|
||||
*
|
||||
* Counterpart in `agentHostChangesetService.ts` (`AgentHostChangesetService._persistSessionFlag`):
|
||||
* keep both copies in sync if the signature changes. Duplicated rather
|
||||
* than lifted because the two consumers persist disjoint metadata
|
||||
* (changeset diffs there vs. customTitle / isRead / isArchived /
|
||||
* configValues here) and a shared util would only have two callers.
|
||||
*/
|
||||
private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void {
|
||||
const ref = this._options.sessionDataService.openDatabase(URI.parse(session));
|
||||
ref.object.setMetadata(key, value).catch(err => {
|
||||
this._logService.warn(`[AgentSideEffects] Failed to persist ${key}`, err);
|
||||
}).finally(() => {
|
||||
ref.dispose();
|
||||
});
|
||||
persistSessionMetadata(this._options.sessionDataService, this._logService, session, key, value);
|
||||
}
|
||||
|
||||
private _persistChatDraft(channel: ProtocolURI, draft: Message | undefined): void {
|
||||
@@ -1298,9 +1264,10 @@ export class AgentSideEffects extends Disposable {
|
||||
queuedMessageId: msg.id,
|
||||
});
|
||||
|
||||
// `/rename` is intercepted generically (see the ChatTurnStarted
|
||||
// handler) and must not reach the agent SDK even when queued.
|
||||
if (this._tryHandleRenameCommand(session, turnId, msg.message.text)) {
|
||||
// Generic host commands (`/rename`, `!command`, …) are intercepted by
|
||||
// the local-command dispatcher (see the ChatTurnStarted handler) and
|
||||
// must not reach the agent SDK even when queued.
|
||||
if (this._localCommands.tryHandle({ turnChannel: session, turnId, text: msg.message.text })) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import { createClaudeThinkingLevelSchema, isClaudeEffortLevel } from '../../comm
|
||||
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
|
||||
import { AgentProvider, AgentSession, AgentSignal, CLAUDE_AGENT_PROVIDER_ID, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IActiveClient, IAgent, IAgentChatDataChange, IAgentChats, IAgentCreateChatForkSource, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSpawnChatEvent, SubagentChatSignal } from '../../common/agentService.js';
|
||||
import { ensureWorkspacelessScratchDir } from '../workspacelessScratchDir.js';
|
||||
import { ActionType } from '../../common/state/sessionActions.js';
|
||||
import { ActionType, AuthRequiredReason, type AuthRequiredParams } from '../../common/state/sessionActions.js';
|
||||
import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
|
||||
import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js';
|
||||
import { PolicyState, ProtectedResourceMetadata, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
|
||||
@@ -219,6 +219,9 @@ export class ClaudeAgent extends Disposable implements IAgent {
|
||||
private readonly _onDidCustomizationsChange = this._register(new Emitter<void>());
|
||||
readonly onDidCustomizationsChange = this._onDidCustomizationsChange.event;
|
||||
|
||||
private readonly _onDidRequireAuth = this._register(new Emitter<Omit<AuthRequiredParams, 'channel'>>());
|
||||
readonly onDidRequireAuth = this._onDidRequireAuth.event;
|
||||
|
||||
private readonly _models = observableValue<readonly IAgentModelInfo[]>(this, []);
|
||||
readonly models: IObservable<readonly IAgentModelInfo[]> = this._models;
|
||||
|
||||
@@ -459,6 +462,18 @@ export class ClaudeAgent extends Disposable implements IAgent {
|
||||
if (next !== this._transportMode) {
|
||||
this._transportMode = next;
|
||||
void this._refreshModels();
|
||||
// Flipping into proxy makes GitHub Copilot auth newly required.
|
||||
// If no proxy handle was ever established, proactively ask the
|
||||
// client to authenticate rather than waiting for the next command
|
||||
// to fail with `AHP_AUTH_REQUIRED`. A handle persists across a
|
||||
// proxy→native→proxy round-trip (cleared only on dispose), so this
|
||||
// fires only when a credential is genuinely missing.
|
||||
if (next === 'proxy' && !this._proxyHandle) {
|
||||
this._onDidRequireAuth.fire({
|
||||
resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource,
|
||||
reason: AuthRequiredReason.Required,
|
||||
});
|
||||
}
|
||||
}
|
||||
}));
|
||||
if (this._transportMode === 'native') {
|
||||
@@ -539,7 +554,7 @@ export class ClaudeAgent extends Disposable implements IAgent {
|
||||
return true;
|
||||
}
|
||||
const tokenChanged = this._githubToken !== token;
|
||||
if (!tokenChanged) {
|
||||
if (!tokenChanged && this._proxyHandle) {
|
||||
this._logService.info('[Claude] Auth token unchanged');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -504,7 +504,7 @@ export function getClaudePastTenseMessage(
|
||||
case 'Agent':
|
||||
return localize('claude.toolComplete.task', "Ran subagent");
|
||||
default:
|
||||
return localize('claude.toolComplete.generic', "Used \"{0}\"", displayName);
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,9 +27,11 @@ import { IParsedAgent, IParsedPlugin, IParsedRule, IParsedSkill, parseAgentFile,
|
||||
import { IFileService } from '../../../files/common/files.js';
|
||||
import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
|
||||
import { ILogService, LogLevel } from '../../../log/common/log.js';
|
||||
import { ITelemetryService } from '../../../telemetry/common/telemetry.js';
|
||||
import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js';
|
||||
import { workspacelessScratchDir } from '../workspacelessScratchDir.js';
|
||||
import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js';
|
||||
import { IAgentHostReviewService } from '../../common/agentHostReviewService.js';
|
||||
import { createPricingMetaFromBilling, hasLongContextSurcharge, type ICAPIModelBilling } from '../../common/agentModelPricing.js';
|
||||
import { AgentHostConfigKey, agentHostCustomizationConfigSchema, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js';
|
||||
import { AgentHostMcpServersConfigKey, AgentHostSessionSyncEnabledConfigKey, AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostMcpServers } from '../../common/agentHostSchema.js';
|
||||
@@ -42,6 +44,7 @@ import type { IAgentServerToolHost } from '../../common/agentServerTools.js';
|
||||
import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js';
|
||||
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
|
||||
import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js';
|
||||
import { IAgentHostProxyResolver } from '../agentHostProxyResolver.js';
|
||||
import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
|
||||
import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
|
||||
import { ActionType, type SessionAction } from '../../common/state/sessionActions.js';
|
||||
@@ -58,12 +61,22 @@ import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitP
|
||||
import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js';
|
||||
import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js';
|
||||
import { ShellManager } from './copilotShellTools.js';
|
||||
import { isRestrictedTelemetryEnabled } from './copilotTokenFields.js';
|
||||
import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js';
|
||||
import { ICopilotApiService } from '../shared/copilotApiService.js';
|
||||
import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js';
|
||||
import { DiscoveredType, SessionCustomizationDiscovery, areDiscoveredDirectoriesEqual, type IDiscoveredDirectory } from './sessionCustomizationDiscovery.js';
|
||||
import { COPILOT_INTEGRATION_ID } from '../../../endpoint/common/licenseAgreement.js';
|
||||
|
||||
const RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS = 300;
|
||||
const COPILOT_CAPI_URL = 'https://api.githubcopilot.com';
|
||||
/**
|
||||
* Proxy env vars that indicate the environment already configures a proxy.
|
||||
*/
|
||||
const COPILOT_PROXY_ENV_KEYS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'] as const;
|
||||
/**
|
||||
* Proxy env vars we set when injecting the resolved CAPI proxy.
|
||||
*/
|
||||
const COPILOT_PROXY_SET_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY'] as const;
|
||||
|
||||
/**
|
||||
* Maps a VS Code {@link LogLevel} to the Copilot CLI runtime's `logLevel`
|
||||
@@ -378,6 +391,12 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
|
||||
private _client: CopilotClient | undefined;
|
||||
private _clientStarting: Promise<CopilotClient> | undefined;
|
||||
/**
|
||||
* Proxy URL injected into the running client's subprocess env (`undefined`
|
||||
* when none was injected). Used to detect when a token change alters the
|
||||
* token-discovered CAPI endpoint's proxy so we can restart the client.
|
||||
*/
|
||||
private _appliedProxy: string | undefined;
|
||||
private _githubToken: string | undefined;
|
||||
private _serverToolHost: IAgentServerToolHost | undefined;
|
||||
|
||||
@@ -463,8 +482,12 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
@ICopilotBranchNameGenerator private readonly _branchNameGenerator: ICopilotBranchNameGenerator,
|
||||
@IAgentHostCompletions completions: IAgentHostCompletions,
|
||||
@IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService,
|
||||
@IAgentHostReviewService private readonly _reviewService: IAgentHostReviewService,
|
||||
@INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,
|
||||
@IByokLmBridgeRegistry private readonly _byokBridgeRegistry: IByokLmBridgeRegistry,
|
||||
@ITelemetryService private readonly _telemetryService: ITelemetryService,
|
||||
@ICopilotApiService private readonly _copilotApiService: ICopilotApiService,
|
||||
@IAgentHostProxyResolver private readonly _proxyResolver: IAgentHostProxyResolver,
|
||||
) {
|
||||
super();
|
||||
this._plugins = this._register(this._instantiationService.createInstance(PluginController));
|
||||
@@ -632,6 +655,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
this._updateRestrictedTelemetry(token);
|
||||
this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`);
|
||||
if (tokenChanged) {
|
||||
await this._restartClientIfProxyChanged();
|
||||
void this._refreshModels();
|
||||
}
|
||||
return true;
|
||||
@@ -648,13 +672,48 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
return handled;
|
||||
}
|
||||
|
||||
private _updateRestrictedTelemetry(token: string | undefined): void {
|
||||
const rtEnabled = isRestrictedTelemetryEnabled(token);
|
||||
private _updateRestrictedTelemetry(githubToken: string | undefined): void {
|
||||
// Safe default synchronously: keep restricted/enhanced telemetry disabled until the minted
|
||||
// CAPI Copilot session token confirms the `rt=1` opt-in. The GitHub token here carries no
|
||||
// `rt`/`tid` claims — those live in the Copilot session token, which the API service mints —
|
||||
// so the real values are resolved asynchronously below. Mirrors how the Copilot extension
|
||||
// reads `rt`/`tid` off its `CopilotToken` rather than the GitHub token.
|
||||
this._applyRestrictedTelemetry(false, undefined, undefined);
|
||||
if (githubToken) {
|
||||
void this._resolveRestrictedTelemetry(githubToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async _resolveRestrictedTelemetry(githubToken: string): Promise<void> {
|
||||
try {
|
||||
const ctx = await this._copilotApiService.resolveRestrictedTelemetryContext(githubToken);
|
||||
if (this._githubToken !== githubToken) {
|
||||
return; // token changed while resolving; a newer call owns the state
|
||||
}
|
||||
this._applyRestrictedTelemetry(
|
||||
ctx.restrictedTelemetryEnabled,
|
||||
ctx.trackingId,
|
||||
ctx.telemetryEndpoint ? `${ctx.telemetryEndpoint}/telemetry` : undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
this._logService.debug(`[Copilot] Restricted telemetry resolution failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private _applyRestrictedTelemetry(rtEnabled: boolean, trackingId: string | undefined, telemetryEndpoint: string | undefined): void {
|
||||
if (rtEnabled !== this._restrictedTelemetryEnabled) {
|
||||
this._restrictedTelemetryEnabled = rtEnabled;
|
||||
this._logService.info(`[Copilot] Restricted telemetry ${rtEnabled ? 'enabled' : 'disabled'}`);
|
||||
this._logService.info(`[Copilot] Enhanced (restricted) telemetry ${rtEnabled ? 'enabled for this account' : 'disabled'}`);
|
||||
this._onDidChangeRestrictedTelemetry.fire();
|
||||
}
|
||||
// Push the token-derived telemetry policy/identity to the restricted sender: `rt` gates
|
||||
// enhanced GH telemetry (kept off for public users), `tid` becomes `copilot_trackingId`, and
|
||||
// the endpoint routes at the user's CAPI telemetry host (dotcom, GHE, or proxy).
|
||||
if (isAgentHostTelemetryService(this._telemetryService)) {
|
||||
this._telemetryService.setRestrictedTelemetryEnabled(rtEnabled);
|
||||
this._telemetryService.setCopilotTrackingId(trackingId);
|
||||
this._telemetryService.setRestrictedTelemetryEndpoint(telemetryEndpoint);
|
||||
}
|
||||
}
|
||||
|
||||
private async _refreshModels(attempt = 0): Promise<void> {
|
||||
@@ -839,6 +898,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
env['COPILOT_CLI_RUN_AS_NODE'] = '1';
|
||||
env['USE_BUILTIN_RIPGREP'] = 'false';
|
||||
env['COPILOT_MCP_APPS'] = 'true';
|
||||
await this._configureProxyEnv(env);
|
||||
|
||||
// On Linux the MXC bubblewrap sandbox backend does not forward a PTY into
|
||||
// the container, so the CLI's default PTY-backed interactive shell can
|
||||
@@ -1427,6 +1487,9 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
if (sourceDbRef) {
|
||||
try {
|
||||
await fs.mkdir(targetDbDir.fsPath, { recursive: true });
|
||||
// VACUUM INTO fails if the target already exists; clear
|
||||
// any stale DB left by a previous (e.g. crashed) attempt.
|
||||
await fs.rm(targetDbPath.fsPath, { force: true });
|
||||
await sourceDbRef.object.vacuumInto(targetDbPath.fsPath);
|
||||
} finally {
|
||||
sourceDbRef.dispose();
|
||||
@@ -1446,6 +1509,16 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
|
||||
const session = agentSession.sessionUri;
|
||||
this._logService.info(`[Copilot] Forked session created: ${session.toString()}`);
|
||||
|
||||
// Copy the source session's reviewed ref so the fork starts with
|
||||
// the parent's review progress (best-effort; a failure just means
|
||||
// the fork starts unreviewed).
|
||||
try {
|
||||
await this._reviewService.copyReviewedRef(sessionConfig.fork!.session.toString(), session.toString(), workingDirectory);
|
||||
} catch (err) {
|
||||
this._logService.warn(`[Copilot] Failed to copy reviewed ref for fork: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService);
|
||||
await this._storeSessionMetadata(session, sessionConfig.model, workingDirectory, workingDirectory, project, true);
|
||||
if (sessionConfig.agent !== undefined) {
|
||||
@@ -2269,6 +2342,9 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
if (sourceDbRef) {
|
||||
try {
|
||||
await fs.mkdir(targetDbDir.fsPath, { recursive: true });
|
||||
// VACUUM INTO fails if the target already exists; clear any
|
||||
// stale DB left by a previous (e.g. crashed) attempt.
|
||||
await fs.rm(targetDbPath.fsPath, { force: true });
|
||||
await sourceDbRef.object.vacuumInto(targetDbPath.fsPath);
|
||||
} finally {
|
||||
sourceDbRef.dispose();
|
||||
@@ -2456,16 +2532,25 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
});
|
||||
}
|
||||
|
||||
async truncateSession(session: URI, turnId?: string): Promise<void> {
|
||||
async truncateSession(session: URI, turnId: string | undefined, chat: URI): Promise<void> {
|
||||
const sessionId = AgentSession.id(session);
|
||||
if (this._provisionalSessions.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
const isPeerChat = !isDefaultChatUri(chat);
|
||||
await this._sessionSequencer.queue(sessionId, async () => {
|
||||
this._logService.info(`[Copilot:${sessionId}] Truncating session${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`);
|
||||
this._logService.info(`[Copilot:${sessionId}] Truncating ${isPeerChat ? `peer chat ${chat.toString()}` : 'session'}${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`);
|
||||
|
||||
// Ensure the session is loaded so we can use the SDK RPC
|
||||
const entry = this._findAnySession(sessionId) ?? await this._resumeSession(sessionId);
|
||||
// Resolve the entry whose history is being truncated: a peer chat has
|
||||
// its own backing SDK session, so route to it rather than the default
|
||||
// chat. `_resolveChatEntry` resumes/materializes the chat if needed.
|
||||
const entry = isPeerChat
|
||||
? await this._resolveChatEntry(session, chat)
|
||||
: (this._findAnySession(sessionId) ?? await this._resumeSession(sessionId));
|
||||
if (!entry) {
|
||||
this._logService.info(`[Copilot:${sessionId}] No chat entry resolved for truncation; nothing to truncate`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the SDK event ID for the truncation boundary.
|
||||
// The protocol semantics: turnId is the last turn to KEEP.
|
||||
@@ -2591,6 +2676,77 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
private async _configureProxyEnv(env: Record<string, string | undefined>): Promise<void> {
|
||||
const proxy = await this._resolveProxyForSdk(env);
|
||||
this._appliedProxy = proxy;
|
||||
if (proxy) {
|
||||
for (const key of COPILOT_PROXY_SET_ENV_KEYS) {
|
||||
env[key] = proxy;
|
||||
}
|
||||
this._logService.info('[Copilot] Resolved CAPI proxy and forwarded HTTP_PROXY/HTTPS_PROXY to Copilot SDK');
|
||||
}
|
||||
}
|
||||
|
||||
private async _resolveProxyForSdk(env: Record<string, string | undefined> = process.env): Promise<string | undefined> {
|
||||
if (COPILOT_PROXY_ENV_KEYS.some(key => env[key])) {
|
||||
this._logService.debug('[Copilot] Proxy env var already set; leaving Copilot SDK proxy configuration to the environment');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let capiUrl = env['VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE'] || COPILOT_CAPI_URL;
|
||||
if (this._githubToken) {
|
||||
try {
|
||||
const discovered = await this._copilotApiService.resolveApiEndpoint(this._githubToken);
|
||||
if (discovered) {
|
||||
capiUrl = discovered;
|
||||
}
|
||||
} catch (error) {
|
||||
this._logService.debug(`[Copilot] CAPI endpoint discovery for proxy resolution failed; using ${capiUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await this._proxyResolver.resolveProxy(capiUrl);
|
||||
} catch (error) {
|
||||
this._logService.warn(`[Copilot] Failed to resolve CAPI proxy for ${capiUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the GitHub token changes, the token-discovered CAPI endpoint (and so
|
||||
* the resolved proxy) can change. The proxy is baked into the SDK subprocess
|
||||
* env at client start, so if it would now differ we stop the running client
|
||||
* here; the next `_ensureClient` re-resolves it against the new token. No-op
|
||||
* when no client is running/starting or the proxy is unchanged.
|
||||
*/
|
||||
private async _restartClientIfProxyChanged(): Promise<void> {
|
||||
if (!this._client && !this._clientStarting) {
|
||||
return;
|
||||
}
|
||||
const oldProxy = this._appliedProxy;
|
||||
const newProxy = await this._resolveProxyForSdk();
|
||||
if (newProxy === oldProxy) {
|
||||
return;
|
||||
}
|
||||
// Let any in-flight start finish so we stop a live client rather than
|
||||
// racing it (the start would otherwise come up with the stale proxy).
|
||||
if (this._clientStarting) {
|
||||
try {
|
||||
await this._clientStarting;
|
||||
} catch {
|
||||
// Start failed; nothing running to restart.
|
||||
}
|
||||
}
|
||||
if (!this._client) {
|
||||
return;
|
||||
}
|
||||
this._logService.info(`[Copilot] CAPI proxy changed after token update (${oldProxy ?? '(none)'} -> ${newProxy ?? '(none)'}); restarting CopilotClient`);
|
||||
this._sessions.clearAndDisposeAll();
|
||||
this._mcpNotificationSubs.clearAndDisposeAll();
|
||||
await this._stopClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes every peer chat hosted on the owning session's entry and drops
|
||||
* their live backings from {@link _chatBackings}. The chat URI encodes its
|
||||
|
||||
@@ -24,6 +24,7 @@ import { INativeEnvironmentService } from '../../../environment/common/environme
|
||||
import { IFileService } from '../../../files/common/files.js';
|
||||
import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
|
||||
import { ILogService } from '../../../log/common/log.js';
|
||||
import { ITelemetryService } from '../../../telemetry/common/telemetry.js';
|
||||
import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js';
|
||||
import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js';
|
||||
import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js';
|
||||
@@ -43,13 +44,14 @@ import type { IExitPlanModeResponse } from './copilotAgent.js';
|
||||
import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
|
||||
import { clientToolNamesFromSnapshot, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js';
|
||||
import { ActiveClientToolSet } from '../activeClientState.js';
|
||||
import { AgentHostTelemetryReporter } from '../agentHostTelemetryReporter.js';
|
||||
import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js';
|
||||
import { buildCopilotSystemNotification } from './copilotSystemNotification.js';
|
||||
import { parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvider.js';
|
||||
import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js';
|
||||
import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js';
|
||||
import type { IAgentServerToolHost } from '../../common/agentServerTools.js';
|
||||
import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js';
|
||||
import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isAgentCoordinationTool, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js';
|
||||
import { FileEditTracker } from '../shared/fileEditTracker.js';
|
||||
import { stripProxyErrorMarker, tryBuildChatErrorMeta, tryBuildChatErrorMetaFromFields } from '../shared/forwardedChatError.js';
|
||||
import { McpCustomizationController, type ISdkMcpServer } from '../shared/mcpCustomizationController.js';
|
||||
@@ -587,6 +589,9 @@ export class CopilotAgentSession extends Disposable {
|
||||
return this._mcpCustomizations.runtimeStates;
|
||||
}
|
||||
|
||||
/** Stateless reporter used to emit restricted GH/MSFT telemetry for this session's model calls. */
|
||||
private readonly _telemetryReporter: AgentHostTelemetryReporter;
|
||||
|
||||
constructor(
|
||||
options: ICopilotAgentSessionOptions,
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
@@ -595,6 +600,7 @@ export class CopilotAgentSession extends Disposable {
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
@INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,
|
||||
@IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
|
||||
@ITelemetryService private readonly _telemetryService: ITelemetryService,
|
||||
) {
|
||||
super();
|
||||
this.sessionId = options.rawSessionId;
|
||||
@@ -608,6 +614,7 @@ export class CopilotAgentSession extends Disposable {
|
||||
this._customizationDirectory = options.customizationDirectory;
|
||||
this._serverToolHost = options.serverToolHost;
|
||||
this._platform = options.platform ?? process.platform;
|
||||
this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService);
|
||||
|
||||
this._appliedSnapshot = options.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} };
|
||||
this._clientToolNames = clientToolNamesFromSnapshot(this._appliedSnapshot);
|
||||
@@ -2466,7 +2473,7 @@ export class CopilotAgentSession extends Disposable {
|
||||
turnId: this._turnId,
|
||||
part: {
|
||||
kind: ResponsePartKind.SystemNotification,
|
||||
content: notification.content,
|
||||
content: notification.messageText,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -2532,6 +2539,14 @@ export class CopilotAgentSession extends Disposable {
|
||||
|
||||
this._register(wrapper.onMessage(e => {
|
||||
this._logService.info(`[Copilot:${sessionId}] Full message received: ${e.data.content.length} chars`);
|
||||
// Report the enhanced GH `request.options.tools` event for this model call — parity with
|
||||
// the Copilot extension, which emits it per LLM request. `assistant.message` is the
|
||||
// agent-host's per-model-call boundary; we correlate on its `x-copilot-service-request-id`.
|
||||
// Main agent only: `_appliedSnapshot.tools` is the session's tool set, which does not
|
||||
// describe a subagent's model call, so subagent messages (mapped or dropped) are skipped.
|
||||
if (!e.agentId) {
|
||||
this._telemetryReporter.assistantMessageReceived(this.sessionUri.toString(), e.data.serviceRequestId, this._appliedSnapshot.tools);
|
||||
}
|
||||
// The SDK fires a `message` event with the full assembled content after
|
||||
// streaming deltas. If deltas already created a markdown part for this
|
||||
// turn, the live state is up to date and we skip. Only emit a fresh
|
||||
@@ -2539,8 +2554,8 @@ export class CopilotAgentSession extends Disposable {
|
||||
// where the SDK delivered the full message at once).
|
||||
//
|
||||
// Other fields (toolRequests, reasoningText, encryptedContent) are
|
||||
// only used for history reconstruction and live tool calls fire
|
||||
// their own tool_start events, so we can safely drop them here.
|
||||
// only used for history reconstruction and live tool calls fire their
|
||||
// own tool_start events, so we can safely drop them here.
|
||||
if (!e.data.content) {
|
||||
return;
|
||||
}
|
||||
@@ -2651,6 +2666,7 @@ export class CopilotAgentSession extends Disposable {
|
||||
toolCallId: e.data.toolCallId,
|
||||
toolName: e.data.toolName,
|
||||
displayName,
|
||||
intention: getShellIntention(e.data.toolName, parameters),
|
||||
contributor,
|
||||
_meta: toToolCallMeta(meta),
|
||||
}, parentToolCallId);
|
||||
@@ -2689,11 +2705,8 @@ export class CopilotAgentSession extends Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
// For client tools, do NOT auto-ready — the tool handler will fire
|
||||
// a separate tool_ready signal once the deferred is in place (or
|
||||
// the permission flow fires it first). MCP tools have no such
|
||||
// handler and are auto-readied below alongside built-in tools.
|
||||
if (contributor?.kind === ToolCallContributorKind.Client) {
|
||||
const shouldWaitForClientToolReady = contributor?.kind === ToolCallContributorKind.Client && !isAgentCoordinationTool(e.data.toolName);
|
||||
if (shouldWaitForClientToolReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2845,6 +2858,10 @@ export class CopilotAgentSession extends Disposable {
|
||||
// clickable file link, matching the `view`-tool display style.
|
||||
this._register(wrapper.onSkillInvoked(e => {
|
||||
this._logService.info(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`);
|
||||
if (this._shouldDropUnmappedSubagentEvent(e, 'skill.invoked')) {
|
||||
return;
|
||||
}
|
||||
const parentToolCallId = this._parentToolCallIdForSubagentEvent(e);
|
||||
const synth = synthesizeSkillToolCall(e.data, e.id);
|
||||
this._emitAction({
|
||||
type: ActionType.ChatToolCallStart,
|
||||
@@ -2852,14 +2869,14 @@ export class CopilotAgentSession extends Disposable {
|
||||
toolCallId: synth.toolCallId,
|
||||
toolName: synth.toolName,
|
||||
displayName: synth.displayName,
|
||||
});
|
||||
}, parentToolCallId);
|
||||
this._emitAction({
|
||||
type: ActionType.ChatToolCallReady,
|
||||
turnId: this._turnId,
|
||||
toolCallId: synth.toolCallId,
|
||||
invocationMessage: synth.invocationMessage,
|
||||
confirmed: ToolCallConfirmationReason.NotNeeded,
|
||||
});
|
||||
}, parentToolCallId);
|
||||
this._emitAction({
|
||||
type: ActionType.ChatToolCallComplete,
|
||||
turnId: this._turnId,
|
||||
@@ -2868,7 +2885,7 @@ export class CopilotAgentSession extends Disposable {
|
||||
success: true,
|
||||
pastTenseMessage: synth.pastTenseMessage,
|
||||
},
|
||||
});
|
||||
}, parentToolCallId);
|
||||
}));
|
||||
|
||||
this._register(wrapper.onSubagentStarted(e => {
|
||||
|
||||
@@ -515,6 +515,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher {
|
||||
clientName: 'vscode',
|
||||
enableMcpApps: true,
|
||||
enableFileHooks: true,
|
||||
enableConfigDiscovery: true,
|
||||
onPermissionRequest: request => runtime.handlePermissionRequest(request),
|
||||
onUserInputRequest: (request, invocation) => runtime.handleUserInputRequest(request, invocation),
|
||||
onElicitationRequest: context => runtime.handleElicitationRequest(context),
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
import type { Tool, ToolResultObject } from '@github/copilot-sdk';
|
||||
import { generateUuid } from '../../../../base/common/uuid.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js';
|
||||
import * as platform from '../../../../base/common/platform.js';
|
||||
import { Disposable, DisposableStore, type IReference, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { Emitter, Event } from '../../../../base/common/event.js';
|
||||
import { IEnvironmentService } from '../../../environment/common/environment.js';
|
||||
@@ -22,22 +20,18 @@ import { isZsh } from '../agentHostShellUtils.js';
|
||||
import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js';
|
||||
import { createAgentHostSandboxEngine } from './agentHostSandboxEngine.js';
|
||||
import { IAgentConfigurationService } from '../agentConfigurationService.js';
|
||||
import { DEFAULT_SHELL_COMMAND_TIMEOUT_MS, executeShellCommand, isMultilineCommand, prefixForHistorySuppression, prepareOutputForModel, shellTypeForExecutable, type IShellCommandResult, type ShellType } from '../shared/shellCommandExecution.js';
|
||||
|
||||
// Re-exported for consumers (and tests) that historically imported these
|
||||
// shell helpers from this module. Their canonical home is the shared,
|
||||
// agent-agnostic shellCommandExecution module.
|
||||
export { isMultilineCommand, prefixForHistorySuppression, shellTypeForExecutable };
|
||||
export type { ShellType };
|
||||
|
||||
/**
|
||||
* Maximum scrollback content (in bytes) returned to the model in tool results.
|
||||
* Message returned to the model when a command switches to the terminal's
|
||||
* alternate buffer (typically an interactive full-screen UI).
|
||||
*/
|
||||
const MAX_OUTPUT_BYTES = 80_000;
|
||||
|
||||
/**
|
||||
* Default command timeout in milliseconds (120 seconds).
|
||||
*/
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* The sentinel prefix used to detect command completion in terminal output.
|
||||
* The full sentinel format is: `<<<COPILOT_SENTINEL_<uuid>_EXIT_<code>>>`.
|
||||
*/
|
||||
const SENTINEL_PREFIX = '<<<COPILOT_SENTINEL_';
|
||||
const ALT_BUFFER_MESSAGE = 'The command opened the alternate buffer and is still running in the terminal. It likely launched an interactive terminal UI. Use write_bash/write_powershell to interact with it, or shutdown the shell to stop it.';
|
||||
|
||||
/**
|
||||
@@ -50,48 +44,6 @@ interface IManagedShell {
|
||||
readonly executable: string;
|
||||
}
|
||||
|
||||
export type ShellType = 'bash' | 'powershell';
|
||||
|
||||
/**
|
||||
* Routes a resolved shell executable to one of the Copilot SDK's two
|
||||
* built-in shell tools (`bash` / `powershell`). Falls back to the platform
|
||||
* default for unknown shells.
|
||||
*/
|
||||
export function shellTypeForExecutable(shellPath: string): ShellType {
|
||||
// Strip path on either separator and the .exe suffix.
|
||||
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
|
||||
const base = shellPath.slice(lastSep + 1).toLowerCase().replace(/\.exe$/, '');
|
||||
switch (base) {
|
||||
// PowerShell
|
||||
case 'pwsh':
|
||||
case 'powershell':
|
||||
case 'pwsh-preview':
|
||||
return 'powershell';
|
||||
// POSIX shells
|
||||
case 'bash':
|
||||
case 'sh':
|
||||
case 'zsh':
|
||||
case 'fish':
|
||||
case 'csh':
|
||||
case 'ksh':
|
||||
case 'nu':
|
||||
case 'xonsh':
|
||||
// Git for Windows bash entry points
|
||||
case 'git-cmd':
|
||||
// WSL launchers — bash inside, but invoked via these stubs
|
||||
case 'wsl':
|
||||
case 'ubuntu':
|
||||
case 'ubuntu1804':
|
||||
case 'kali':
|
||||
case 'debian':
|
||||
case 'opensuse-42':
|
||||
case 'sles-12':
|
||||
return 'bash';
|
||||
default:
|
||||
return platform.isWindows ? 'powershell' : 'bash';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ShellManager
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -322,75 +274,6 @@ export class ShellManager extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sentinel helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeSentinelId(): string {
|
||||
return generateUuid().replace(/-/g, '');
|
||||
}
|
||||
|
||||
function buildSentinelCommand(sentinelId: string, shellType: ShellType): string {
|
||||
if (shellType === 'powershell') {
|
||||
return `Write-Output "${SENTINEL_PREFIX}${sentinelId}_EXIT_$LASTEXITCODE>>>"`;
|
||||
}
|
||||
return `echo "${SENTINEL_PREFIX}${sentinelId}_EXIT_$?>>>"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* For POSIX shells (bash/zsh) that honor `HISTCONTROL=ignorespace` /
|
||||
* `HIST_IGNORE_SPACE`, prepending a single space prevents the command from
|
||||
* being recorded in shell history. The shell integration scripts opt these
|
||||
* settings in via the `VSCODE_PREVENT_SHELL_HISTORY` env var (set when the
|
||||
* terminal is created with `preventShellHistory: true`). PowerShell
|
||||
* suppresses history through PSReadLine instead, so no prefix is needed.
|
||||
*/
|
||||
export function prefixForHistorySuppression(shellType: ShellType): string {
|
||||
return shellType === 'powershell' ? '' : ' ';
|
||||
}
|
||||
|
||||
export function isMultilineCommand(command: string): boolean {
|
||||
const normalized = command.replace(/\r\n|\r/g, '\n');
|
||||
return /(?<!\\)\n/.test(normalized);
|
||||
}
|
||||
|
||||
function shouldUseBracketedPasteMode(command: string): boolean {
|
||||
return platform.isMacintosh || isMultilineCommand(command);
|
||||
}
|
||||
|
||||
function parseSentinel(content: string, sentinelId: string): { found: boolean; exitCode: number; outputBeforeSentinel: string } {
|
||||
const marker = `${SENTINEL_PREFIX}${sentinelId}_EXIT_`;
|
||||
let markerIndex = content.lastIndexOf(marker);
|
||||
while (markerIndex !== -1) {
|
||||
const outputBeforeSentinel = content.substring(0, markerIndex);
|
||||
const afterMarker = content.substring(markerIndex + marker.length);
|
||||
const endIdx = afterMarker.indexOf('>>>');
|
||||
if (endIdx !== -1) {
|
||||
const exitCodeStr = afterMarker.substring(0, endIdx).trim();
|
||||
if (/^-?\d+$/.test(exitCodeStr)) {
|
||||
return {
|
||||
found: true,
|
||||
exitCode: parseInt(exitCodeStr, 10),
|
||||
outputBeforeSentinel,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Ignore echoed sentinel command text (for example `$?`) and continue
|
||||
// scanning for the latest complete numeric sentinel marker.
|
||||
markerIndex = content.lastIndexOf(marker, markerIndex - 1);
|
||||
}
|
||||
|
||||
return { found: false, exitCode: -1, outputBeforeSentinel: content };
|
||||
}
|
||||
|
||||
function prepareOutputForModel(rawOutput: string): string {
|
||||
let text = removeAnsiEscapeCodes(rawOutput).trim();
|
||||
if (text.length > MAX_OUTPUT_BYTES) {
|
||||
text = text.substring(text.length - MAX_OUTPUT_BYTES);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -412,25 +295,33 @@ function makeExecutionResult(toolResult: ToolResultObject, options?: { keepShell
|
||||
return { toolResult, keepShellBusy: options?.keepShellBusy };
|
||||
}
|
||||
|
||||
function makeBackgroundExecutionResult(text: string): IShellExecutionResult {
|
||||
return makeExecutionResult(makeSuccessResult(text), { keepShellBusy: true });
|
||||
}
|
||||
|
||||
function makeAltBufferExecutionResult(): IShellExecutionResult {
|
||||
return makeExecutionResult(makeFailureResult(ALT_BUFFER_MESSAGE, 'alternateBuffer'), { keepShellBusy: true });
|
||||
}
|
||||
|
||||
function registerAltBufferHandler(
|
||||
shell: IManagedShell,
|
||||
terminalManager: IAgentHostTerminalManager,
|
||||
logService: ILogService,
|
||||
disposables: DisposableStore,
|
||||
finish: (result: IShellExecutionResult) => void,
|
||||
): void {
|
||||
void terminalManager.createAltBufferPromise(shell.terminalUri, disposables).then(() => {
|
||||
logService.info('[ShellTool] Command entered alternate buffer');
|
||||
finish(makeAltBufferExecutionResult());
|
||||
});
|
||||
/**
|
||||
* Maps the neutral {@link IShellCommandResult} produced by the shared shell
|
||||
* executor to the Copilot SDK {@link ToolResultObject} shape expected by the
|
||||
* shell tools.
|
||||
*/
|
||||
function shellCommandResultToExecutionResult(result: IShellCommandResult, timeoutMs: number): IShellExecutionResult {
|
||||
switch (result.status) {
|
||||
case 'completed': {
|
||||
const exitCode = result.exitCode ?? 0;
|
||||
const text = `Exit code: ${exitCode}\n${result.output}`;
|
||||
return makeExecutionResult(exitCode === 0 ? makeSuccessResult(text) : makeFailureResult(text));
|
||||
}
|
||||
case 'shellExited':
|
||||
return makeExecutionResult(makeFailureResult(`Shell exited with code ${result.exitCode}\n${result.output}`));
|
||||
case 'timeout':
|
||||
return makeExecutionResult(makeFailureResult(
|
||||
`Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${result.output}`,
|
||||
'timeout',
|
||||
));
|
||||
case 'background':
|
||||
return makeExecutionResult(
|
||||
makeSuccessResult('The user chose to continue this command in the background. The terminal is still running.'),
|
||||
{ keepShellBusy: true },
|
||||
);
|
||||
case 'altBuffer':
|
||||
return makeExecutionResult(makeFailureResult(ALT_BUFFER_MESSAGE, 'alternateBuffer'), { keepShellBusy: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCommandInShell(
|
||||
@@ -440,9 +331,10 @@ async function executeCommandInShell(
|
||||
terminalManager: IAgentHostTerminalManager,
|
||||
logService: ILogService,
|
||||
): Promise<IShellExecutionResult> {
|
||||
const result = terminalManager.supportsCommandDetection(shell.terminalUri)
|
||||
? await executeCommandWithShellIntegration(shell, command, timeoutMs, terminalManager, logService)
|
||||
: await executeCommandWithSentinel(shell, command, timeoutMs, terminalManager, logService);
|
||||
const result = shellCommandResultToExecutionResult(
|
||||
await executeShellCommand(shell, command, timeoutMs, terminalManager, logService),
|
||||
timeoutMs,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
toolResult: {
|
||||
@@ -452,182 +344,6 @@ async function executeCommandInShell(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command using shell integration (OSC 633) for completion detection.
|
||||
* No sentinel echo is injected — the shell's own command-finished signal
|
||||
* provides the exit code and cleanly delineated output.
|
||||
*/
|
||||
async function executeCommandWithShellIntegration(
|
||||
shell: IManagedShell,
|
||||
command: string,
|
||||
timeoutMs: number,
|
||||
terminalManager: IAgentHostTerminalManager,
|
||||
logService: ILogService,
|
||||
): Promise<IShellExecutionResult> {
|
||||
const disposables = new DisposableStore();
|
||||
|
||||
const result = new Promise<IShellExecutionResult>(resolve => {
|
||||
let resolved = false;
|
||||
const finish = (result: IShellExecutionResult) => {
|
||||
if (resolved) {
|
||||
return;
|
||||
}
|
||||
resolved = true;
|
||||
disposables.dispose();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
disposables.add(terminalManager.onCommandFinished(shell.terminalUri, event => {
|
||||
const output = prepareOutputForModel(event.output);
|
||||
const exitCode = event.exitCode ?? 0;
|
||||
logService.info(`[ShellTool] Command completed (shell integration) with exit code ${exitCode}`);
|
||||
if (exitCode === 0) {
|
||||
finish(makeExecutionResult(makeSuccessResult(`Exit code: ${exitCode}\n${output}`)));
|
||||
} else {
|
||||
finish(makeExecutionResult(makeFailureResult(`Exit code: ${exitCode}\n${output}`)));
|
||||
}
|
||||
}));
|
||||
|
||||
registerAltBufferHandler(shell, terminalManager, logService, disposables, finish);
|
||||
|
||||
disposables.add(terminalManager.onExit(shell.terminalUri, (exitCode: number) => {
|
||||
logService.info(`[ShellTool] Shell exited unexpectedly with code ${exitCode}`);
|
||||
const fullContent = terminalManager.getContent(shell.terminalUri) ?? '';
|
||||
const output = prepareOutputForModel(fullContent);
|
||||
finish(makeExecutionResult(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`)));
|
||||
}));
|
||||
|
||||
disposables.add(terminalManager.onClaimChanged(shell.terminalUri, (claim) => {
|
||||
if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) {
|
||||
logService.info(`[ShellTool] Continuing in background (claim narrowed)`);
|
||||
finish(makeBackgroundExecutionResult('The user chose to continue this command in the background. The terminal is still running.'));
|
||||
}
|
||||
}));
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
logService.warn(`[ShellTool] Command timed out after ${timeoutMs}ms`);
|
||||
const fullContent = terminalManager.getContent(shell.terminalUri) ?? '';
|
||||
const output = prepareOutputForModel(fullContent);
|
||||
finish(makeExecutionResult(makeFailureResult(
|
||||
`Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`,
|
||||
'timeout',
|
||||
)));
|
||||
}, timeoutMs);
|
||||
disposables.add(toDisposable(() => clearTimeout(timer)));
|
||||
|
||||
});
|
||||
|
||||
try {
|
||||
await terminalManager.sendText(shell.terminalUri, `${prefixForHistorySuppression(shell.shellType)}${command}`, {
|
||||
shouldExecute: true,
|
||||
bracketedPasteMode: shouldUseBracketedPasteMode(command),
|
||||
});
|
||||
} catch (err) {
|
||||
disposables.dispose();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: execute a command using a sentinel echo to detect completion.
|
||||
* Used when shell integration is not available.
|
||||
*/
|
||||
async function executeCommandWithSentinel(
|
||||
shell: IManagedShell,
|
||||
command: string,
|
||||
timeoutMs: number,
|
||||
terminalManager: IAgentHostTerminalManager,
|
||||
logService: ILogService,
|
||||
): Promise<IShellExecutionResult> {
|
||||
const sentinelId = makeSentinelId();
|
||||
const sentinelCmd = buildSentinelCommand(sentinelId, shell.shellType);
|
||||
const disposables = new DisposableStore();
|
||||
|
||||
const contentBefore = terminalManager.getContent(shell.terminalUri) ?? '';
|
||||
const offsetBefore = contentBefore.length;
|
||||
|
||||
const result = new Promise<IShellExecutionResult>(resolve => {
|
||||
let resolved = false;
|
||||
const finish = (result: IShellExecutionResult) => {
|
||||
if (resolved) {
|
||||
return;
|
||||
}
|
||||
resolved = true;
|
||||
disposables.dispose();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const checkForSentinel = () => {
|
||||
const fullContent = terminalManager.getContent(shell.terminalUri) ?? '';
|
||||
// Clamp offset: the terminal manager trims content when it exceeds
|
||||
// 100k chars (slices to last 80k). If trimming happened after we
|
||||
// captured offsetBefore, scan from the start of the current buffer.
|
||||
const clampedOffset = Math.min(offsetBefore, fullContent.length);
|
||||
const newContent = fullContent.substring(clampedOffset);
|
||||
const parsed = parseSentinel(newContent, sentinelId);
|
||||
if (parsed.found) {
|
||||
const output = prepareOutputForModel(parsed.outputBeforeSentinel);
|
||||
logService.info(`[ShellTool] Command completed with exit code ${parsed.exitCode}`);
|
||||
if (parsed.exitCode === 0) {
|
||||
finish(makeExecutionResult(makeSuccessResult(`Exit code: ${parsed.exitCode}\n${output}`)));
|
||||
} else {
|
||||
finish(makeExecutionResult(makeFailureResult(`Exit code: ${parsed.exitCode}\n${output}`)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
disposables.add(terminalManager.onData(shell.terminalUri, () => {
|
||||
checkForSentinel();
|
||||
}));
|
||||
|
||||
registerAltBufferHandler(shell, terminalManager, logService, disposables, finish);
|
||||
|
||||
disposables.add(terminalManager.onExit(shell.terminalUri, (exitCode: number) => {
|
||||
logService.info(`[ShellTool] Shell exited unexpectedly with code ${exitCode}`);
|
||||
const fullContent = terminalManager.getContent(shell.terminalUri) ?? '';
|
||||
const newContent = fullContent.substring(offsetBefore);
|
||||
const output = prepareOutputForModel(newContent);
|
||||
finish(makeExecutionResult(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`)));
|
||||
}));
|
||||
|
||||
disposables.add(terminalManager.onClaimChanged(shell.terminalUri, (claim) => {
|
||||
if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) {
|
||||
logService.info(`[ShellTool] Continuing in background (claim narrowed)`);
|
||||
finish(makeBackgroundExecutionResult('The user chose to continue this command in the background. The terminal is still running.'));
|
||||
}
|
||||
}));
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
logService.warn(`[ShellTool] Command timed out after ${timeoutMs}ms`);
|
||||
const fullContent = terminalManager.getContent(shell.terminalUri) ?? '';
|
||||
const newContent = fullContent.substring(offsetBefore);
|
||||
const output = prepareOutputForModel(newContent);
|
||||
finish(makeExecutionResult(makeFailureResult(
|
||||
`Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`,
|
||||
'timeout',
|
||||
)));
|
||||
}, timeoutMs);
|
||||
disposables.add(toDisposable(() => clearTimeout(timer)));
|
||||
|
||||
checkForSentinel();
|
||||
});
|
||||
|
||||
try {
|
||||
await terminalManager.sendText(shell.terminalUri, `${prefixForHistorySuppression(shell.shellType)}${command}`, {
|
||||
shouldExecute: true,
|
||||
bracketedPasteMode: shouldUseBracketedPasteMode(command),
|
||||
});
|
||||
await terminalManager.sendText(shell.terminalUri, sentinelCmd, { shouldExecute: true });
|
||||
} catch (err) {
|
||||
disposables.dispose();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -705,7 +421,7 @@ export async function createShellTools(
|
||||
},
|
||||
overridesBuiltInTool: true,
|
||||
handler: async (args, invocation) => {
|
||||
const timeoutMs = args.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||
const timeoutMs = args.timeout ?? DEFAULT_SHELL_COMMAND_TIMEOUT_MS;
|
||||
const ref = await shellManager.getOrCreateShell(
|
||||
shellType,
|
||||
invocation.toolCallId,
|
||||
|
||||
@@ -8,8 +8,6 @@ import { softAssertNever } from '../../../../base/common/assert.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
|
||||
export interface ICopilotSystemNotification {
|
||||
/** Body shown inside an active turn; cleaned from SDK `system.notification.data.content`. */
|
||||
readonly content: string;
|
||||
/** Text for a new system-origin AHP turn; derived from SDK `data.kind` metadata, e.g. shell completion `description`. */
|
||||
readonly messageText: string;
|
||||
/** Whether the runtime notification wakes the agent loop when it arrives while idle. */
|
||||
@@ -30,7 +28,6 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste
|
||||
const description = kind.description;
|
||||
const shellId = kind.shellId;
|
||||
return {
|
||||
content,
|
||||
messageText: description
|
||||
? localize('agentHost.copilot.systemNotification.shellDescriptionCompleted', "`{0}` completed", description)
|
||||
: shellId
|
||||
@@ -41,7 +38,6 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste
|
||||
}
|
||||
case 'agent_completed':
|
||||
return {
|
||||
content,
|
||||
messageText: kind.status === 'failed'
|
||||
? localize('agentHost.copilot.systemNotification.agentFailed', "Background agent {0} failed", kind.agentId)
|
||||
: localize('agentHost.copilot.systemNotification.agentCompleted', "Background agent {0} completed", kind.agentId),
|
||||
@@ -49,19 +45,16 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste
|
||||
};
|
||||
case 'agent_idle':
|
||||
return {
|
||||
content,
|
||||
messageText: localize('agentHost.copilot.systemNotification.agentIdle', "Background agent {0} is complete", kind.agentId),
|
||||
startsTurn: true,
|
||||
};
|
||||
case 'new_inbox_message':
|
||||
return {
|
||||
content,
|
||||
messageText: localize('agentHost.copilot.systemNotification.newInboxMessage', "New inbox message from {0}", kind.senderName),
|
||||
startsTurn: false,
|
||||
};
|
||||
case 'instruction_discovered':
|
||||
return {
|
||||
content,
|
||||
messageText: localize('agentHost.copilot.systemNotification.instructionDiscovered', "Instruction discovered: {0}", kind.description ?? kind.sourcePath),
|
||||
startsTurn: false,
|
||||
};
|
||||
|
||||
@@ -193,6 +193,26 @@ interface ICopilotWebFetchToolArgs {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters shared by the agent-coordination tools (`read_agent`,
|
||||
* `write_agent`). The Copilot CLI identifies the target agent by its
|
||||
* human-readable `agent_id` (e.g. `math-helper`).
|
||||
*/
|
||||
interface ICopilotAgentToolArgs {
|
||||
agent_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a well-formed `agent_id` from untrusted tool parameters. Since these are
|
||||
* parsed from JSON they may not match the expected shape, so the id is returned
|
||||
* only when it is a non-empty string and is therefore safe to render as inline
|
||||
* markdown code.
|
||||
*/
|
||||
function getAgentId(parameters: Record<string, unknown> | undefined): string | undefined {
|
||||
const agentId = (parameters as ICopilotAgentToolArgs | undefined)?.agent_id;
|
||||
return typeof agentId === 'string' && agentId.length > 0 ? agentId : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for the `apply_patch` / `git_apply_patch` tools. The patch text
|
||||
* itself lives in `input` using the V4A diff format (file headers like
|
||||
@@ -380,6 +400,18 @@ export function isHiddenTool(toolName: string): boolean {
|
||||
return HIDDEN_TOOL_NAMES.has(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true for the auto-approved agent-coordination tools (list/read/write
|
||||
* agents). These are client-contributed tools that never go through the
|
||||
* permission flow, so the agent host auto-readies them at start to surface a
|
||||
* tailored invocation message instead of the generic fallback.
|
||||
*/
|
||||
export function isAgentCoordinationTool(toolName: string): boolean {
|
||||
return toolName === CopilotToolName.ListAgents
|
||||
|| toolName === CopilotToolName.ReadAgent
|
||||
|| toolName === CopilotToolName.WriteAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the tool is Copilot's internal Autopilot completion signal.
|
||||
*/
|
||||
@@ -441,6 +473,20 @@ export function isShellTool(toolName: string): boolean {
|
||||
return SHELL_TOOL_NAMES.has(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the intention for a shell tool call from its `description`
|
||||
* argument. The Copilot shell tools (`bash`/`powershell`) carry a short
|
||||
* human-readable description of what the command does, which matches the
|
||||
* model's intention summary. Non-shell tools have no such argument, so this
|
||||
* returns `undefined` for them.
|
||||
*/
|
||||
export function getShellIntention(toolName: string, parameters: Record<string, unknown> | undefined): string | undefined {
|
||||
if (isShellTool(toolName) && typeof parameters?.description === 'string' && parameters.description.length > 0) {
|
||||
return parameters.description;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Display helpers
|
||||
//
|
||||
@@ -644,8 +690,17 @@ export function getInvocationMessage(toolName: string, displayName: string, para
|
||||
}
|
||||
case CopilotToolName.ExitPlanMode:
|
||||
return localize('toolInvoke.exitPlanMode', "Presenting plan");
|
||||
case CopilotToolName.Task:
|
||||
return localize('toolInvoke.task', "Delegating task");
|
||||
// The agent-coordination tools (list/read/write agents) are fast, so
|
||||
// they use a single message for both the running and completed states:
|
||||
// the past-tense phrasing. See getPastTenseMessage.
|
||||
case CopilotToolName.ListAgents:
|
||||
case CopilotToolName.ReadAgent:
|
||||
case CopilotToolName.WriteAgent:
|
||||
return getPastTenseMessage(toolName, displayName, parameters, true);
|
||||
default:
|
||||
return localize('toolInvoke.generic', "Using \"{0}\"", displayName);
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,8 +814,26 @@ export function getPastTenseMessage(toolName: string, displayName: string, param
|
||||
}
|
||||
case CopilotToolName.ExitPlanMode:
|
||||
return localize('toolComplete.exitPlanMode', "Exited plan mode");
|
||||
case CopilotToolName.Task:
|
||||
return localize('toolComplete.task', "Delegated task");
|
||||
case CopilotToolName.ListAgents:
|
||||
return localize('toolComplete.listAgents', "Listed agents");
|
||||
case CopilotToolName.ReadAgent: {
|
||||
const agentId = getAgentId(parameters);
|
||||
if (agentId) {
|
||||
return md(localize('toolComplete.readAgent', "Read agent {0}", appendEscapedMarkdownInlineCode(agentId)));
|
||||
}
|
||||
return localize('toolComplete.readAgentGeneric', "Read agent");
|
||||
}
|
||||
case CopilotToolName.WriteAgent: {
|
||||
const agentId = getAgentId(parameters);
|
||||
if (agentId) {
|
||||
return md(localize('toolComplete.writeAgent', "Wrote to agent {0}", appendEscapedMarkdownInlineCode(agentId)));
|
||||
}
|
||||
return localize('toolComplete.writeAgentGeneric', "Wrote to agent");
|
||||
}
|
||||
default:
|
||||
return localize('toolComplete.generic', "Used \"{0}\"", displayName);
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@ import { toToolCallMeta } from '../../common/meta/agentToolCallMeta.js';
|
||||
import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js';
|
||||
import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js';
|
||||
import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type AgentSelection, type Message, type ModelSelection, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js';
|
||||
import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall } from './copilotToolDisplay.js';
|
||||
import { getInvocationMessage, getPastTenseMessage, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall } from './copilotToolDisplay.js';
|
||||
import { buildSessionDbUri } from '../shared/fileEditTracker.js';
|
||||
import { getMediaMime } from '../../../../base/common/mime.js';
|
||||
import { buildCopilotSystemNotification } from './copilotSystemNotification.js';
|
||||
|
||||
function tryStringify(value: unknown): string | undefined {
|
||||
try {
|
||||
@@ -71,6 +72,8 @@ interface IToolStartInfo {
|
||||
readonly toolInput?: string;
|
||||
readonly toolKind?: 'terminal' | 'subagent' | 'search';
|
||||
readonly language?: string;
|
||||
/** Intention (why the command runs) for shell tools, from their `description` argument. */
|
||||
readonly intention?: string;
|
||||
readonly subagentAgentName?: string;
|
||||
readonly subagentDescription?: string;
|
||||
readonly parameters: Record<string, unknown> | undefined;
|
||||
@@ -103,10 +106,10 @@ export interface IMapSessionEventsOptions {
|
||||
readonly agent?: AgentSelection;
|
||||
}
|
||||
|
||||
function newTurnBuilder(id: string, text: string, options?: { attachments?: MessageAttachment[]; model?: ModelSelection; agent?: AgentSelection }): ITurnBuilder {
|
||||
function newTurnBuilder(id: string, text: string, options?: { attachments?: MessageAttachment[]; model?: ModelSelection; agent?: AgentSelection; origin?: MessageKind }): ITurnBuilder {
|
||||
const message: Message = {
|
||||
text,
|
||||
origin: { kind: MessageKind.User },
|
||||
origin: { kind: options?.origin ?? MessageKind.User },
|
||||
...(options?.attachments?.length ? { attachments: options.attachments } : {}),
|
||||
...(options?.model ? { model: options.model } : {}),
|
||||
...(options?.agent ? { agent: options.agent } : {}),
|
||||
@@ -138,6 +141,7 @@ function makeToolStartInfo(toolName: string, rawArguments: unknown, parentToolCa
|
||||
toolInput: getToolInputString(toolName, parameters, toolArgs),
|
||||
toolKind,
|
||||
language: toolKind === 'terminal' ? getShellLanguage(toolName) : undefined,
|
||||
intention: getShellIntention(toolName, parameters),
|
||||
subagentAgentName: subagentMeta?.agentName,
|
||||
subagentDescription: subagentMeta?.description,
|
||||
parameters,
|
||||
@@ -258,6 +262,7 @@ export async function mapSessionEvents(
|
||||
let parentBuilder: ITurnBuilder | undefined;
|
||||
let parentTurnState = TurnState.Cancelled;
|
||||
let parentTurnAborted = false;
|
||||
let rootAssistantTurnActive = false;
|
||||
|
||||
const flushParent = (): void => {
|
||||
if (!parentBuilder) {
|
||||
@@ -307,6 +312,16 @@ export async function mapSessionEvents(
|
||||
|
||||
for (const e of events) {
|
||||
switch (e.type) {
|
||||
case 'assistant.turn_start':
|
||||
if (!e.agentId) {
|
||||
rootAssistantTurnActive = true;
|
||||
}
|
||||
break;
|
||||
case 'assistant.turn_end':
|
||||
if (!e.agentId) {
|
||||
rootAssistantTurnActive = false;
|
||||
}
|
||||
break;
|
||||
case 'session.model_change': {
|
||||
currentModel = { id: e.data.newModel };
|
||||
break;
|
||||
@@ -399,6 +414,22 @@ export async function mapSessionEvents(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'system.notification': {
|
||||
const notification = buildCopilotSystemNotification(e);
|
||||
if (!notification) {
|
||||
break;
|
||||
}
|
||||
if (rootAssistantTurnActive && parentBuilder) {
|
||||
parentBuilder.responseParts.push({
|
||||
kind: ResponsePartKind.SystemNotification,
|
||||
content: notification.messageText,
|
||||
});
|
||||
} else if (notification.startsTurn) {
|
||||
flushParent();
|
||||
parentBuilder = newTurnBuilder(e.id, notification.messageText, { origin: MessageKind.SystemNotification });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'subagent.started': {
|
||||
const d = e.data;
|
||||
subagentInfoByToolCallId.set(d.toolCallId, {
|
||||
@@ -483,9 +514,12 @@ export async function mapSessionEvents(
|
||||
const parentToolCallId = resolveParentToolCallId(e.agentId, undefined);
|
||||
if (parentToolCallId) {
|
||||
subagentTurnStates.set(parentToolCallId, TurnState.Cancelled);
|
||||
} else if (parentBuilder) {
|
||||
parentTurnState = TurnState.Cancelled;
|
||||
parentTurnAborted = true;
|
||||
} else {
|
||||
rootAssistantTurnActive = false;
|
||||
if (parentBuilder) {
|
||||
parentTurnState = TurnState.Cancelled;
|
||||
parentTurnAborted = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -680,6 +714,7 @@ function makeCompletedToolCallPart(
|
||||
toolCallId: d.toolCallId,
|
||||
toolName: info.toolName,
|
||||
displayName: info.displayName,
|
||||
intention: info.intention,
|
||||
invocationMessage: info.invocationMessage,
|
||||
toolInput: info.toolInput,
|
||||
success: d.success,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user