diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index 1fa4a3778b0..69bdc2b009b 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -47,6 +47,7 @@ Then read the relevant spec for the area you are changing (see table below). If - **Line-number decoration tooltips belong in Monaco decoration options**: A `lineNumberClassName` node is regenerated as the editor renders and scrolls, so DOM-managed hovers can silently attach to a stale or never-decorated element. Set the localized `lineNumberHoverMessage` with the same decoration instead; Monaco's glyph hover controller follows the rendered line-number lifecycle. - **Compact multi-diff control alignment**: The file-header twistie, unchanged-region expand control, and fold control form one visual column in the Agents editor. Remove the header content's left padding and use the same small inset for both unchanged-region controls; do not let the shared multi-diff defaults leave each control at a separate horizontal offset. - **Embedded multi-diff gutters need a shared minimum width**: Each embedded editor otherwise sizes line numbers from its own largest line number, causing the content and nearby feedback glyph to appear to drift between file entries. Set a common `lineNumbersMinChars` width for the compact multi-diff; it remains stable through three-digit line numbers and grows only when a file exceeds that reserved capacity. +- **Editor-content overlays must anchor to the inset pane, not the full editor group**: In single-pane mode the editor group spans both the editor and docked detail panel, while `EditorGroupView.editorPaneContainer` bounds only the editor content. Mount submit/navigation overlays to that pane container so their bottom-right position stays inside the diff when the detail panel is visible or resized. - **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. - **Sticky prompt navigation must match the rail/title reveal**: Previous/Next and the sticky title must all reveal the prompt (request) row aligned to the top via the shared `reveal(requestId)` — the same path the dock/ruler rail uses. Do not align the following response to keep the header pinned, and do not add a "navigation pin" that forces the header to stay visible after a jump: the header is a `top:0` overlay, so it would cover the freshly top-aligned prompt (the prompt shows only a sliver). Let the header follow scroll tracking (it hides once the prompt is at the top), consistent with the dock. Use the chat request-bubble hover background (`--vscode-chat-requestBubbleHoverBackground`, toolbar hover as fallback), composited over the opaque panel base, for the sticky title affordance rather than an underline. - **Sticky prompt header transition is a label *roll*, not a moving band**: on prompt change the label text rolls (WAAPI slide+fade of absolutely-positioned line elements inside an `overflow:hidden` clip viewport), while the opaque band stays fixed. Do NOT translate the whole band to get an Explorer-style push-off: the band would move above the transcript top (its container `.interactive-session` is `overflow:visible`, so it'd overlap the session header) and clipping it would cut the band's soft drop-shadow. The roll gives the "header gives way to the next" feel with none of that risk. Gate the roll on the header already being visible (snap on first appearance/jumps) and honor `prefers-reduced-motion`. @@ -96,6 +97,7 @@ Then read the relevant spec for the area you are changing (see table below). If - **A reused new-session composer must re-seed its workspace draft when it swaps out of quick-chat mode**: the session-type picker hides itself when it has no folder types (`sessionTypePicker` `_folderSessionTypes.length === 0`), which is the case whenever the composer has **no active session** (`refresh(undefined)` clears the types). A *freshly opened* new-session composer avoids this by seeding a workspace draft from the restored folder in its constructor — but the same `NewChatWidget` instance is **reused** across the quick-chat→new-session transition (`sessionView.ts` keeps `kind==='newSession'`), and Cmd+N's `openNewSession` discard branch only `_activate(undefined)`, leaving the reused composer session-less → picker hidden. Fix by re-running the constructor's seed (`_seedWorkspaceDraft()`) from an autorun when `_isQuickChatComposer` flips **true→false with no active session**, so the reused composer matches a fresh one (folder + visible picker). Don't assume the constructor-time restore covers a reused composer. - **Every untitled-session-title fallback must be quick-chat aware**: an untitled session's title observable is `''`, so a hardcoded `localize(…, "New Session")` fallback shows "New Session" even for a quick chat (whose composer says "New Chat"). Route **all** such fallbacks through the shared `getUntitledSessionTitle(isQuickChat)` helper (`services/sessions/common/session.ts`, boolean param so each caller controls reader-tracked `.read(reader)` vs `.get()`). There are ≥5 sites — titlebar (`sessionsTitleBarWidget`), session header (×2: title + rename placeholder), list-row hover (`sessionHoverContent`), sessions picker (`sessionsActions`) — keep them on the helper; never hardcode "New Session". (The Cmd+N *action* title stays "New Session" — that action creates a session, unrelated to a session's own title.) - **`NeedsInput` is still an active turn for live turn UI**: agent-host tool and input confirmations intentionally transition a running chat from `InProgress` to `NeedsInput` without ending `activeTurn`. Live status surfaces such as the chat input pills must use `isActiveSessionStatus` so they do not disappear until the next output returns the chat to `InProgress`. +- **Chat file pills must not hardcode an editor**: open their resource through the shared `chat.editorAssociations` resolution path so both completed-response pills and the session input toolbar respect the configured editor. Do not invoke `markdown.showPreview` directly. - **Agent-host-only exclusions for built-in client tools belong in `ClientToolSetsContribution`, not the global tool registration**: `AgentHostActiveClientService.getClientTools` advertises enabled members of every non-deprecated tool set, including extension-contributed sets. Omit an unsupported built-in tool from the client tool sets so normal Copilot chat can continue using it; do not treat this contribution as the sole Agent Host allowlist. - **Non-interactive MCP authentication probes must not create dynamic authentication providers**: Provider creation can prompt for manual client registration when dynamic registration is unsupported. With `allowInteraction: false`, only inspect existing providers and sessions; defer metadata discovery and provider creation until the user invokes the `mcpAuthenticationRequired` action. - **Use structured maps for the state that is actually multi-keyed, not for an incidental cache**: If MCP tracking is addressed by session + server, model that source of truth directly with `NKeyMap`. Do not add a separate `NKeyMap` that merely caches serialized storage keys while leaving the real tracking state in nested or synchronized maps. @@ -109,6 +111,10 @@ Whenever the user flags a wrong pattern, rejects an approach, or gives design/ru - **Definitive session deletion and temporary list eviction are different operations**: deletion clears durable provenance and pending state; filtering a still-existing session only removes its visible list entry. Keep the list-removal helper side-effect-free, and let each caller explicitly update its mutation generation instead of passing an "already incremented" boolean. +- **Durable user intent must never be discarded on `onDidChangeSessions.removed`**: pins, manual sort keys, and group membership (`SessionsListModelService`, `SessionGroupsService`) are cleared only on `ISessionsManagementService.onDidDeleteSession` (or archive), never on the provider's `removed` delta. `removed` is an *eviction*, not a deletion: `BaseAgentHostSessionsProvider._refreshSessions` reconciles against one listing that the host aggregates across all its agents, and an agent that cannot answer yet returns `[]` instead of failing (`CodexAgent.listSessions` returns `[]` for a missing `_githubToken`, a not-yet-downloaded SDK, or a failed `thread/list`; `ClaudeAgent.listSessions` does the same). Persisting the removal turned a ~300 ms startup race into permanent loss of the user's pins and groups. Runtime-only consumers of `removed` (terminals, grid slots, layout) are fine as-is — only *persisted* state needs the delete event. + +- **`_refreshSessions` must not evict a cached session whose agent contributed no rows**: a listing with zero rows for an agent means "unknown", not "empty", so scope eviction to `listedAgentProviders` (the set of `AgentSession.provider(...)` schemes actually present in the response) and compare against `adapter.agentProvider`. Real deletions still arrive through `deleteSessions` and the `sessionRemoved` notification; the only cost is that an agent's *last* session, deleted elsewhere, lingers until it lists something again. + - **Keep session-list refresh filtering linear**: when retention pruning needs the complete backend key set, collect those keys while filtering entries in the original loop, then reconcile last-seen/pruning afterward. Do not introduce a candidate-map/filter/map pipeline when one loop plus one reconciliation call expresses the lifecycle more clearly. - **Centralize session workspace filtering behind a semantic predicate**: refresh, add-notification, and summary-update paths should call one `_isSessionInWorkspace(entry)`-style helper. Keep key construction, working-directory parsing, pending-local lookup, and provenance checks out of each caller so the high-level list flow stays readable and all paths apply identical rules. diff --git a/build/azure-pipelines/darwin/product-smoke-flaky-darwin.yml b/build/azure-pipelines/darwin/product-smoke-flaky-darwin.yml index 8b4ca271357..75ae75d044f 100644 --- a/build/azure-pipelines/darwin/product-smoke-flaky-darwin.yml +++ b/build/azure-pipelines/darwin/product-smoke-flaky-darwin.yml @@ -57,12 +57,22 @@ jobs: # Run the Electron smoke test once per iteration. continueOnError lets every # iteration run even if some fail, and gives each run its own timeline record # so the SmokeFlaky function can tally passes vs. failures. + # + # The smoke runner wipes .build/logs/smoke-tests-electron on startup, so a + # failing iteration's diagnostics would be destroyed by the next one and the + # published artifact would only ever hold the last iteration. Set the failed + # run aside under a name the runner does not touch. - ${{ each i in parameters.iterations }}: - script: | set -e APP_ROOT=$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) APP_NAME="`ls $APP_ROOT | head -n 1`" - npm run smoketest-no-compile -- --tracing --build "$APP_ROOT/$APP_NAME" + status=0 + npm run smoketest-no-compile -- --tracing --build "$APP_ROOT/$APP_NAME" || status=$? + if [ $status -ne 0 ] && [ -d .build/logs/smoke-tests-electron ]; then + mv .build/logs/smoke-tests-electron ".build/logs/failed-iteration-${{ i }}" + fi + exit $status displayName: "🧪 Smoke test iteration ${{ i }}/${{ length(parameters.iterations) }} (Electron)" continueOnError: true timeoutInMinutes: 20 diff --git a/build/azure-pipelines/linux/product-smoke-flaky-linux.yml b/build/azure-pipelines/linux/product-smoke-flaky-linux.yml index dcb8e804e08..708696264c1 100644 --- a/build/azure-pipelines/linux/product-smoke-flaky-linux.yml +++ b/build/azure-pipelines/linux/product-smoke-flaky-linux.yml @@ -100,10 +100,20 @@ jobs: # Run the Electron smoke test once per iteration. continueOnError lets every # iteration run even if some fail, and gives each run its own timeline record # so the SmokeFlaky function can tally passes vs. failures. + # + # The smoke runner wipes .build/logs/smoke-tests-electron on startup, so a + # failing iteration's diagnostics would be destroyed by the next one and the + # published artifact would only ever hold the last iteration. Set the failed + # run aside under a name the runner does not touch. - ${{ each i in parameters.iterations }}: - script: | set -e - npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)" + status=0 + npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)" || status=$? + if [ $status -ne 0 ] && [ -d .build/logs/smoke-tests-electron ]; then + mv .build/logs/smoke-tests-electron ".build/logs/failed-iteration-${{ i }}" + fi + exit $status env: TMPDIR: $(Agent.TempDirectory) LD_PRELOAD: $(VSCODE_SMOKE_LD_PRELOAD) diff --git a/build/azure-pipelines/product-copilot-recovery.yml b/build/azure-pipelines/product-copilot-recovery.yml index 6f7888e934c..17150e6fd23 100644 --- a/build/azure-pipelines/product-copilot-recovery.yml +++ b/build/azure-pipelines/product-copilot-recovery.yml @@ -97,6 +97,7 @@ extends: testSteps: - checkout: self + path: s lfs: true retryCountOnTaskFailure: 3 - template: copilot/setup-steps.yml diff --git a/build/azure-pipelines/win32/product-smoke-flaky-win32.yml b/build/azure-pipelines/win32/product-smoke-flaky-win32.yml index 87e8d519f89..22eec9876bb 100644 --- a/build/azure-pipelines/win32/product-smoke-flaky-win32.yml +++ b/build/azure-pipelines/win32/product-smoke-flaky-win32.yml @@ -60,8 +60,19 @@ jobs: # Run the Electron smoke test once per iteration. continueOnError lets every # iteration run even if some fail, and gives each run its own timeline record # so the SmokeFlaky function can tally passes vs. failures. + # + # The smoke runner wipes .build\logs\smoke-tests-electron on startup, so a + # failing iteration's diagnostics would be destroyed by the next one and the + # published artifact would only ever hold the last iteration. Set the failed + # run aside under a name the runner does not touch. - ${{ each i in parameters.iterations }}: - - powershell: npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)\VSCode-win32-$(VSCODE_ARCH)" + - powershell: | + npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)\VSCode-win32-$(VSCODE_ARCH)" + $status = $LASTEXITCODE + if ($status -ne 0 -and (Test-Path .build\logs\smoke-tests-electron)) { + Move-Item .build\logs\smoke-tests-electron ".build\logs\failed-iteration-${{ i }}" + } + exit $status displayName: "🧪 Smoke test iteration ${{ i }}/${{ length(parameters.iterations) }} (Electron)" continueOnError: true timeoutInMinutes: 20 diff --git a/build/lib/copilot.ts b/build/lib/copilot.ts index 9ec67047ef0..c24662a0f08 100644 --- a/build/lib/copilot.ts +++ b/build/lib/copilot.ts @@ -4,8 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; -import { ensureNpmPackage, type EnsureNpmPackageOptions } from './npmPackage.ts'; +import { ensureNpmPackage, materializeNpmPackageVersion, type EnsureNpmPackageOptions } from './npmPackage.ts'; + +/** + * Options for {@link prepareBuiltInCopilotRipgrepShim}. Extends the npm packing + * options with an override for the extension lockfile used to verify natives + * fetched for the pinned version (defaults to the repo's copy; overridable in + * tests). + */ +export interface PrepareBuiltInCopilotOptions extends EnsureNpmPackageOptions { + extensionLockfilePath?: string; +} /** * The platforms that @github/copilot ships platform-specific packages for. @@ -105,6 +116,10 @@ function getCopilotOptionalNativePayloadFiles(platform: string): string[] { 'prebuilds/*/Copilot Computer Use.app/**', 'prebuilds/*/CopilotComputerUse.exe', 'prebuilds/*/keytar.node', + // macOS voice media-pause helper (MediaRemote adapter). Optional and + // nested under prebuilds; keep it out of the product so universal + // merge does not need to special-case the framework binary tree. + 'prebuilds/*/mediaremote-adapter/**', ]; if (platform !== 'win32') { @@ -232,7 +247,7 @@ export function ensureCopilotPlatformPackage(platform: string, arch: string, nod * Failures throw to fail the build because built-in packaging must guarantee * this artifact is present. */ -export function prepareBuiltInCopilotRipgrepShim(platform: string, arch: string, builtInCopilotExtensionDir: string, appNodeModulesDir: string): void { +export function prepareBuiltInCopilotRipgrepShim(platform: string, arch: string, builtInCopilotExtensionDir: string, appNodeModulesDir: string, options: PrepareBuiltInCopilotOptions = {}): void { const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch); const platformArch = `${nodePlatform}-${nodeArch}`; const copilotPackagePlatformArch = toCopilotPackagePlatformArch(platform, arch); @@ -244,7 +259,7 @@ export function prepareBuiltInCopilotRipgrepShim(platform: string, arch: string, if (!fs.existsSync(copilotSdkBase)) { throw new Error(`[prepareBuiltInCopilotRipgrepShim] Copilot SDK directory not found at ${copilotSdkBase}`); } - materializeBuiltInCopilotSdkPlatformFiles(copilotPackagePlatformArch, tgrepPlatformArch, copilotBase, appNodeModulesDir); + materializeBuiltInCopilotSdkPlatformFiles(copilotPackagePlatformArch, tgrepPlatformArch, copilotBase, appNodeModulesDir, options); pruneNonTargetCopilotSdkPrebuilds(copilotPackagePlatformArch, path.join(copilotSdkBase, 'prebuilds'), copilotPlatforms); pruneNonTargetCopilotSdkPrebuilds(tgrepPlatformArch, path.join(copilotSdkBase, path.join('tgrep', 'bin')), copilotTgrepPlatforms); pruneNonTargetCopilotSdkPrebuilds(tgrepPlatformArch, path.join(copilotBase, path.join('tgrep', 'bin')), copilotTgrepPlatforms); @@ -277,37 +292,131 @@ export function prepareBuiltInCopilotRipgrepShim(platform: string, arch: string, } } -function materializeBuiltInCopilotSdkPlatformFiles(copilotPackagePlatformArch: string, tgrepPlatformArch: string, copilotBase: string, appNodeModulesDir: string): void { +function materializeBuiltInCopilotSdkPlatformFiles(copilotPackagePlatformArch: string, tgrepPlatformArch: string, copilotBase: string, appNodeModulesDir: string, options: PrepareBuiltInCopilotOptions = {}): void { if (!copilotPlatforms.includes(copilotPackagePlatformArch)) { return; } - const platformPackageDir = path.join(appNodeModulesDir, '@github', `copilot-${copilotPackagePlatformArch}`); - if (!fs.existsSync(platformPackageDir)) { - throw new Error(`[prepareBuiltInCopilotRipgrepShim] Copilot platform package not found at ${platformPackageDir}`); + // The SDK JavaScript shipped inside the built-in extension and the native + // `runtime.node` it loads MUST be the same @github/copilot version: the JS + // calls native functions the binary may not export (e.g. a newer CLI that + // removed one), which throws at load. Source the native from a platform + // package matching the EXTENSION's version rather than whatever app-root + // currently has — the extension is intentionally pinned to a fixed CLI + // version for the extension host while the agent host (app-root) keeps + // updating, so the two versions diverge by design. + const extVersion = readCopilotPackageVersion(copilotBase); + const { dir: platformPackageDir, cleanup } = resolveVersionMatchedCopilotPlatformPackage(copilotPackagePlatformArch, extVersion, appNodeModulesDir, options); + try { + const sdkPrebuildsTarget = path.join(copilotBase, 'sdk', 'prebuilds', copilotPackagePlatformArch); + copyRequiredDirectory( + path.join(platformPackageDir, 'prebuilds', copilotPackagePlatformArch), + sdkPrebuildsTarget, + `Copilot SDK native prebuilds for ${copilotPackagePlatformArch}` + ); + // Built-in materialization copies the whole prebuilds tree (not the gulp + // exclude globs above), so drop mediaremote-adapter explicitly afterward. + fs.rmSync(path.join(sdkPrebuildsTarget, 'mediaremote-adapter'), { recursive: true, force: true }); + + if (!copilotTgrepPlatforms.includes(tgrepPlatformArch)) { + return; + } + + const tgrepSource = path.join(platformPackageDir, 'tgrep', 'bin', tgrepPlatformArch); + copyRequiredDirectory( + tgrepSource, + path.join(copilotBase, 'tgrep', 'bin', tgrepPlatformArch), + `Copilot tgrep for ${tgrepPlatformArch}` + ); + copyRequiredDirectory( + tgrepSource, + path.join(copilotBase, 'sdk', 'tgrep', 'bin', tgrepPlatformArch), + `Copilot SDK tgrep for ${tgrepPlatformArch}` + ); + } finally { + cleanup(); + } +} + +/** + * Resolves a `@github/copilot-{platform}` package directory whose version + * matches `extVersion`, so the native copied into the built-in extension always + * matches the extension's own SDK JavaScript. + * + * Prefers the app-root package when it already matches (no extra work), and + * otherwise fetches the exact extension version into a temp dir. The extension + * is pinned to a fixed CLI version for the extension host while the agent host + * (app-root) keeps updating, so app-root will normally NOT match and the fetch + * is the expected path once the two versions diverge. The fetched tarball is + * verified against the SHA-512 the extension lockfile pins for that version + * before extraction; resolution fails closed if that integrity is missing. + */ +function resolveVersionMatchedCopilotPlatformPackage(copilotPackagePlatformArch: string, extVersion: string, appNodeModulesDir: string, options: PrepareBuiltInCopilotOptions): { dir: string; cleanup: () => void } { + const noop = () => { }; + const packageName = `@github/copilot-${copilotPackagePlatformArch}`; + + const appRootDir = path.join(appNodeModulesDir, '@github', `copilot-${copilotPackagePlatformArch}`); + if (readOptionalPackageVersion(appRootDir) === extVersion) { + return { dir: appRootDir, cleanup: noop }; } - copyRequiredDirectory( - path.join(platformPackageDir, 'prebuilds', copilotPackagePlatformArch), - path.join(copilotBase, 'sdk', 'prebuilds', copilotPackagePlatformArch), - `Copilot SDK native prebuilds for ${copilotPackagePlatformArch}` - ); + const integrity = resolvePinnedPlatformPackageIntegrity(packageName, extVersion, options); + const staged = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-copilot-native-')); + try { + const stagedPackageDir = path.join(staged, `copilot-${copilotPackagePlatformArch}`); + materializeNpmPackageVersion(packageName, extVersion, stagedPackageDir, integrity, options); + console.log(`[prepareBuiltInCopilotRipgrepShim] ${packageName} in app-root does not match the built-in extension's @github/copilot@${extVersion}; using the version-matched package instead.`); + return { dir: stagedPackageDir, cleanup: () => fs.rmSync(staged, { recursive: true, force: true }) }; + } catch (err) { + fs.rmSync(staged, { recursive: true, force: true }); + throw err; + } +} - if (!copilotTgrepPlatforms.includes(tgrepPlatformArch)) { - return; +/** + * Reads the `sha512-...` integrity the built-in extension's lockfile pins for + * `packageName` at `extVersion`. Fails closed: a missing lockfile, entry, + * version mismatch, or integrity means the fetched native cannot be verified, + * so the build must stop rather than ship an unverified binary. + */ +function resolvePinnedPlatformPackageIntegrity(packageName: string, extVersion: string, options: PrepareBuiltInCopilotOptions): string { + const lockfilePath = options.extensionLockfilePath ?? path.join(import.meta.dirname, '..', '..', 'extensions', 'copilot', 'package-lock.json'); + + let lock: { packages?: Record }; + try { + lock = JSON.parse(fs.readFileSync(lockfilePath, 'utf8')); + } catch (err) { + throw new Error(`[prepareBuiltInCopilotRipgrepShim] Could not read ${lockfilePath} to verify ${packageName}@${extVersion}: ${err instanceof Error ? err.message : String(err)}`); } - const tgrepSource = path.join(platformPackageDir, 'tgrep', 'bin', tgrepPlatformArch); - copyRequiredDirectory( - tgrepSource, - path.join(copilotBase, 'tgrep', 'bin', tgrepPlatformArch), - `Copilot tgrep for ${tgrepPlatformArch}` - ); - copyRequiredDirectory( - tgrepSource, - path.join(copilotBase, 'sdk', 'tgrep', 'bin', tgrepPlatformArch), - `Copilot SDK tgrep for ${tgrepPlatformArch}` - ); + const entry = lock.packages?.[path.posix.join('node_modules', packageName)]; + if (!entry) { + throw new Error(`[prepareBuiltInCopilotRipgrepShim] ${packageName} is not recorded in ${lockfilePath}; refusing to fetch an unverifiable native.`); + } + if (entry.version !== extVersion) { + throw new Error(`[prepareBuiltInCopilotRipgrepShim] ${packageName} is pinned to ${entry.version} in ${lockfilePath} but the built-in extension is @github/copilot@${extVersion}; refusing to fetch an unverifiable native.`); + } + if (!entry.integrity) { + throw new Error(`[prepareBuiltInCopilotRipgrepShim] ${packageName}@${extVersion} has no integrity in ${lockfilePath}; refusing to fetch an unverifiable native.`); + } + return entry.integrity; +} + +function readCopilotPackageVersion(copilotBase: string): string { + const version = readOptionalPackageVersion(copilotBase); + if (!version) { + throw new Error(`[prepareBuiltInCopilotRipgrepShim] Could not read a version from ${path.join(copilotBase, 'package.json')}`); + } + return version; +} + +function readOptionalPackageVersion(packageDir: string): string | undefined { + try { + const version = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')).version; + return typeof version === 'string' ? version : undefined; + } catch { + return undefined; + } } function copyRequiredDirectory(source: string, target: string, description: string): void { diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index d56fe189549..acfd99d583d 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -692,6 +692,10 @@ "name": "vs/sessions/contrib/codeReview", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/customViewTest", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/fileTreeView", "project": "vscode-sessions" diff --git a/build/lib/npmPackage.ts b/build/lib/npmPackage.ts index 1be92590f78..6de7f17ccc3 100644 --- a/build/lib/npmPackage.ts +++ b/build/lib/npmPackage.ts @@ -54,6 +54,33 @@ export function ensureNpmPackage(packageName: string, nodeModulesRoot = 'node_mo } } +/** + * Materializes a SPECIFIC version of an npm package into `targetDir`, replacing + * any existing contents. Unlike {@link ensureNpmPackage}, the version is passed + * explicitly rather than read from the adjacent lockfile — use for build-time + * payloads whose required version does not match that lockfile. Pass + * `expectedIntegrity` (the `sha512-...` recorded for that version in the + * relevant lockfile) to verify the fetched tarball before extraction. + */ +export function materializeNpmPackageVersion(packageName: string, version: string, targetDir: string, expectedIntegrity: string | undefined, options: EnsureNpmPackageOptions = {}): void { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-npm-package-')); + try { + const tarballPath = (options.packPackage ?? packNpmPackage)(packageName, version, tempDir); + verifyNpmIntegrity(tarballPath, expectedIntegrity); + + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.mkdirSync(targetDir, { recursive: true }); + extract({ file: tarballPath, cwd: targetDir, strip: 1, sync: true }); + console.log(`[materializeNpmPackageVersion] Materialized ${packageName}@${version} in ${targetDir}`); + } catch (err) { + fs.rmSync(targetDir, { recursive: true, force: true }); + throw new Error(`[materializeNpmPackageVersion] Failed to materialize ${packageName}@${version}: ${err instanceof Error ? err.message : String(err)}`); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + + function packNpmPackage(packageName: string, version: string, tempDir: string): string { execFileSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['pack', `${packageName}@${version}`, '--pack-destination', tempDir, '--silent'], { stdio: 'pipe', shell: process.platform === 'win32' }); diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 52db3d2c065..694aaf18069 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1022,6 +1022,10 @@ "--part-border-color", "--pane-header-size", "--model-hover-surface-background", + "--modern-ui-editor-tab-action-active-background", + "--modern-ui-editor-tab-action-hover-background", + "--modern-ui-tab-active-background", + "--modern-ui-tab-hover-background", "--scroll-shadow-surface", "--vscode-chat-list-background", "--vscode-editorCodeLens-fontFamily", diff --git a/build/lib/test/copilot.test.ts b/build/lib/test/copilot.test.ts index b87fbb7f2ef..870cced4041 100644 --- a/build/lib/test/copilot.test.ts +++ b/build/lib/test/copilot.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { createHash } from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -11,6 +12,25 @@ import { suite, test } from 'node:test'; import { create } from 'tar'; import { copilotPlatforms, ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getMxcExcludeFilter, prepareBuiltInCopilotRipgrepShim } from '../copilot.ts'; +/** + * Builds a fake `@github/copilot-win32-x64@1.0.73` tarball on disk and returns + * its path plus the `sha512-...` integrity of its bytes, so a test can pin that + * integrity in a lockfile the build verifies against. + */ +function createPinnedCopilotWin32Tarball(dir: string): { tarball: string; integrity: string } { + const stage = fs.mkdtempSync(path.join(dir, 'pkg-')); + const packageRoot = path.join(stage, 'package'); + fs.mkdirSync(path.join(packageRoot, 'prebuilds', 'win32-x64'), { recursive: true }); + fs.mkdirSync(path.join(packageRoot, 'tgrep', 'bin', 'win32-x64'), { recursive: true }); + fs.writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ version: '1.0.73' })); + fs.writeFileSync(path.join(packageRoot, 'prebuilds', 'win32-x64', 'runtime.node'), 'EXT-NATIVE-1.0.73'); + fs.writeFileSync(path.join(packageRoot, 'tgrep', 'bin', 'win32-x64', 'tgrep.exe'), 'EXT-TGREP-1.0.73'); + const tarball = path.join(stage, 'copilot-win32-x64.tgz'); + create({ file: tarball, cwd: stage, gzip: true, sync: true }, ['package']); + const integrity = 'sha512-' + createHash('sha512').update(fs.readFileSync(tarball)).digest('base64'); + return { tarball, integrity }; +} + suite('copilot', () => { test('keeps the public copilot platform package include list scoped to the selected package', () => { const files = getCopilotRuntimePrebuildFiles('linux', 'x64'); @@ -30,6 +50,7 @@ suite('copilot', () => { '!node_modules/@github/copilot-linux-x64/prebuilds/*/Copilot Computer Use.app/**', '!node_modules/@github/copilot-linux-x64/prebuilds/*/CopilotComputerUse.exe', '!node_modules/@github/copilot-linux-x64/prebuilds/*/keytar.node', + '!node_modules/@github/copilot-linux-x64/prebuilds/*/mediaremote-adapter/**', '!node_modules/@github/copilot-linux-x64/prebuilds/*/cli-native.node', ]); assertCopilotPlatformPackageIncludes(files, 'node_modules/@github/copilot-linux-x64', [ @@ -60,6 +81,7 @@ suite('copilot', () => { '!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/Copilot Computer Use.app/**', '!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/CopilotComputerUse.exe', '!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/keytar.node', + '!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/mediaremote-adapter/**', '!node_modules/@github/copilot-linuxmusl-x64/prebuilds/*/cli-native.node', ]); assertCopilotPlatformPackageIncludes(files, 'node_modules/@github/copilot-linuxmusl-x64', [ @@ -87,6 +109,7 @@ suite('copilot', () => { '!node_modules/@github/copilot-win32-x64/prebuilds/*/Copilot Computer Use.app/**', '!node_modules/@github/copilot-win32-x64/prebuilds/*/CopilotComputerUse.exe', '!node_modules/@github/copilot-win32-x64/prebuilds/*/keytar.node', + '!node_modules/@github/copilot-win32-x64/prebuilds/*/mediaremote-adapter/**', ]); assertCopilotPlatformPackageIncludes(getCopilotRuntimePrebuildFiles('win32', 'x64'), 'node_modules/@github/copilot-win32-x64', [ 'index.js', @@ -115,6 +138,7 @@ suite('copilot', () => { '!node_modules/@github/copilot-win32-arm64/prebuilds/*/Copilot Computer Use.app/**', '!node_modules/@github/copilot-win32-arm64/prebuilds/*/CopilotComputerUse.exe', '!node_modules/@github/copilot-win32-arm64/prebuilds/*/keytar.node', + '!node_modules/@github/copilot-win32-arm64/prebuilds/*/mediaremote-adapter/**', ]); assertOptionalCopilotNativeDependenciesExcluded(getCopilotRuntimePrebuildFiles('win32', 'x64'), 'node_modules/@github/copilot-win32-x64'); assertCopilotStandaloneExecutableExcluded(getCopilotRuntimePrebuildFiles('win32', 'arm64'), 'node_modules/@github/copilot-win32-arm64'); @@ -186,10 +210,15 @@ suite('copilot', () => { fs.mkdirSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'linux-x64'), { recursive: true }); fs.writeFileSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'linux-x64', 'runtime.node'), ''); + fs.writeFileSync(path.join(extensionCopilotDir, 'package.json'), JSON.stringify({ version: '1.0.73' })); fs.mkdirSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'conpty'), { recursive: true }); + fs.writeFileSync(path.join(platformPackageDir, 'package.json'), JSON.stringify({ version: '1.0.73' })); + fs.mkdirSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'mediaremote-adapter', 'MediaRemoteAdapter.framework'), { recursive: true }); fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'runtime.node'), ''); fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'conpty.node'), ''); fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'conpty', 'OpenConsole.exe'), ''); + fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'mediaremote-adapter', 'mediaremote-adapter.pl'), ''); + fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'mediaremote-adapter', 'MediaRemoteAdapter.framework', 'MediaRemoteAdapter'), ''); fs.mkdirSync(path.join(platformPackageDir, 'tgrep', 'bin', 'win32-x64'), { recursive: true }); fs.writeFileSync(path.join(platformPackageDir, 'tgrep', 'bin', 'win32-x64', 'tgrep.exe'), ''); fs.mkdirSync(path.join(appNodeModulesDir, '@vscode', 'ripgrep-universal', 'bin', 'win32-x64'), { recursive: true }); @@ -200,6 +229,7 @@ suite('copilot', () => { assert(fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'win32-x64', 'runtime.node'))); assert(fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'win32-x64', 'conpty.node'))); assert(fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'win32-x64', 'conpty', 'OpenConsole.exe'))); + assert(!fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'win32-x64', 'mediaremote-adapter'))); assert(!fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'linux-x64'))); assert(fs.existsSync(path.join(extensionCopilotDir, 'tgrep', 'bin', 'win32-x64', 'tgrep.exe'))); assert(fs.existsSync(path.join(extensionCopilotDir, 'sdk', 'tgrep', 'bin', 'win32-x64', 'tgrep.exe'))); @@ -209,6 +239,87 @@ suite('copilot', () => { } }); + test('materializes a version-matched native when app-root diverges from the pinned extension', () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-copilot-sdk-pinned-test-')); + try { + const builtInCopilotExtensionDir = path.join(repoRoot, 'extensions', 'copilot'); + const extensionCopilotDir = path.join(builtInCopilotExtensionDir, 'node_modules', '@github', 'copilot'); + const appNodeModulesDir = path.join(repoRoot, 'node_modules'); + const platformPackageDir = path.join(appNodeModulesDir, '@github', 'copilot-win32-x64'); + + // Extension pinned at 1.0.73. + fs.mkdirSync(path.join(extensionCopilotDir, 'sdk'), { recursive: true }); + fs.writeFileSync(path.join(extensionCopilotDir, 'package.json'), JSON.stringify({ version: '1.0.73' })); + + // App-root updated ahead of the pinned extension — its (mismatched) native must NOT be used. + fs.mkdirSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64'), { recursive: true }); + fs.writeFileSync(path.join(platformPackageDir, 'package.json'), JSON.stringify({ version: '9.9.9-canary' })); + fs.writeFileSync(path.join(platformPackageDir, 'prebuilds', 'win32-x64', 'runtime.node'), 'CANARY-NATIVE'); + fs.mkdirSync(path.join(platformPackageDir, 'tgrep', 'bin', 'win32-x64'), { recursive: true }); + fs.writeFileSync(path.join(platformPackageDir, 'tgrep', 'bin', 'win32-x64', 'tgrep.exe'), 'CANARY-TGREP'); + + fs.mkdirSync(path.join(appNodeModulesDir, '@vscode', 'ripgrep-universal', 'bin', 'win32-x64'), { recursive: true }); + fs.writeFileSync(path.join(appNodeModulesDir, '@vscode', 'ripgrep-universal', 'bin', 'win32-x64', 'rg.exe'), ''); + + // Pin the fetched tarball's integrity in the extension lockfile the build verifies against. + const { tarball, integrity } = createPinnedCopilotWin32Tarball(repoRoot); + const extensionLockfilePath = path.join(builtInCopilotExtensionDir, 'package-lock.json'); + fs.writeFileSync(extensionLockfilePath, JSON.stringify({ + packages: { 'node_modules/@github/copilot-win32-x64': { version: '1.0.73', integrity } } + })); + + const packCalls: { packageName: string; version: string }[] = []; + prepareBuiltInCopilotRipgrepShim('win32', 'x64', builtInCopilotExtensionDir, appNodeModulesDir, { + extensionLockfilePath, + packPackage: (packageName, version) => { + packCalls.push({ packageName, version }); + return tarball; + } + }); + + // The version-matched (1.0.73) native was fetched and used — not app-root's canary. + assert.deepStrictEqual(packCalls, [{ packageName: '@github/copilot-win32-x64', version: '1.0.73' }]); + assert.strictEqual( + fs.readFileSync(path.join(extensionCopilotDir, 'sdk', 'prebuilds', 'win32-x64', 'runtime.node'), 'utf8'), + 'EXT-NATIVE-1.0.73' + ); + assert.strictEqual( + fs.readFileSync(path.join(extensionCopilotDir, 'sdk', 'tgrep', 'bin', 'win32-x64', 'tgrep.exe'), 'utf8'), + 'EXT-TGREP-1.0.73' + ); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + test('refuses to ship a fetched native that does not match the pinned extension lockfile integrity', () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-copilot-sdk-integrity-test-')); + try { + const builtInCopilotExtensionDir = path.join(repoRoot, 'extensions', 'copilot'); + const extensionCopilotDir = path.join(builtInCopilotExtensionDir, 'node_modules', '@github', 'copilot'); + const appNodeModulesDir = path.join(repoRoot, 'node_modules'); + + fs.mkdirSync(path.join(extensionCopilotDir, 'sdk'), { recursive: true }); + fs.writeFileSync(path.join(extensionCopilotDir, 'package.json'), JSON.stringify({ version: '1.0.73' })); + fs.mkdirSync(path.join(appNodeModulesDir, '@vscode', 'ripgrep-universal', 'bin', 'win32-x64'), { recursive: true }); + fs.writeFileSync(path.join(appNodeModulesDir, '@vscode', 'ripgrep-universal', 'bin', 'win32-x64', 'rg.exe'), ''); + + const { tarball } = createPinnedCopilotWin32Tarball(repoRoot); + const extensionLockfilePath = path.join(builtInCopilotExtensionDir, 'package-lock.json'); + // Lockfile pins a DIFFERENT (tampered) integrity than the fetched tarball. + fs.writeFileSync(extensionLockfilePath, JSON.stringify({ + packages: { 'node_modules/@github/copilot-win32-x64': { version: '1.0.73', integrity: `sha512-${'A'.repeat(88)}` } } + })); + + assert.throws(() => prepareBuiltInCopilotRipgrepShim('win32', 'x64', builtInCopilotExtensionDir, appNodeModulesDir, { + extensionLockfilePath, + packPackage: () => tarball + }), /integrity mismatch/); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + test('strips all copilot platform packages for unsupported armhf builds', () => { assert.deepStrictEqual( getCopilotExcludeFilter('linux', 'armhf'), @@ -281,6 +392,8 @@ function assertOptionalCopilotNativeDependenciesExcluded(patterns: string[], pac assert(!matchesGlob(`${packageDir}/prebuilds/win32-x64/CopilotComputerUse.exe`, patterns), 'CopilotComputerUse.exe'); assert(patterns.includes(`!${packageDir}/prebuilds/*/keytar.node`), 'keytar.node'); assert(!matchesGlob(`${packageDir}/prebuilds/linux-x64/keytar.node`, patterns), 'keytar.node'); + assert(patterns.includes(`!${packageDir}/prebuilds/*/mediaremote-adapter/**`), 'mediaremote-adapter'); + assert(!matchesGlob(`${packageDir}/prebuilds/darwin-arm64/mediaremote-adapter/MediaRemoteAdapter.framework/MediaRemoteAdapter`, patterns), 'mediaremote-adapter'); if (!packageDir.includes('win32')) { assert(patterns.includes(`!${packageDir}/prebuilds/*/cli-native.node`), 'cli-native.node'); diff --git a/build/rspack/rspack.serve-out.config.mts b/build/rspack/rspack.serve-out.config.mts index d533ce29a29..760c9880d12 100644 --- a/build/rspack/rspack.serve-out.config.mts +++ b/build/rspack/rspack.serve-out.config.mts @@ -98,6 +98,9 @@ export default { { test: /\.ttf$/, type: 'asset/resource', + generator: { + publicPath: isStaticComponentExplorerBuild ? '../' : '/', + }, }, { // Built-in theme JSON files use JSONC (comments / trailing diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 717b17c6ad6..31145cc68f8 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -5618,7 +5618,7 @@ }, { "command": "github.copilot.chat.checkoutPullRequestReroute", - "when": "chatSessionType == copilot-cloud-agent && !github.vscode-pull-request-github.activated && gitOpenRepositoryCount != 0", + "when": "chatSessionType == copilot-cloud-agent && chatSessionPullRequest != 'none' && !github.vscode-pull-request-github.activated && gitOpenRepositoryCount != 0", "group": "navigation@0" }, { diff --git a/extensions/copilot/src/extension/byok/common/geminiFunctionDeclarationConverter.ts b/extensions/copilot/src/extension/byok/common/geminiFunctionDeclarationConverter.ts index f25e50d3ee5..990537ed9a0 100644 --- a/extensions/copilot/src/extension/byok/common/geminiFunctionDeclarationConverter.ts +++ b/extensions/copilot/src/extension/byok/common/geminiFunctionDeclarationConverter.ts @@ -11,7 +11,7 @@ export type ToolJsonSchema = { properties?: Record; items?: ToolJsonSchema; required?: string[]; - enum?: string[]; + enum?: unknown[]; // Add support for JSON Schema composition keywords anyOf?: ToolJsonSchema[]; @@ -104,8 +104,11 @@ function transformConcrete(schema: ToolJsonSchema): Schema { transformed.description = schema.description; } - if (schema.enum) { - transformed.enum = schema.enum; + if (type === 'string' && schema.enum) { + const values = schema.enum.filter((value): value is string => typeof value === 'string'); + if (values.length > 0) { + transformed.enum = values; + } } if (type === 'object' && schema.properties) { diff --git a/extensions/copilot/src/extension/byok/common/geminiMessageConverter.ts b/extensions/copilot/src/extension/byok/common/geminiMessageConverter.ts index 2a8179c1a95..56b0db40d46 100644 --- a/extensions/copilot/src/extension/byok/common/geminiMessageConverter.ts +++ b/extensions/copilot/src/extension/byok/common/geminiMessageConverter.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { Content, FunctionCall, FunctionResponse, Part } from '@google/genai'; import { Raw } from '@vscode/prompt-tsx'; -import type { LanguageModelChatMessage } from 'vscode'; +import type { LanguageModelChatMessage, LanguageModelChatMessage2 } from 'vscode'; import { CustomDataPartMimeTypes } from '../../../platform/endpoint/common/endpointTypes'; import { LanguageModelChatMessageRole, LanguageModelDataPart, LanguageModelTextPart, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolResultPart, LanguageModelToolResultPart2 } from '../../../vscodeTypes'; @@ -120,7 +120,7 @@ function apiContentToGeminiContent(content: (LanguageModelTextPart | LanguageMod return convertedContent; } -export function apiMessageToGeminiMessage(messages: LanguageModelChatMessage[]): { contents: Content[]; systemInstruction?: Content } { +export function apiMessageToGeminiMessage(messages: Array): { contents: Content[]; systemInstruction?: Content } { const contents: Content[] = []; let systemInstruction: Content | undefined; @@ -131,8 +131,7 @@ export function apiMessageToGeminiMessage(messages: LanguageModelChatMessage[]): if (message.role === LanguageModelChatMessageRole.System) { // Gemini uses system instruction separately const systemText = message.content - .filter((p): p is LanguageModelTextPart => p instanceof LanguageModelTextPart) - .map(p => p.value) + .map(part => part instanceof LanguageModelTextPart ? part.value : '') .join(''); if (systemText.trim()) { diff --git a/extensions/copilot/src/extension/byok/common/test/geminiFunctionDeclarationConverter.spec.ts b/extensions/copilot/src/extension/byok/common/test/geminiFunctionDeclarationConverter.spec.ts index 986c6a51749..80b64f6d4d2 100644 --- a/extensions/copilot/src/extension/byok/common/test/geminiFunctionDeclarationConverter.spec.ts +++ b/extensions/copilot/src/extension/byok/common/test/geminiFunctionDeclarationConverter.spec.ts @@ -179,6 +179,41 @@ describe('GeminiFunctionDeclarationConverter', () => { }); }); + it('should omit non-string enums from nested schemas', () => { + const result = toGeminiFunction('nestedEnumFunction', 'Function with nested non-string enums', { + type: 'object', + properties: { + values: { + type: 'array', + items: { + type: 'object', + properties: { + enabled: { + type: 'boolean', + enum: [true] + }, + count: { + type: 'integer', + enum: [1, 2] + } + } + } + } + } + }); + + expect(result.parameters!.properties!['values']).toEqual({ + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + enabled: { type: Type.BOOLEAN }, + count: { type: Type.INTEGER } + } + } + }); + }); + it('should handle nullable anyOf schemas', () => { const result = toGeminiFunction('nullableAnyOfFunction', 'Function with nullable anyOf', { type: 'object', diff --git a/extensions/copilot/src/extension/byok/common/test/geminiMessageConverter.spec.ts b/extensions/copilot/src/extension/byok/common/test/geminiMessageConverter.spec.ts index 68e36160eb4..1d02c638f2e 100644 --- a/extensions/copilot/src/extension/byok/common/test/geminiMessageConverter.spec.ts +++ b/extensions/copilot/src/extension/byok/common/test/geminiMessageConverter.spec.ts @@ -5,9 +5,9 @@ import { Raw } from '@vscode/prompt-tsx'; import { describe, expect, it } from 'vitest'; -import type { LanguageModelChatMessage } from 'vscode'; +import type { LanguageModelChatMessage, LanguageModelChatMessage2 } from 'vscode'; import { CustomDataPartMimeTypes } from '../../../../platform/endpoint/common/endpointTypes'; -import { LanguageModelChatMessageRole, LanguageModelDataPart, LanguageModelTextPart, LanguageModelToolResultPart, LanguageModelTextPart as LMText } from '../../../../vscodeTypes'; +import { LanguageModelChatMessageRole, LanguageModelDataPart, LanguageModelTextPart, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolResultPart, LanguageModelTextPart as LMText } from '../../../../vscodeTypes'; import { apiMessageToGeminiMessage } from '../geminiMessageConverter'; describe('GeminiMessageConverter', () => { @@ -80,6 +80,27 @@ describe('GeminiMessageConverter', () => { expect(result.contents[0].parts![1].text).toBe('Hello!'); }); + it('should attach a thought signature to the following function call', () => { + const messages: Array = [{ + role: LanguageModelChatMessageRole.Assistant, + content: [ + new LanguageModelThinkingPart('', undefined, { signature: 'thought-signature' }), + new LanguageModelToolCallPart('call-1', 'default_api:view', { path: 'README.md' }), + ], + name: undefined, + }]; + + const result = apiMessageToGeminiMessage(messages); + + expect(result.contents[0].parts).toEqual([{ + functionCall: { + name: 'default_api:view', + args: { path: 'README.md' }, + }, + thoughtSignature: 'thought-signature', + }]); + }); + it('should extract functionResponse parts from model message into subsequent user message and prune empty model', () => { // Simulate a model message that (incorrectly) contains only a tool result part const toolResult = new LanguageModelToolResultPart('myTool_12345', [new LanguageModelTextPart('{"foo":"bar"}')]); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts index f8a612d248b..ac0c1c6f1b5 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts @@ -7,22 +7,17 @@ import { homedir } from 'os'; import { join } from 'path'; const COPILOT_HOME_DIRECTORY = '.copilot'; -const APP_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'ide'); -const SESSION_STATE_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'session-state'); export function getCopilotHome(): string { - const xdgHome = process.env.XDG_STATE_HOME; - return xdgHome ? join(xdgHome, COPILOT_HOME_DIRECTORY) : join(homedir(), COPILOT_HOME_DIRECTORY); + return process.env.COPILOT_HOME || join(homedir(), COPILOT_HOME_DIRECTORY); } export function getCopilotCliStateDir(): string { - const xdgHome = process.env.XDG_STATE_HOME; - return xdgHome ? join(xdgHome, APP_DIRECTORY) : join(homedir(), APP_DIRECTORY); + return join(getCopilotHome(), 'ide'); } export function getCopilotCLISessionStateDir(): string { - const xdgHome = process.env.XDG_STATE_HOME; - return xdgHome ? join(xdgHome, SESSION_STATE_DIRECTORY) : join(homedir(), SESSION_STATE_DIRECTORY); + return join(getCopilotHome(), 'session-state'); } export function getCopilotCLISessionDir(sessionId: string): string { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index f90561a23ce..0b5a30fb7f4 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -107,6 +107,7 @@ class CopilotCLIResponseStreamRouter { push: (part: vscode.ExtendedChatResponsePart): void => { this._call('push', [part]); }, thinkingProgress: (thinkingDelta: vscode.ThinkingDelta): void => { this._call('thinkingProgress', [thinkingDelta]); }, hookProgress: (hookType: vscode.ChatHookType, stopReason?: string, systemMessage?: string): void => { this._call('hookProgress', [hookType, stopReason, systemMessage]); }, + voiceProgress: (id: string, value: string): void => { this._call('voiceProgress', [id, value]); }, textEdit: (target: vscode.Uri, editsOrDone: vscode.TextEdit | vscode.TextEdit[] | true): void => { this._call('textEdit', [target, editsOrDone]); }, notebookEdit: (target: vscode.Uri, editsOrDone: vscode.NotebookEdit | vscode.NotebookEdit[] | true): void => { this._call('notebookEdit', [target, editsOrDone]); }, workspaceEdit: (edits: vscode.ChatWorkspaceFileEdit[]): void => { this._call('workspaceEdit', [edits]); }, diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/cliHelpers.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/cliHelpers.spec.ts new file mode 100644 index 00000000000..ed9b18fb7fd --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/cliHelpers.spec.ts @@ -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 { homedir } from 'os'; +import { join } from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + getCopilotCliStateDir, + getCopilotCLISessionStateDir, + getCopilotHome, +} from '../cliHelpers'; + +const originalCopilotHome = process.env.COPILOT_HOME; +const originalXdgStateHome = process.env.XDG_STATE_HOME; + +function setEnv( + name: 'COPILOT_HOME' | 'XDG_STATE_HOME', + value: string | undefined, +): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +afterEach(() => { + setEnv('COPILOT_HOME', originalCopilotHome); + setEnv('XDG_STATE_HOME', originalXdgStateHome); +}); + +describe('Copilot CLI state directories', () => { + it('uses COPILOT_HOME', () => { + setEnv('COPILOT_HOME', '/tmp/copilot-home'); + setEnv('XDG_STATE_HOME', '/tmp/xdg-state'); + + expect(getCopilotHome()).toBe('/tmp/copilot-home'); + expect(getCopilotCliStateDir()).toBe(join('/tmp/copilot-home', 'ide')); + expect(getCopilotCLISessionStateDir()).toBe( + join('/tmp/copilot-home', 'session-state'), + ); + }); + + it('does not use the legacy XDG_STATE_HOME location', () => { + setEnv('COPILOT_HOME', undefined); + setEnv('XDG_STATE_HOME', '/tmp/xdg-state'); + + expect(getCopilotHome()).toBe(join(homedir(), '.copilot')); + expect(getCopilotCliStateDir()).toBe( + join(homedir(), '.copilot', 'ide'), + ); + expect(getCopilotCLISessionStateDir()).toBe( + join(homedir(), '.copilot', 'session-state'), + ); + }); + + it('falls back to the user home directory', () => { + setEnv('COPILOT_HOME', undefined); + setEnv('XDG_STATE_HOME', undefined); + + expect(getCopilotHome()).toBe(join(homedir(), '.copilot')); + expect(getCopilotCliStateDir()).toBe( + join(homedir(), '.copilot', 'ide'), + ); + expect(getCopilotCLISessionStateDir()).toBe( + join(homedir(), '.copilot', 'session-state'), + ); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index dfbac6a6636..019fa142903 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -178,7 +178,7 @@ describe('CopilotCLISessionService', () => { let configurationService: IConfigurationService; let createSessionService: (options?: ICreateSessionServiceOptions) => CopilotCLISessionService; let tempStateHome: string | undefined; - const originalXdgStateHome = process.env.XDG_STATE_HOME; + const originalCopilotHome = process.env.COPILOT_HOME; beforeEach(async () => { vi.useRealTimers(); const sdk = { @@ -251,7 +251,11 @@ describe('CopilotCLISessionService', () => { void rm(tempStateHome, { recursive: true, force: true }); tempStateHome = undefined; } - process.env.XDG_STATE_HOME = originalXdgStateHome; + if (originalCopilotHome === undefined) { + delete process.env.COPILOT_HOME; + } else { + process.env.COPILOT_HOME = originalCopilotHome; + } vi.useRealTimers(); vi.restoreAllMocks(); disposables.clear(); @@ -718,7 +722,7 @@ describe('CopilotCLISessionService', () => { describe('CopilotCLISessionService.tryGetPartialSesionHistory', () => { it('reconstructs history from persisted files', async () => { tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-')); - process.env.XDG_STATE_HOME = tempStateHome; + process.env.COPILOT_HOME = join(tempStateHome, '.copilot'); const sessionId = 'partial-session'; const sessionDir = URI.file(getCopilotCLISessionDir(sessionId)); const fileSystem = new MockFileSystemService(); @@ -757,7 +761,7 @@ describe('CopilotCLISessionService', () => { it('returns cached result on second call without re-reading the file', async () => { tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-')); - process.env.XDG_STATE_HOME = tempStateHome; + process.env.COPILOT_HOME = join(tempStateHome, '.copilot'); const sessionId = 'cache-test-session'; const sessionDir = URI.file(getCopilotCLISessionDir(sessionId)); const fileSystem = new MockFileSystemService(); @@ -796,7 +800,7 @@ describe('CopilotCLISessionService', () => { it('returns undefined when the events file does not exist', async () => { tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-')); - process.env.XDG_STATE_HOME = tempStateHome; + process.env.COPILOT_HOME = join(tempStateHome, '.copilot'); const result = await service.tryGetPartialSessionHistory('nonexistent-session-id'); expect(result).toBeUndefined(); @@ -863,7 +867,7 @@ describe('CopilotCLISessionService', () => { it('falls back to partial session data when getSession fails with an unknown event type', async () => { tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-')); - process.env.XDG_STATE_HOME = tempStateHome; + process.env.COPILOT_HOME = join(tempStateHome, '.copilot'); const sessionId = 'invalid-session'; const sessionDir = URI.file(getCopilotCLISessionDir(sessionId)); const fileSystem = new MockFileSystemService(); @@ -909,7 +913,7 @@ describe('CopilotCLISessionService', () => { it('does not emit session when summary is truncated and no user turns exist', async () => { tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-')); - process.env.XDG_STATE_HOME = tempStateHome; + process.env.COPILOT_HOME = join(tempStateHome, '.copilot'); const sessionId = 'no-user-turns-session'; const sessionDir = URI.file(getCopilotCLISessionDir(sessionId)); const fileSystem = new MockFileSystemService(); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/lockFile.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/lockFile.spec.ts index 5150f17758e..333fd0ade82 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/lockFile.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/lockFile.spec.ts @@ -101,8 +101,8 @@ describe('createLockFile', () => { let createdLockFile: string | null = null; beforeEach(() => { - originalEnv = process.env.XDG_STATE_HOME; - process.env.XDG_STATE_HOME = testDir; + originalEnv = process.env.COPILOT_HOME; + process.env.COPILOT_HOME = path.join(testDir, '.copilot'); }); afterEach(async () => { @@ -111,9 +111,9 @@ describe('createLockFile', () => { createdLockFile = null; } if (originalEnv !== undefined) { - process.env.XDG_STATE_HOME = originalEnv; + process.env.COPILOT_HOME = originalEnv; } else { - delete process.env.XDG_STATE_HOME; + delete process.env.COPILOT_HOME; } await fs.rm(testDir, { recursive: true, force: true }).catch(() => { }); }); @@ -177,16 +177,16 @@ describe('cleanupStaleLockFiles', () => { let originalEnv: string | undefined; beforeEach(async () => { - originalEnv = process.env.XDG_STATE_HOME; - process.env.XDG_STATE_HOME = testDir; + originalEnv = process.env.COPILOT_HOME; + process.env.COPILOT_HOME = path.join(testDir, '.copilot'); await fs.mkdir(copilotDir, { recursive: true }); }); afterEach(async () => { if (originalEnv !== undefined) { - process.env.XDG_STATE_HOME = originalEnv; + process.env.COPILOT_HOME = originalEnv; } else { - delete process.env.XDG_STATE_HOME; + delete process.env.COPILOT_HOME; } await fs.rm(testDir, { recursive: true, force: true }).catch(() => { }); }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index 8feea1a6cf0..4b435a75f97 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -525,8 +525,9 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C pullRequestNumber = SessionIdForPr.parsePullRequestNumber(resource); } - + // Reachable when `chatSessionPullRequest` is unknown, which keeps the action visible. if (!pullRequestNumber) { + this.logService.warn('No pull request number could be resolved for the requested cloud session action.'); return; } const repoIds = await getRepoId(this._gitService); diff --git a/extensions/copilot/src/extension/chronicle/node/cloudSessionApiClient.ts b/extensions/copilot/src/extension/chronicle/node/cloudSessionApiClient.ts index f14ff1a7db5..928aca02c08 100644 --- a/extensions/copilot/src/extension/chronicle/node/cloudSessionApiClient.ts +++ b/extensions/copilot/src/extension/chronicle/node/cloudSessionApiClient.ts @@ -6,7 +6,9 @@ import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { ICopilotTokenManager } from '../../../platform/authentication/common/copilotTokenManager'; import { INTEGRATION_ID } from '../../../platform/endpoint/common/licenseAgreement'; -import { IFetcherService } from '../../../platform/networking/common/fetcherService'; +import { IFetcherService, type Response } from '../../../platform/networking/common/fetcherService'; +import { FetchBlockedError, type HttpFetchFn, type HttpResponse } from '../../../shared-fetch-utils/common/fetchTypes'; +import { rateLimitBackoffMiddleware } from '../../../shared-fetch-utils/common/middleware/rateLimitBackoffMiddleware'; import type { CreateSessionFailureReason, CreateSessionResult, CloudSession, SessionEvent, SubmitSessionEventsResult } from '../common/cloudSessionTypes'; /** Timeout for individual cloud API requests (ms). */ @@ -15,6 +17,31 @@ const REQUEST_TIMEOUT_MS = 10_000; /** Cloud sessions endpoint path. */ const SESSIONS_PATH = '/agents/sessions'; +/** Initial backoff applied when the server reports a rate limit without a hint. */ +const RATE_LIMIT_INITIAL_BACKOFF_MS = 60_000; + +/** Upper bound on any rate limit backoff, including one the server asks for. */ +const RATE_LIMIT_MAX_BACKOFF_MS = 600_000; + +/** What a cloud request produced, so each caller can map it onto its own result shape. */ +type CloudFetchOutcome = + | { readonly kind: 'response'; readonly response: Response } + | { readonly kind: 'rateLimited' } + | { readonly kind: 'error' }; + +/** Carries the underlying response through the middleware, which only reads status and headers. */ +type AdaptedResponse = HttpResponse & { readonly original: Response }; + +/** Options for a single cloud API call. */ +type CloudRequestInit = { + readonly method: string; + readonly json?: unknown; + /** Passed to the fetcher for request telemetry. */ + readonly callSite: string; + /** Reported to {@link CloudSessionApiClient.onRateLimited}. */ + readonly operation: string; +}; + // ── Cloud agent application IDs ───────────────────────────────────────────────── /** Agent application IDs used by the cloud sessions API (`agent_id` field). */ @@ -37,43 +64,84 @@ export const CloudAgentId = { */ export class CloudSessionApiClient { - /** Timestamp (epoch ms) until which all requests should be skipped due to 429. */ + /** Timestamp (epoch ms) until which all requests should be skipped due to a rate limit. */ private _rateLimitedUntil = 0; - /** Number of times we've been rate-limited. */ - private _rateLimitCount = 0; - - /** Callback fired when a 429 is received. */ + /** Callback fired when the server reports a new rate limit. */ onRateLimited: ((callSite: string, retryAfterSec: number) => void) | undefined; + /** + * Shared rate limit handling. Only this middleware is applied: `403` here means policy + * blocked rather than an auth failure, and `5xx` backoff is owned by the exporter's circuit + * breaker, so neither the auth nor the server error middleware belongs in this stack. + */ + private readonly _rateLimitedFetch: HttpFetchFn; + constructor( private readonly _tokenManager: ICopilotTokenManager, private readonly _authService: IAuthenticationService, private readonly _fetcherService: IFetcherService, - ) { } + // Injectable so tests can exercise the backoff without waiting on the wall clock. + private readonly _now: () => number = Date.now, + ) { + this._rateLimitedFetch = rateLimitBackoffMiddleware({ + initialDelayMs: RATE_LIMIT_INITIAL_BACKOFF_MS, + maxDelayMs: RATE_LIMIT_MAX_BACKOFF_MS, + now: this._now, + })(async (request) => { + const { method, json, callSite } = request.state as CloudRequestInit; + const original = await this._fetcherService.fetch(request.url, { + callSite, + // FetchOptions.method is typed narrowly (GET/POST/PUT) for CAPI + // compatibility; the underlying fetcher accepts DELETE at runtime. + method: method as 'POST', + headers: request.headers, + json, + timeout: REQUEST_TIMEOUT_MS, + }); + return { + status: original.status, + headers: original.headers, + body: null, + text: () => original.text(), + json: () => original.json(), + original, + } satisfies AdaptedResponse; + }); + } /** Returns true if we're currently rate-limited and should skip requests. */ isRateLimited(): boolean { - return Date.now() < this._rateLimitedUntil; + return this._now() < this._rateLimitedUntil; } - /** Record a 429 response and back off for the indicated duration. */ - private _handleRateLimit(res: { headers?: { get?(name: string): string | null } }, callSite: string): void { - let retryAfterSec = 60; // Default: 60 seconds + /** + * Performs a cloud API request, short-circuiting while rate limited. + * + * The middleware decides how long to wait; this only mirrors that window so + * {@link isRateLimited} can be polled synchronously by the exporter. + */ + private async _fetch(path: string, init: CloudRequestInit): Promise { + // Checked before building the request so a blocked call costs no token lookup, and so the + // telemetry callback only fires for newly reported limits. + if (this.isRateLimited()) { + return { kind: 'rateLimited' }; + } + const { url, headers } = await this._buildRequest(path); + if (!url) { + return { kind: 'error' }; + } try { - const header = res.headers?.get?.('Retry-After'); - if (header) { - const parsed = parseInt(header, 10); - if (!isNaN(parsed) && parsed > 0 && parsed <= 600) { - retryAfterSec = parsed; - } + const response = await this._rateLimitedFetch({ url, headers, state: init }); + return { kind: 'response', response: (response as AdaptedResponse).original }; + } catch (err) { + if (err instanceof FetchBlockedError) { + this._rateLimitedUntil = Math.max(this._rateLimitedUntil, this._now() + err.retryAfterMs); + this.onRateLimited?.(init.operation, Math.round(err.retryAfterMs / 1000)); + return { kind: 'rateLimited' }; } - } catch { - // Use default + return { kind: 'error' }; } - this._rateLimitedUntil = Date.now() + retryAfterSec * 1000; - this._rateLimitCount++; - this.onRateLimited?.(callSite, retryAfterSec); } /** @@ -87,43 +155,31 @@ export class CloudSessionApiClient { sessionId: string, indexingLevel: 'user' | 'repo_and_user' = 'user', ): Promise { - if (this.isRateLimited()) { - return { ok: false, reason: 'rate_limited' }; - } - try { - const { url, headers } = await this._buildRequest(SESSIONS_PATH); - if (!url) { - return { ok: false, reason: 'error' }; - } - - const body = { + const outcome = await this._fetch(SESSIONS_PATH, { + method: 'POST', + callSite: 'chronicle.cloudCreateSession', + operation: 'createSession', + json: { owner_id: ownerId, repo_id: repoId, agent_task_id: sessionId, indexing_level: indexingLevel, - }; + }, + }); + if (outcome.kind !== 'response') { + return { ok: false, reason: outcome.kind === 'rateLimited' ? 'rate_limited' : 'error' }; + } - const res = await this._fetcherService.fetch(url, { - callSite: 'chronicle.cloudCreateSession', - method: 'POST', - headers, - json: body, - timeout: REQUEST_TIMEOUT_MS, - }); - - if (res.status === 429) { - this._handleRateLimit(res, 'createSession'); - return { ok: false, reason: 'rate_limited' }; - } - - if (!res.ok) { - const reason: CreateSessionFailureReason = res.status === 403 ? 'policy_blocked' : 'error'; - return { ok: false, reason }; - } + const res = outcome.response; + if (!res.ok) { + const reason: CreateSessionFailureReason = res.status === 403 ? 'policy_blocked' : 'error'; + return { ok: false, reason }; + } + try { const response = await res.json() as { id: string; task_id?: string; agent_task_id?: string }; return { ok: true, response }; - } catch (err) { + } catch { return { ok: false, reason: 'error' }; } } @@ -137,69 +193,40 @@ export class CloudSessionApiClient { sessionId: string, events: SessionEvent[], ): Promise { - if (this.isRateLimited()) { - return { ok: false, reason: 'rate_limited' }; + const outcome = await this._fetch(`${SESSIONS_PATH}/${sessionId}/events`, { + method: 'POST', + callSite: 'chronicle.cloudSubmitEvents', + operation: 'submitEvents', + json: { events }, + }); + if (outcome.kind !== 'response') { + return { ok: false, reason: outcome.kind === 'rateLimited' ? 'rate_limited' : 'error' }; } - try { - const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}/${sessionId}/events`); - if (!url) { - return { ok: false, reason: 'error' }; - } - const res = await this._fetcherService.fetch(url, { - callSite: 'chronicle.cloudSubmitEvents', - method: 'POST', - headers, - json: { events }, - timeout: REQUEST_TIMEOUT_MS, - }); - - if (res.status === 429) { - this._handleRateLimit(res, 'submitEvents'); - return { ok: false, reason: 'rate_limited' }; - } - - if (!res.ok) { - const reason: 'policy_blocked' | 'error' = res.status === 403 ? 'policy_blocked' : 'error'; - return { ok: false, reason }; - } - - return { ok: true }; - } catch (err) { - return { ok: false, reason: 'error' }; + const res = outcome.response; + if (!res.ok) { + const reason: 'policy_blocked' | 'error' = res.status === 403 ? 'policy_blocked' : 'error'; + return { ok: false, reason }; } + + return { ok: true }; } /** * Get a session by ID (used for reattach verification). */ async getSession(sessionId: string): Promise { - if (this.isRateLimited()) { + const outcome = await this._fetch(`${SESSIONS_PATH}/${sessionId}`, { + method: 'GET', + callSite: 'chronicle.cloudGetSession', + operation: 'getSession', + }); + if (outcome.kind !== 'response' || !outcome.response.ok) { return undefined; } + try { - const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}/${sessionId}`); - if (!url) { - return undefined; - } - - const res = await this._fetcherService.fetch(url, { - callSite: 'chronicle.cloudGetSession', - method: 'GET', - headers, - timeout: REQUEST_TIMEOUT_MS, - }); - - if (res.status === 429) { - this._handleRateLimit(res, 'getSession'); - return undefined; - } - - if (!res.ok) { - return undefined; - } - - return (await res.json()) as CloudSession; + return (await outcome.response.json()) as CloudSession; } catch { return undefined; } @@ -211,36 +238,21 @@ export class CloudSessionApiClient { */ async listSessions(): Promise> { const allSessions: Array<{ id: string; task_id?: string; agent_task_id?: string; agent_id?: number; state: string; created_at: string }> = []; - if (this.isRateLimited()) { - return allSessions; - } const pageSize = 100; let page = 1; try { while (true) { - const { url, headers } = await this._buildRequest(`${SESSIONS_PATH}?page_size=${pageSize}&page_number=${page}`); - if (!url) { - return allSessions; - } - - const res = await this._fetcherService.fetch(url, { - callSite: 'chronicle.cloudListSessions', + const outcome = await this._fetch(`${SESSIONS_PATH}?page_size=${pageSize}&page_number=${page}`, { method: 'GET', - headers, - timeout: REQUEST_TIMEOUT_MS, + callSite: 'chronicle.cloudListSessions', + operation: 'listSessions', }); - - if (res.status === 429) { - this._handleRateLimit(res, 'listSessions'); + if (outcome.kind !== 'response' || !outcome.response.ok) { return allSessions; } - if (!res.ok) { - return allSessions; - } - - const data = await res.json(); + const data = await outcome.response.json(); const sessions = Array.isArray(data) ? data : (data as Record).sessions; const pageSessions = Array.isArray(sessions) ? sessions : []; @@ -272,38 +284,20 @@ export class CloudSessionApiClient { * treated as success), or 'error' on failure. */ async deleteSession(taskId: string): Promise<'deleted' | 'not_found' | 'error'> { - if (this.isRateLimited()) { + const outcome = await this._fetch(`/agents/tasks/${encodeURIComponent(taskId)}`, { + method: 'DELETE', + callSite: 'chronicle.cloudDeleteSession', + operation: 'deleteSession', + }); + if (outcome.kind !== 'response') { return 'error'; } - try { - const { url, headers } = await this._buildRequest(`/agents/tasks/${encodeURIComponent(taskId)}`); - if (!url) { - return 'error'; - } - const res = await this._fetcherService.fetch(url, { - callSite: 'chronicle.cloudDeleteSession', - // FetchOptions.method is typed narrowly (GET/POST/PUT) for CAPI - // compatibility; the underlying fetcher accepts DELETE at runtime. - method: 'DELETE' as 'POST', - headers, - timeout: REQUEST_TIMEOUT_MS, - }); - - if (res.status === 429) { - this._handleRateLimit(res, 'deleteSession'); - return 'error'; - } - if (res.status === 404) { - return 'not_found'; - } - if (res.ok) { - return 'deleted'; - } - return 'error'; - } catch (err) { - return 'error'; + const res = outcome.response; + if (res.status === 404) { + return 'not_found'; } + return res.ok ? 'deleted' : 'error'; } /** @@ -311,33 +305,18 @@ export class CloudSessionApiClient { * Single API call that queues all eligible sessions for reindexing. */ async backfillAnalytics(indexingLevel: 'user' | 'repo_and_user'): Promise<{ ok: true; sessionsQueued: number } | { ok: false }> { - if (this.isRateLimited()) { + const outcome = await this._fetch('/agents/analytics/backfill', { + method: 'POST', + callSite: 'chronicle.cloudBackfillAnalytics', + operation: 'backfillAnalytics', + json: { indexing_level: indexingLevel }, + }); + if (outcome.kind !== 'response' || !outcome.response.ok) { return { ok: false }; } + try { - const { url, headers } = await this._buildRequest('/agents/analytics/backfill'); - if (!url) { - return { ok: false }; - } - - const res = await this._fetcherService.fetch(url, { - callSite: 'chronicle.cloudBackfillAnalytics', - method: 'POST', - headers, - json: { indexing_level: indexingLevel }, - timeout: REQUEST_TIMEOUT_MS, - }); - - if (res.status === 429) { - this._handleRateLimit(res, 'backfillAnalytics'); - return { ok: false }; - } - - if (!res.ok) { - return { ok: false }; - } - - const data = await res.json() as { sessions_queued?: number }; + const data = await outcome.response.json() as { sessions_queued?: number }; return { ok: true, sessionsQueued: data.sessions_queued ?? 0 }; } catch { return { ok: false }; diff --git a/extensions/copilot/src/extension/chronicle/node/test/cloudSessionApiClient.spec.ts b/extensions/copilot/src/extension/chronicle/node/test/cloudSessionApiClient.spec.ts index fd731efd17c..98f9da3ebf0 100644 --- a/extensions/copilot/src/extension/chronicle/node/test/cloudSessionApiClient.spec.ts +++ b/extensions/copilot/src/extension/chronicle/node/test/cloudSessionApiClient.spec.ts @@ -31,11 +31,12 @@ function createMockServices() { return { tokenManager, authService, fetcherService }; } -function makeFetchResponse(status: number, body: unknown = {}): { ok: boolean; status: number; headers: { get: (n: string) => string | null }; json: () => Promise } { +function makeFetchResponse(status: number, body: unknown = {}, headers: Record = {}): { ok: boolean; status: number; headers: { get: (n: string) => string | null }; json: () => Promise } { + const lowerCased = new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])); return { ok: status >= 200 && status < 300, status, - headers: { get: () => null }, + headers: { get: (name: string) => lowerCased.get(name.toLowerCase()) ?? null }, json: async () => body, }; } @@ -124,4 +125,69 @@ describe('CloudSessionApiClient', () => { expect(result).toEqual({ ok: false, reason: 'rate_limited' }); }); }); + + describe('rate limiting', () => { + it('skips requests while rate limited and resumes once the window passes', async () => { + const { tokenManager, authService, fetcherService } = createMockServices(); + let now = Date.UTC(2026, 0, 1); + const fetch = fetcherService.fetch as any; + fetch.mockResolvedValue(makeFetchResponse(429, {}, { 'retry-after': '120' })); + + const client = new CloudSessionApiClient(tokenManager, authService, fetcherService, () => now); + + const first = await client.submitSessionEvents('sess-1', []); + const callsAfterLimit = fetch.mock.calls.length; + + // Inside the window the call short-circuits without touching the network. + now += 60_000; + const during = await client.submitSessionEvents('sess-1', []); + const callsDuringWindow = fetch.mock.calls.length; + + // Past the window the client tries again and recovers. + now += 61_000; + fetch.mockResolvedValue(makeFetchResponse(200)); + const after = await client.submitSessionEvents('sess-1', []); + + expect({ first, during, after, callsAfterLimit, callsDuringWindow, callsTotal: fetch.mock.calls.length, limitedNow: client.isRateLimited() }).toEqual({ + first: { ok: false, reason: 'rate_limited' }, + during: { ok: false, reason: 'rate_limited' }, + after: { ok: true }, + callsAfterLimit: 1, + callsDuringWindow: 1, + callsTotal: 2, + limitedNow: false, + }); + }); + + it('reports each new limit once through onRateLimited', async () => { + const { tokenManager, authService, fetcherService } = createMockServices(); + let now = Date.UTC(2026, 0, 1); + (fetcherService.fetch as any).mockResolvedValue(makeFetchResponse(429, {}, { 'retry-after': '90' })); + + const client = new CloudSessionApiClient(tokenManager, authService, fetcherService, () => now); + const reported: Array<{ callSite: string; retryAfterSec: number }> = []; + client.onRateLimited = (callSite, retryAfterSec) => reported.push({ callSite, retryAfterSec }); + + await client.createSession(1, 2, 'local-1'); + // A follow-up blocked by the same window must not report again. + now += 30_000; + await client.createSession(1, 2, 'local-2'); + + expect(reported).toEqual([{ callSite: 'createSession', retryAfterSec: 90 }]); + }); + + it('clamps an implausible retry-after to the maximum backoff', async () => { + const { tokenManager, authService, fetcherService } = createMockServices(); + const now = Date.UTC(2026, 0, 1); + (fetcherService.fetch as any).mockResolvedValue(makeFetchResponse(429, {}, { 'retry-after': '86400' })); + + const client = new CloudSessionApiClient(tokenManager, authService, fetcherService, () => now); + const reported: number[] = []; + client.onRateLimited = (_callSite, retryAfterSec) => reported.push(retryAfterSec); + + await client.getSession('sess-1'); + + expect(reported).toEqual([600]); + }); + }); }); diff --git a/extensions/copilot/src/extension/codeBlocks/node/codeBlockProcessor.ts b/extensions/copilot/src/extension/codeBlocks/node/codeBlockProcessor.ts index daec78f3059..cfbeb0acb13 100644 --- a/extensions/copilot/src/extension/codeBlocks/node/codeBlockProcessor.ts +++ b/extensions/copilot/src/extension/codeBlocks/node/codeBlockProcessor.ts @@ -127,6 +127,7 @@ export class CodeBlockTrackingChatResponseStream implements ChatResponseStream { warning = this.forward(this._wrapped.warning.bind(this._wrapped)); info = this.forward(this._wrapped.info.bind(this._wrapped)); hookProgress = this.forward(this._wrapped.hookProgress.bind(this._wrapped)); + voiceProgress = this.forward(this._wrapped.voiceProgress.bind(this._wrapped)); reference2 = this.forward(this._wrapped.reference2.bind(this._wrapped)); codeCitation = this.forward(this._wrapped.codeCitation.bind(this._wrapped)); anchor = this.forward(this._wrapped.anchor.bind(this._wrapped)); diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index 02e314bc338..2d0592ef1f1 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -840,8 +840,15 @@ export class CopilotLanguageModelWrapper extends Disposable { let thinkingActive = false; const finishCallback: FinishedCallback = async (_text, index, delta): Promise => { if (delta.thinking) { - // Show thinking progress for unencrypted thinking deltas - if (!isEncryptedThinkingDelta(delta.thinking)) { + if (isEncryptedThinkingDelta(delta.thinking)) { + if (options.includeEncryptedThinking) { + progress.report(new vscode.LanguageModelThinkingPart( + delta.thinking.text ?? '', + delta.thinking.id, + { encrypted_content: delta.thinking.encrypted }, + )); + } + } else { const text = delta.thinking.text ?? ''; progress.report(new vscode.LanguageModelThinkingPart(text, delta.thinking.id, delta.thinking.metadata)); thinkingActive = true; diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccessPrompt.tsx b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccessPrompt.tsx index 5d2720bf68f..bb8ee4c7d63 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccessPrompt.tsx +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccessPrompt.tsx @@ -20,6 +20,29 @@ export type Props = PromptElementProps<{ messages: Array; }>; +interface IThinkingGroup { + readonly id: string; + readonly text: string[]; + readonly metadata: Record; +} + +function groupThinkingParts(parts: readonly vscode.LanguageModelThinkingPart[]): IThinkingGroup[] { + const groups = new Map(); + for (const part of parts) { + if (!part.id) { + continue; + } + const previous = groups.get(part.id); + const text = Array.isArray(part.value) ? part.value : [part.value]; + groups.set(part.id, { + id: part.id, + text: [...previous?.text ?? [], ...text], + metadata: { ...previous?.metadata, ...part.metadata }, + }); + } + return [...groups.values()]; +} + export class LanguageModelAccessPrompt extends PromptElement { async render() { @@ -38,14 +61,17 @@ export class LanguageModelAccessPrompt extends PromptElement { const statefulMarkerPart = message.content.find(part => part instanceof vscode.LanguageModelDataPart && part.mimeType === CustomDataPartMimeTypes.StatefulMarker) as vscode.LanguageModelDataPart | undefined; const statefulMarker = statefulMarkerPart && decodeStatefulMarker(statefulMarkerPart.data); const filteredContent = message.content.filter(part => !(part instanceof vscode.LanguageModelDataPart)); - // There should only be one string part per message - const content = filteredContent.find(part => part instanceof LanguageModelTextPart); + const content = filteredContent.filter(part => part instanceof LanguageModelTextPart).map(part => part.value).join(''); const toolCalls = filteredContent.filter(part => part instanceof vscode.LanguageModelToolCallPart); - const thinking = filteredContent.find(part => part instanceof vscode.LanguageModelThinkingPart); + const thinkingParts = filteredContent.filter(part => part instanceof vscode.LanguageModelThinkingPart); + const thinkingGroups = groupThinkingParts(thinkingParts); const statefulMarkerElement = statefulMarker && ; - const thinkingElement = thinking && thinking.id && ; - chatMessages.push( ({ id: tc.callId, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.input) } }))}>{statefulMarkerElement}{content?.value}{thinkingElement}); + const thinkingElements = thinkingGroups.map(group => { + const encrypted = typeof group.metadata.encrypted_content === 'string' ? group.metadata.encrypted_content : undefined; + return ; + }); + chatMessages.push( ({ id: tc.callId, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.input) } }))}>{statefulMarkerElement}{content}{thinkingElements}); } else if (message.role === vscode.LanguageModelChatMessageRole.User) { for (const part of message.content) { if (part instanceof vscode.LanguageModelToolResultPart2 || part instanceof vscode.LanguageModelToolResultPart) { diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccessPrompt.spec.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccessPrompt.spec.ts new file mode 100644 index 00000000000..e38c529784c --- /dev/null +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccessPrompt.spec.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Raw } from '@vscode/prompt-tsx'; +import { describe, expect, test } from 'vitest'; +import { IChatMLFetcher } from '../../../../platform/chat/common/chatMLFetcher'; +import { StaticChatMLFetcher } from '../../../../platform/chat/test/common/staticChatMLFetcher'; +import { MockEndpoint } from '../../../../platform/endpoint/test/node/mockEndpoint'; +import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { LanguageModelChatMessageRole, LanguageModelTextPart, LanguageModelThinkingPart } from '../../../../vscodeTypes'; +import { createExtensionUnitTestingServices } from '../../../test/node/services'; +import { renderPromptElement } from '../../../prompts/node/base/promptRenderer'; +import { LanguageModelAccessPrompt } from '../languageModelAccessPrompt'; + +describe('LanguageModelAccessPrompt', () => { + test('preserves all assistant text and groups thinking by id', async () => { + const services = createExtensionUnitTestingServices(); + services.define(IChatMLFetcher, new StaticChatMLFetcher([])); + const accessor = services.createTestingAccessor(); + const endpoint = accessor.get(IInstantiationService).createInstance(MockEndpoint, 'gpt-5'); + const message = { + role: LanguageModelChatMessageRole.Assistant, + content: [ + new LanguageModelTextPart('first'), + new LanguageModelThinkingPart('a1', 'rs_a', { encrypted_content: 'opaque-a' }), + new LanguageModelThinkingPart('b', 'rs_b', { encrypted_content: 'opaque-b' }), + new LanguageModelThinkingPart('a2', 'rs_a'), + new LanguageModelTextPart('second'), + ], + name: undefined, + }; + + const { messages } = await renderPromptElement( + accessor.get(IInstantiationService), + endpoint, + LanguageModelAccessPrompt, + { noSafety: true, messages: [message] }, + ); + const assistant = messages.find(candidate => candidate.role === Raw.ChatRole.Assistant); + const text = assistant?.content + .filter(part => part.type === Raw.ChatCompletionContentPartKind.Text) + .map(part => part.text) + .join(''); + const thinking = assistant?.content + .filter(part => part.type === Raw.ChatCompletionContentPartKind.Opaque) + .map(part => part.value); + + expect({ text, thinking }).toEqual({ + text: 'firstsecond', + thinking: [ + { + type: 'thinking', + thinking: { + id: 'rs_a', + text: ['a1', 'a2'], + metadata: { encrypted_content: 'opaque-a' }, + encrypted: 'opaque-a', + }, + }, + { + type: 'thinking', + thinking: { + id: 'rs_b', + text: ['b'], + metadata: { encrypted_content: 'opaque-b' }, + encrypted: 'opaque-b', + }, + }, + ], + }); + }); +}); diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts index d38deaa1e2a..6b0b20e9e7a 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts @@ -701,7 +701,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider ({ ...val, isFromSpeculativeRequest: true })); + return firstEdit.map(val => ({ ...val, isFromSpeculativeRequest: true, baseCacheEntry: val.baseCacheEntry ?? val })); } return nextEditResult.nextEdit.isError() ? nextEditResult.nextEdit : requestToReuse.firstEdit.p; } else { diff --git a/extensions/copilot/src/extension/inlineEdits/test/common/userInteractionMonitor.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/common/userInteractionMonitor.spec.ts index 60f6990aace..54547474407 100644 --- a/extensions/copilot/src/extension/inlineEdits/test/common/userInteractionMonitor.spec.ts +++ b/extensions/copilot/src/extension/inlineEdits/test/common/userInteractionMonitor.spec.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { beforeEach, describe, expect, test } from 'vitest'; -import { ConfigKey } from '../../../../platform/configuration/common/configurationService'; +import { ConfigKey, ExperimentBasedConfig, ExperimentBasedConfigType } from '../../../../platform/configuration/common/configurationService'; import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService'; import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService'; -import { AggressivenessLevel, DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION, UserHappinessScoreConfiguration } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; +import { AggressivenessLevel, AggressivenessSetting, DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION, UserHappinessScoreConfiguration } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; import { ILogService } from '../../../../platform/log/common/logService'; import { IExperimentationService, NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService'; @@ -47,9 +47,22 @@ class TestUserInteractionMonitor extends UserInteractionMonitor { * Mock configuration service that allows setting specific config values for testing. */ class MockConfigurationService extends InMemoryConfigurationService { + private _useAdaptiveAggressiveness = false; + constructor() { super(new DefaultsOnlyConfigurationService()); } + + useAdaptiveAggressiveness(): void { + this._useAdaptiveAggressiveness = true; + } + + override getExperimentBasedConfig(key: ExperimentBasedConfig, experimentationService: IExperimentationService): T { + if (this._useAdaptiveAggressiveness && key === ConfigKey.TeamInternal.InlineEditsXtabAggressivenessLevel) { + return undefined as T; + } + return super.getExperimentBasedConfig(key, experimentationService); + } } interface TelemetryCall { @@ -197,13 +210,25 @@ describe('UserInteractionMonitor', () => { }); describe('aggressiveness level calculation', () => { - test('returns neutral aggressiveness with no history', () => { - // With no data, score is 0.5, which is between low and medium thresholds for the default config - const level = monitor.getAggressivenessLevel().aggressivenessLevel; - expect(level).toBe(AggressivenessLevel.Medium); + test('defaults to medium aggressiveness without using adaptive scoring', () => { + expect(monitor.getAggressivenessLevel()).toEqual({ + aggressivenessLevel: AggressivenessLevel.Medium, + userHappinessScore: undefined, + }); + }); + + test('explicit user eagerness takes priority over configured aggressiveness', () => { + configurationService.setConfig(ConfigKey.Advanced.InlineEditsAggressiveness, AggressivenessSetting.High); + configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabAggressivenessLevel, AggressivenessLevel.Low); + + expect(monitor.getAggressivenessLevel()).toEqual({ + aggressivenessLevel: AggressivenessLevel.High, + userHappinessScore: undefined, + }); }); test('returns high aggressiveness after many acceptances', () => { + configurationService.useAdaptiveAggressiveness(); // Fill with 10 acceptances for (let i = 0; i < 10; i++) { monitor.handleAcceptance(); @@ -214,6 +239,7 @@ describe('UserInteractionMonitor', () => { }); test('returns low aggressiveness after many rejections', () => { + configurationService.useAdaptiveAggressiveness(); // Fill with 10 rejections for (let i = 0; i < 10; i++) { monitor.handleRejection(); @@ -239,6 +265,7 @@ describe('UserInteractionMonitor', () => { }); test('recent actions have more weight than older ones', () => { + configurationService.useAdaptiveAggressiveness(); // Start with acceptances, end with rejections for (let i = 0; i < 5; i++) { monitor.handleAcceptance(); @@ -270,6 +297,7 @@ describe('UserInteractionMonitor', () => { describe('ignored action limiting', () => { test('ignored actions are included in aggressiveness calculation', () => { + configurationService.useAdaptiveAggressiveness(); // With custom config that includes ignored actions const customConfig: UserHappinessScoreConfiguration = { ...DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION, @@ -294,6 +322,7 @@ describe('UserInteractionMonitor', () => { }); test('total ignored limit is respected', () => { + configurationService.useAdaptiveAggressiveness(); const customConfig: UserHappinessScoreConfiguration = { ...DEFAULT_USER_HAPPINESS_SCORE_CONFIGURATION, includeIgnored: true, @@ -325,6 +354,7 @@ describe('UserInteractionMonitor', () => { let mockTelemetryService: MockTelemetryService; beforeEach(() => { + configurationService.useAdaptiveAggressiveness(); mockTelemetryService = new MockTelemetryService(); monitor = new TestUserInteractionMonitor(configurationService, experimentationService, logService, mockTelemetryService); }); diff --git a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditCacheRebase.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditCacheRebase.spec.ts index 0ad096870de..d0c8867b8dc 100644 --- a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditCacheRebase.spec.ts +++ b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditCacheRebase.spec.ts @@ -170,6 +170,7 @@ describe('NextEditCache rebase — Fibonacci scenario', () => { assert(cachedEdit !== undefined, 'setKthNextEdit should return the cached edit'); assert(cachedEdit.userEditSince !== undefined, 'userEditSince should be set'); + cachedEdit.wasRenderedAsInlineSuggestion = true; const rebaseResult = cache.tryRebaseCacheEntry( cachedEdit, @@ -180,6 +181,64 @@ describe('NextEditCache rebase — Fibonacci scenario', () => { assert(rebaseResult.edit !== undefined, 'should rebase successfully'); assert(rebaseResult.edit.rebasedEdit !== undefined, 'should have a rebased edit for the class body'); assert.strictEqual(rebaseResult.edit.modelTelemetry, testModelTelemetry, 'should preserve model attribution on the rebased edit'); + const baseCacheEntry = rebaseResult.edit.baseCacheEntry; + assert(baseCacheEntry, 'should reference the stable cache entry'); + assert.strictEqual(baseCacheEntry, cachedEdit); + assert.strictEqual(baseCacheEntry.wasRenderedAsInlineSuggestion, true, 'should preserve inline-rendered state on the stable cache entry'); + }); +}); + +describe('NextEditCache ghost-text presentation state', () => { + + const document = new StringText('const value = 1;\n'); + const docId = DocumentId.create(URI.file('/test/cache-presentation-state.ts').toString()); + + function makeSource(): NextEditFetchRequest { + const logContext = new InlineEditRequestLogContext('test', 0, undefined); + return new NextEditFetchRequest(generateUuid(), logContext, undefined, false); + } + + it('does not carry inline-rendered state to a replacement cache entry', () => { + const workspace = new MutableObservableWorkspace(); + workspace.addDocument({ id: docId, initialValue: document.value }); + const cache = new NextEditCache(workspace, new LogServiceImpl([]), new DefaultsOnlyConfigurationService(), new NullExperimentationService()); + + const first = cache.setKthNextEdit( + docId, + document, + undefined, + StringReplacement.insert(document.value.length, 'first'), + 0, + undefined, + undefined, + makeSource(), + { isFromCursorJump: false, modelTelemetry: testModelTelemetry }, + ); + assert(first); + first.wasRenderedAsInlineSuggestion = true; + + const replacement = cache.setKthNextEdit( + docId, + document, + undefined, + StringReplacement.insert(document.value.length, 'replacement'), + 0, + undefined, + undefined, + makeSource(), + { isFromCursorJump: false, modelTelemetry: testModelTelemetry }, + ); + const result = cache.lookupNextEdit(docId, document, [OffsetRange.emptyAt(document.value.length)]); + + assert.deepStrictEqual({ + isReplacementEntry: result === replacement, + newText: result?.edit?.newText, + wasRenderedAsInlineSuggestion: result?.wasRenderedAsInlineSuggestion, + }, { + isReplacementEntry: true, + newText: 'replacement', + wasRenderedAsInlineSuggestion: undefined, + }); }); }); diff --git a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderSpeculative.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderSpeculative.spec.ts index 6e72d67baa7..0a94294d73f 100644 --- a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderSpeculative.spec.ts +++ b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderSpeculative.spec.ts @@ -627,6 +627,46 @@ describe('NextEditProvider speculative requests', () => { await statelessProvider.calls[1].completed.p; }); + it('reused speculative request preserves inline-rendered state on the cached entry', async () => { + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On); + + const statelessProvider = new TestStatelessNextEditProvider(); + statelessProvider.enqueueBehavior({ kind: 'yieldEditThenNoSuggestions', edit: lineReplacement(1, 'const value = 2;') }); + const specContinue = new DeferredPromise(); + statelessProvider.enqueueBehavior({ kind: 'yieldEditThenWait', edit: lineReplacement(2, 'console.log(value + 1);'), continueSignal: specContinue }); + const { nextEditProvider, workspace } = createProviderAndWorkspace(statelessProvider); + + const doc = workspace.addDocument({ + id: DocumentId.create(URI.file('/test/spec-cache-entry-identity.ts').toString()), + initialValue: 'const value = 1;\nconsole.log(value);', + }); + doc.setSelection([new OffsetRange(0, 0)], undefined); + + const firstSuggestion = await getNextEdit(nextEditProvider, doc.id); + assert(firstSuggestion.result?.edit); + nextEditProvider.handleShown(firstSuggestion); + await statelessProvider.waitForCall(2); + nextEditProvider.handleAcceptance(doc.id, firstSuggestion); + doc.applyEdit(firstSuggestion.result.edit.toEdit()); + + const speculativeSuggestion = await getNextEdit(nextEditProvider, doc.id); + assert(speculativeSuggestion.result?.cacheEntry); + speculativeSuggestion.result.cacheEntry.wasRenderedAsInlineSuggestion = true; + + specContinue.complete(); + await statelessProvider.calls[1].completed.p; + + const cachedSuggestion = await getNextEdit(nextEditProvider, doc.id); + assert(cachedSuggestion.result?.cacheEntry); + expect({ + isSameCacheEntry: cachedSuggestion.result.cacheEntry === speculativeSuggestion.result.cacheEntry, + wasRenderedAsInlineSuggestion: cachedSuggestion.result.cacheEntry.wasRenderedAsInlineSuggestion, + }).toEqual({ + isSameCacheEntry: true, + wasRenderedAsInlineSuggestion: true, + }); + }); + it('skips cache delay for edits from speculative requests even when enforceCacheDelay is true', async () => { const CACHE_DELAY_MS = 5_000; await configService.setConfig(ConfigKey.TeamInternal.InlineEditsSpeculativeRequests, SpeculativeRequestsEnablement.On); diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index fbe2039c50e..ca068398332 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -35,12 +35,13 @@ import { DeferredPromise, timeout } from '../../../util/vs/base/common/async'; import { CancellationTokenSource } from '../../../util/vs/base/common/cancellation'; import { CancellationError, isCancellationError } from '../../../util/vs/base/common/errors'; import { Emitter } from '../../../util/vs/base/common/event'; +import { stringHash } from '../../../util/vs/base/common/hash'; import { Disposable, IDisposable } from '../../../util/vs/base/common/lifecycle'; import { Mutable } from '../../../util/vs/base/common/types'; import { URI } from '../../../util/vs/base/common/uri'; import { generateUuid } from '../../../util/vs/base/common/uuid'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { ChatResponsePullRequestPart, LanguageModelDataPart2, LanguageModelPartAudience, LanguageModelToolResult2, MarkdownString } from '../../../vscodeTypes'; +import { ChatResponsePullRequestPart, LanguageModelDataPart2, LanguageModelPartAudience, LanguageModelTextPart, LanguageModelToolResult2, MarkdownString } from '../../../vscodeTypes'; import { InteractionOutcomeComputer } from '../../inlineChat/node/promptCraftingTypes'; import { ChatVariablesCollection } from '../../prompt/common/chatVariablesCollection'; import { Conversation, IResultMetadata, ResponseStreamParticipant, TurnStatus, TurnTokenUsageMetadata } from '../../prompt/common/conversation'; @@ -52,7 +53,7 @@ import { PseudoStopStartResponseProcessor } from '../../prompt/node/pseudoStartS import { ResponseProcessorContext } from '../../prompt/node/responseProcessorContext'; import { SummarizedConversationHistoryMetadata } from '../../prompts/node/agent/summarizedConversationHistory'; import { ToolFailureEncountered, ToolResultMetadata } from '../../prompts/node/panel/toolCalling'; -import { ToolName } from '../../tools/common/toolNames'; +import { getToolName, ToolName } from '../../tools/common/toolNames'; import { IToolsService, ToolCallCancelledError } from '../../tools/common/toolsService'; import { ReadFileParams } from '../../tools/node/readFileTool'; import { isHookAbortError, processHookResults } from './hookResultProcessor'; @@ -86,6 +87,10 @@ export interface IToolCallingLoopOptions { * The current chat request */ request: ChatRequest; + /** + * Enables deterministic Voice Mode progress for the top-level Agent loop. + */ + enableVoiceProgress?: boolean; /** * A getter that returns true if VS Code has requested the extension to * gracefully yield. When set, it's likely that the editor will immediately @@ -145,6 +150,171 @@ interface SubagentStopHookResult { readonly reasons?: readonly string[]; } +type VoiceProgressPhase = 'investigating' | 'planning' | 'editing' | 'validating' | 'recovering'; + +interface VoiceProgressToolInput { + readonly stage: VoiceProgressPhase; + readonly summary: string; +} + +type VoiceProgressToolInputResult = { readonly input: VoiceProgressToolInput } | { readonly error: string }; + +const voiceProgressPhases = new Set(['investigating', 'planning', 'editing', 'validating', 'recovering']); +const voiceProgressSummaryMaxLength = 240; +const unsafeVoiceProgressSummaryPattern = /[`*_#\[\]<>]|(?:https?:\/\/|file:\/\/)|(?:^|\s)(?:\.{0,2}[\\/]|[A-Za-z]:\\)|\b[\w.-]+\/[\w./-]+\b|\b[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+\b|\b(?:gh[pousr]_|AKIA)[A-Za-z0-9_-]+|\b[0-9a-f]{8}-[0-9a-f-]{27,}\b|\b[0-9a-f]{32,}\b/i; + +const editingToolNames = new Set([ + ToolName.ApplyPatch, + ToolName.CreateDirectory, + ToolName.CreateFile, + ToolName.CreateNewJupyterNotebook, + ToolName.EditFile, + ToolName.EditNotebook, + ToolName.MultiReplaceString, + ToolName.ReplaceString, +]); + +const validationToolNames = new Set([ + ToolName.CoreCreateAndRunTask, + ToolName.CoreRunTask, + ToolName.CoreRunTest, + ToolName.GetErrors, + ToolName.RunNotebookCell, +]); + +const investigatingToolNames = new Set([ + ToolName.Codebase, + ToolName.VSCodeAPI, + ToolName.FindFiles, + ToolName.FindTextInFiles, + ToolName.ReadFile, + ToolName.ViewImage, + ToolName.ListDirectory, + ToolName.GetScmChanges, + ToolName.ReadProjectStructure, + ToolName.SearchWorkspaceSymbols, + ToolName.GetNotebookSummary, + ToolName.ReadCellOutput, + ToolName.FetchWebPage, + ToolName.FindTestFiles, + ToolName.GithubSemanticRepoSearch, + ToolName.GithubTextSearch, + ToolName.SearchSubagent, + ToolName.ExploreSubagent, + ToolName.CoreRunSubagent, + ToolName.ToolSearch, + ToolName.CoreReadPage, + ToolName.CoreScreenshotPage, +]); + +const planningToolNames = new Set([ + ToolName.CoreManageTodoList, + ToolName.CoreReviewPlan, + ToolName.CoreAskQuestions, +]); + +function isEditingTool(name: string): boolean { + return editingToolNames.has(getToolName(name)); +} + +function isInvestigatingTool(name: string): boolean { + const toolName = getToolName(name); + return investigatingToolNames.has(toolName) || /(?:^|_)(?:explore|find|grep|inspect|list|read|search)(?:_|$)/i.test(toolName); +} + +function isPlanningTool(name: string): boolean { + const toolName = getToolName(name); + return planningToolNames.has(toolName) || /(?:askQuestions|artifact|plan|todo)/i.test(toolName); +} + +function isValidationToolCall(call: IToolCall): boolean { + const name = getToolName(call.name); + if (validationToolNames.has(name)) { + return true; + } + return name === ToolName.CoreRunInTerminal && /\b(?:build|check|compile|lint|test|typecheck)\b/i.test(call.arguments); +} + +function isVoiceProgressPhase(value: string): value is VoiceProgressPhase { + return voiceProgressPhases.has(value as VoiceProgressPhase); +} + +function parseVoiceProgressToolInput(argumentsJson: string): VoiceProgressToolInputResult { + let value: unknown; + try { + value = JSON.parse(argumentsJson); + } catch { + return { error: 'the input must be valid JSON' }; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return { error: 'the input must be an object' }; + } + const input = value as Record; + if (Object.keys(input).some(key => key !== 'stage' && key !== 'summary')) { + return { error: 'only stage and summary are allowed' }; + } + if (typeof input.stage !== 'string' || !isVoiceProgressPhase(input.stage)) { + return { error: 'stage must be investigating, planning, editing, validating, or recovering' }; + } + if (typeof input.summary !== 'string') { + return { error: 'summary must be a string' }; + } + const summary = input.summary.replace(/\s+/g, ' ').trim(); + if (!summary) { + return { error: 'summary must not be empty' }; + } + if (summary.length > voiceProgressSummaryMaxLength) { + return { error: `summary must be at most ${voiceProgressSummaryMaxLength} characters` }; + } + if (unsafeVoiceProgressSummaryPattern.test(summary)) { + return { error: 'summary must use plain speech without markdown, paths, commands, identifiers, URLs, or secrets' }; + } + return { input: { stage: input.stage, summary } }; +} + +function getVoiceProgressMessage(phase: VoiceProgressPhase, requestId: string): string { + let variants: readonly string[]; + switch (phase) { + case 'investigating': + variants = [ + l10n.t("I'm tracing the relevant code now."), + l10n.t("I'm looking through the code to find the right path."), + l10n.t("I'm investigating how this fits together."), + ]; + break; + case 'planning': + variants = [ + l10n.t("I've got the context. I'm working out the approach."), + l10n.t("I'm mapping out the cleanest change now."), + l10n.t("I've found the path. I'm planning the update."), + ]; + break; + case 'editing': + variants = [ + l10n.t("Found the spot. I'm making the change now."), + l10n.t("There it is. I'm updating the code."), + l10n.t("I've got the change point. Making the edit now."), + ]; + break; + case 'validating': + variants = [ + l10n.t("Nice, that's in. I'm checking it now."), + l10n.t("The update's ready. I'm putting it through its checks."), + l10n.t("Good progress. I'm verifying everything now."), + ]; + break; + case 'recovering': + variants = [ + l10n.t("That hit a snag. I'm switching approaches."), + l10n.t("Small detour. I'm trying a better route."), + l10n.t("Not quite. I've got another angle to try."), + ]; + break; + } + const index = (stringHash(`${requestId}:${phase}`, 0) >>> 0) % variants.length; + return variants[index]; +} + /** * Formats a hook context message from blocking reasons. * @param reasons The reasons hooks blocked the agent from stopping @@ -168,6 +338,7 @@ export abstract class ToolCallingLoop = Object.create(null); private toolCallRounds: IToolCallRound[] = []; @@ -179,6 +350,7 @@ export abstract class ToolCallingLoop(); /** * Running total of Copilot credits across every model call in the current @@ -654,6 +826,134 @@ export abstract class ToolCallingLoop isEditingTool(call.name)); + const validationFailed = round.toolCalls.some(call => getToolName(call.name) === ToolName.CoreTestFailure); + if (validationFailed || (hasEditingTool && this.reportedVoiceProgress.has('validating'))) { + return 'recovering'; + } + if (!this.reportedVoiceProgress.has('editing') && hasEditingTool) { + return 'editing'; + } + if (round.toolCalls.some(isValidationToolCall)) { + return 'validating'; + } + if (round.toolCalls.some(call => isPlanningTool(call.name))) { + return 'planning'; + } + if (round.toolCalls.some(call => isInvestigatingTool(call.name))) { + return 'investigating'; + } + return undefined; + } + + protected reportVoiceProgressForRound(outputStream: ChatResponseStream | undefined, round: IToolCallRound): void { + const phase = this.getVoiceProgressFallbackPhase(round); + if (!phase) { + return; + } + const emitted = this.reportVoiceProgress(outputStream, phase); + this._logService.info(`[VoiceProgress] fallback request=${this.options.request.id} phase=${phase} emitted=${emitted} tools=${round.toolCalls.map(call => getToolName(call.name)).join(',')}`); + } + + protected ensureVoiceProgressTool(availableTools: LanguageModelToolInformation[]): LanguageModelToolInformation[] { + if (!this.isVoiceProgressEnabled() || availableTools.some(tool => tool.name === ToolCallingLoop.VOICE_PROGRESS_TOOL_NAME)) { + return availableTools; + } + this._logService.info(`[VoiceProgress] injected tool request=${this.options.request.id} availableTools=${availableTools.length + 1}`); + return [...availableTools, { + name: ToolCallingLoop.VOICE_PROGRESS_TOOL_NAME, + description: 'Report one concise factual spoken progress update to the user at a meaningful stage change. Call this in parallel with actual work when possible. Do not use it for acknowledgements, questions, confirmations, or the final response.', + inputSchema: { + type: 'object', + properties: { + stage: { + type: 'string', + enum: ['investigating', 'planning', 'editing', 'validating', 'recovering'], + description: 'The current work stage.', + }, + summary: { + type: 'string', + minLength: 1, + maxLength: voiceProgressSummaryMaxLength, + description: 'A concise user-facing factual update in plain speech, without markdown, paths, commands, identifiers, secrets, reasoning, or raw source and tool output.', + }, + }, + required: ['stage', 'summary'], + additionalProperties: false, + }, + tags: [], + source: undefined, + }]; + } + + protected processVoiceProgressToolCalls(outputStream: ChatResponseStream | undefined, toolCalls: readonly IToolCall[]): void { + if (!this.isVoiceProgressEnabled()) { + return; + } + for (const toolCall of toolCalls) { + if (toolCall.name !== ToolCallingLoop.VOICE_PROGRESS_TOOL_NAME) { + continue; + } + const parsed = parseVoiceProgressToolInput(toolCall.arguments); + let resultMessage: string; + if ('error' in parsed) { + resultMessage = `Voice progress was not reported because ${parsed.error}.`; + } else if (this.reportVoiceProgress(outputStream, parsed.input.stage, parsed.input.summary)) { + resultMessage = 'Voice progress reported.'; + } else { + resultMessage = `Voice progress for ${parsed.input.stage} was already reported.`; + } + this.toolCallResults[toolCall.id] = new LanguageModelToolResult2([new LanguageModelTextPart(resultMessage)]); + this._logService.info(`[VoiceProgress] processed model tool request=${this.options.request.id} call=${toolCall.id} valid=${!('error' in parsed)}`); + } + } + + protected hasProductiveToolCalls(round: IToolCallRound): boolean { + return round.toolCalls.some(toolCall => + toolCall.name !== ToolCallingLoop.TASK_COMPLETE_TOOL_NAME + && toolCall.name !== ToolCallingLoop.VOICE_PROGRESS_TOOL_NAME + ); + } + + protected getPersistableToolCallingState(): { toolCallRounds: IToolCallRound[]; toolCallResults: Record } { + const toolCallRounds: IToolCallRound[] = []; + const toolCallResults: Record = {}; + for (const round of this.toolCallRounds) { + const persistableRound = this.withoutVoiceProgressToolCalls(round); + if (!persistableRound.toolCalls.length && !persistableRound.response && !persistableRound.thinking && !persistableRound.statefulMarker && !persistableRound.compaction && !persistableRound.hookContext) { + continue; + } + toolCallRounds.push(persistableRound); + for (const toolCall of persistableRound.toolCalls) { + const result = this.toolCallResults[toolCall.id]; + if (result) { + toolCallResults[toolCall.id] = result; + } + } + } + return { toolCallRounds, toolCallResults }; + } + + private withoutVoiceProgressToolCalls(round: IToolCallRound): IToolCallRound { + const toolCalls = round.toolCalls.filter(toolCall => toolCall.name !== ToolCallingLoop.VOICE_PROGRESS_TOOL_NAME); + return toolCalls.length === round.toolCalls.length ? round : { ...round, toolCalls }; + } + /** * Ensures the `task_complete` tool is present in the available tools when running in * autopilot mode. If it's missing (e.g. filtered out by the tool picker), it's resolved @@ -1133,6 +1433,8 @@ export abstract class ToolCallingLoop= this.options.toolCallLimit) { @@ -1162,6 +1464,7 @@ export abstract class ToolCallingLoop tc.name === ToolCallingLoop.TASK_COMPLETE_TOOL_NAME)) { + if (this.autopilotStopHookActive && this.hasProductiveToolCalls(result.round)) { this.autopilotStopHookActive = false; this.autopilotIterationCount = 0; } @@ -1191,6 +1494,7 @@ export abstract class ToolCallingLoop { this.stopHookUserInitiated = false; }); + this.processVoiceProgressToolCalls(outputStream, toolCalls); markChatExt(this.options.conversation.sessionId, ChatExtPerfMark.DidFetch); // Store the server-echoed headerRequestId from the fetch response for subagent telemetry linking. @@ -1711,12 +2027,14 @@ export abstract class ToolCallingLoop ({ + const transcriptToolRequests: ToolRequest[] = toolCalls + .filter(toolCall => toolCall.name !== ToolCallingLoop.VOICE_PROGRESS_TOOL_NAME) + .map(tc => ({ toolCallId: tc.id, name: tc.name, arguments: tc.arguments, type: 'function' as const, - })); + })); this._sessionTranscriptService.logAssistantMessage( this.options.conversation.sessionId, fetchResult.value, diff --git a/extensions/copilot/src/extension/intents/test/node/toolCallingLoopAutopilot.spec.ts b/extensions/copilot/src/extension/intents/test/node/toolCallingLoopAutopilot.spec.ts index 7777b5abef5..ff6a3b763f4 100644 --- a/extensions/copilot/src/extension/intents/test/node/toolCallingLoopAutopilot.spec.ts +++ b/extensions/copilot/src/extension/intents/test/node/toolCallingLoopAutopilot.spec.ts @@ -4,19 +4,22 @@ *--------------------------------------------------------------------------------------------*/ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ChatRequest, LanguageModelToolInformation } from 'vscode'; +import type { ChatRequest, ChatResponseStream, LanguageModelToolInformation } from 'vscode'; import { IChatHookService } from '../../../../platform/chat/common/chatHookService'; import { ChatFetchResponseType, ChatResponse } from '../../../../platform/chat/common/commonTypes'; +import { SpyChatResponseStream } from '../../../../util/common/test/mockChatResponseStream'; import { CancellationToken, CancellationTokenSource } from '../../../../util/vs/base/common/cancellation'; import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; import { generateUuid } from '../../../../util/vs/base/common/uuid'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { Conversation, Turn } from '../../../prompt/common/conversation'; -import { IBuildPromptContext, IToolCallRound } from '../../../prompt/common/intents'; +import { IBuildPromptContext, IToolCall, IToolCallRound } from '../../../prompt/common/intents'; import { IBuildPromptResult, nullRenderPromptResult } from '../../../prompt/node/intents'; import { createExtensionUnitTestingServices } from '../../../test/node/services'; +import { ToolName } from '../../../tools/common/toolNames'; import { IToolsService } from '../../../tools/common/toolsService'; import { TestToolsService } from '../../../tools/node/test/testToolsService'; +import { LanguageModelTextPart, LanguageModelToolResult2 } from '../../../../vscodeTypes'; import { IToolCallingLoopOptions, IToolCallSingleResult, ToolCallingLoop } from '../../node/toolCallingLoop'; import { MockChatHookService } from './mockChatHookService'; @@ -69,6 +72,34 @@ class AutopilotTestToolCallingLoop extends ToolCallingLoop } { + return this.getPersistableToolCallingState(); + } + + public testHasProductiveToolCalls(round: IToolCallRound): boolean { + return this.hasProductiveToolCalls(round); + } } function createMockChatRequest(overrides: Partial = {}): ChatRequest { @@ -102,7 +133,7 @@ function createTestConversation(turnCount: number = 1): Conversation { return new Conversation(generateUuid(), turns); } -function createMockRound(toolCallNames: string[] = [], response: string = ''): IToolCallRound { +function createMockRound(toolCallNames: string[] = [], response: string = '', toolArguments = '{}'): IToolCallRound { return { id: generateUuid(), response, @@ -110,7 +141,7 @@ function createMockRound(toolCallNames: string[] = [], response: string = ''): I toolCalls: toolCallNames.map(name => ({ id: generateUuid(), name, - arguments: '{}', + arguments: toolArguments, })), }; } @@ -150,7 +181,7 @@ describe('ToolCallingLoop autopilot', () => { vi.restoreAllMocks(); }); - function createLoop(permissionLevel?: string, requestOverrides: Partial = {}): AutopilotTestToolCallingLoop { + function createLoop(permissionLevel?: string, requestOverrides: Partial = {}, enableVoiceProgress = true): AutopilotTestToolCallingLoop { const conversation = createTestConversation(1); const request = createMockChatRequest({ permissionLevel, @@ -162,12 +193,225 @@ describe('ToolCallingLoop autopilot', () => { conversation, toolCallLimit: 10, request, + enableVoiceProgress, } ); disposables.add(loop); return loop; } + describe('voice progress', () => { + it('classifies read and planning tools as semantic progress', () => { + const loop = createLoop(undefined, { id: 'voice-request', isVoiceModeInput: true }); + const stream = new SpyChatResponseStream(); + + loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.ReadFile])); + loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.CoreManageTodoList])); + + expect(stream.items).toEqual([ + expect.objectContaining({ id: 'investigating' }), + expect.objectContaining({ id: 'planning' }), + ]); + }); + + it('selects phrase variants deterministically per request', () => { + const firstLoop = createLoop(undefined, { id: 'stable-request', isVoiceModeInput: true }); + const secondLoop = createLoop(undefined, { id: 'stable-request', isVoiceModeInput: true }); + const firstStream = new SpyChatResponseStream(); + const secondStream = new SpyChatResponseStream(); + + firstLoop.testReportVoiceProgress(firstStream, 'editing'); + secondLoop.testReportVoiceProgress(secondStream, 'editing'); + + expect(firstStream.items).toEqual(secondStream.items); + }); + + it('does not emit for typed or subagent requests', () => { + const typedLoop = createLoop(); + const subagentLoop = createLoop(undefined, { isVoiceModeInput: true, subAgentInvocationId: 'subagent' }); + const disabledLoop = createLoop(undefined, { isVoiceModeInput: true }, false); + const stream = new SpyChatResponseStream(); + + typedLoop.testReportVoiceProgress(stream, 'editing'); + subagentLoop.testReportVoiceProgress(stream, 'editing'); + disabledLoop.testReportVoiceProgress(stream, 'editing'); + + expect(stream.items).toEqual([]); + }); + + it('emits each significant phase once in order', () => { + const loop = createLoop(undefined, { isVoiceModeInput: true }); + const stream = new SpyChatResponseStream(); + + loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.ReadFile])); + loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.CoreManageTodoList])); + loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.EditFile])); + loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.CoreRunInTerminal], '', '{"command":"npm test"}')); + loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.EditFile])); + + expect(stream.items).toEqual([ + expect.objectContaining({ id: 'investigating' }), + expect.objectContaining({ id: 'planning' }), + expect.objectContaining({ id: 'editing' }), + expect.objectContaining({ id: 'validating' }), + expect.objectContaining({ id: 'recovering' }), + ]); + }); + + it('offers the progress tool only to a top-level voice Agent loop', () => { + const voiceLoop = createLoop(undefined, { isVoiceModeInput: true }); + const typedLoop = createLoop(); + const subagentLoop = createLoop(undefined, { isVoiceModeInput: true, subAgentInvocationId: 'subagent' }); + const nonAgentLoop = createLoop(undefined, { isVoiceModeInput: true }, false); + + expect({ + voiceTools: voiceLoop.testEnsureVoiceProgressTool([]).map(tool => ({ + name: tool.name, + inputSchema: tool.inputSchema, + })), + typedTools: typedLoop.testEnsureVoiceProgressTool([]), + subagentTools: subagentLoop.testEnsureVoiceProgressTool([]), + nonAgentTools: nonAgentLoop.testEnsureVoiceProgressTool([]), + }).toEqual({ + voiceTools: [{ + name: 'report_voice_progress', + inputSchema: expect.objectContaining({ + required: ['stage', 'summary'], + additionalProperties: false, + }), + }], + typedTools: [], + subagentTools: [], + nonAgentTools: [], + }); + }); + + it('turns a valid model progress call into a hidden progress part and local result', () => { + const loop = createLoop(undefined, { isVoiceModeInput: true }); + const stream = new SpyChatResponseStream(); + + loop.testProcessVoiceProgressToolCalls(stream, [{ + id: 'progress-call', + name: 'report_voice_progress', + arguments: JSON.stringify({ + stage: 'investigating', + summary: 'I am tracing the request flow now.', + }), + }]); + + const result = loop.testGetToolCallResult('progress-call'); + expect({ + streamItems: stream.items, + resultText: result?.content[0] instanceof LanguageModelTextPart ? result.content[0].value : undefined, + }).toEqual({ + streamItems: [expect.objectContaining({ + id: 'investigating', + value: 'I am tracing the request flow now.', + })], + resultText: 'Voice progress reported.', + }); + }); + + it('rejects unsafe or out-of-bounds model progress summaries', () => { + const loop = createLoop(undefined, { isVoiceModeInput: true }); + const stream = new SpyChatResponseStream(); + const calls = [ + { id: 'bad-stage', stage: 'done', summary: 'Finished.' }, + { id: 'too-long', stage: 'editing', summary: 'x'.repeat(241) }, + { id: 'markdown', stage: 'editing', summary: 'Editing **src/secret.ts** now.' }, + ]; + + loop.testProcessVoiceProgressToolCalls(stream, calls.map(call => ({ + id: call.id, + name: 'report_voice_progress', + arguments: JSON.stringify({ stage: call.stage, summary: call.summary }), + }))); + + expect({ + streamItems: stream.items, + results: calls.map(call => { + const result = loop.testGetToolCallResult(call.id); + return result?.content[0] instanceof LanguageModelTextPart ? result.content[0].value : undefined; + }), + }).toEqual({ + streamItems: [], + results: [ + 'Voice progress was not reported because stage must be investigating, planning, editing, validating, or recovering.', + 'Voice progress was not reported because summary must be at most 240 characters.', + 'Voice progress was not reported because summary must use plain speech without markdown, paths, commands, identifiers, URLs, or secrets.', + ], + }); + }); + + it('model progress suppresses the same-stage deterministic fallback', () => { + const loop = createLoop(undefined, { isVoiceModeInput: true }); + const stream = new SpyChatResponseStream(); + + loop.testProcessVoiceProgressToolCalls(stream, [{ + id: 'editing-progress', + name: 'report_voice_progress', + arguments: '{"stage":"editing","summary":"I found the change point and I am updating it."}', + }]); + loop.testReportVoiceProgressForRound(stream, createMockRound([ToolName.EditFile])); + + expect(stream.items).toEqual([expect.objectContaining({ + id: 'editing', + value: 'I found the change point and I am updating it.', + })]); + }); + + it('removes the internal progress call and summary from persisted tool history', () => { + const loop = createLoop(undefined, { isVoiceModeInput: true }); + const stream = new SpyChatResponseStream(); + const progressCall: IToolCall = { + id: 'progress-call', + name: 'report_voice_progress', + arguments: '{"stage":"editing","summary":"I found the change point."}', + }; + loop.testProcessVoiceProgressToolCalls(stream, [progressCall]); + loop.addToolCallRound({ + id: 'mixed-round', + response: '', + toolInputRetry: 0, + toolCalls: [ + progressCall, + { id: 'edit-call', name: ToolName.EditFile, arguments: '{}' }, + ], + }); + + const state = loop.testGetPersistableToolCallingState(); + expect({ + state, + leaksProgress: JSON.stringify(state).includes('report_voice_progress') || JSON.stringify(state).includes('I found the change point.'), + }).toEqual({ + state: { + toolCallRounds: [{ + id: 'mixed-round', + response: '', + toolInputRetry: 0, + toolCalls: [{ id: 'edit-call', name: ToolName.EditFile, arguments: '{}' }], + }], + toolCallResults: {}, + }, + leaksProgress: false, + }); + }); + + it('does not treat voice progress as productive autopilot work', () => { + const loop = createLoop('autopilot', { isVoiceModeInput: true }); + + expect({ + progressOnly: loop.testHasProductiveToolCalls(createMockRound(['report_voice_progress'])), + taskCompleteOnly: loop.testHasProductiveToolCalls(createMockRound(['task_complete'])), + progressAndEdit: loop.testHasProductiveToolCalls(createMockRound(['report_voice_progress', ToolName.EditFile])), + }).toEqual({ + progressOnly: false, + taskCompleteOnly: false, + progressAndEdit: true, + }); + }); + }); + describe('shouldAutopilotContinue', () => { it('should return a nudge message when task_complete was not called', async () => { const loop = createLoop('autopilot'); diff --git a/extensions/copilot/src/extension/linkify/common/responseStreamWithLinkification.ts b/extensions/copilot/src/extension/linkify/common/responseStreamWithLinkification.ts index 5c8b96734c4..e951f144b55 100644 --- a/extensions/copilot/src/extension/linkify/common/responseStreamWithLinkification.ts +++ b/extensions/copilot/src/extension/linkify/common/responseStreamWithLinkification.ts @@ -2,7 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { ChatQuestion, ChatResponseClearToPreviousToolInvocationReason, ChatResponseFileTree, ChatResponsePart, ChatResponseStream, ChatResultUsage, ChatToolInvocationStreamData, ChatVulnerability, ChatWorkspaceFileEdit, Command, Location, NotebookEdit, TextEdit, ThinkingDelta, Uri } from 'vscode'; +import type { ChatQuestion, ChatResponseClearToPreviousToolInvocationReason, ChatResponseFileTree, ChatResponsePart, ChatResponseStream, ChatResponseVoiceProgressStage, ChatResultUsage, ChatToolInvocationStreamData, ChatVulnerability, ChatWorkspaceFileEdit, Command, Location, NotebookEdit, TextEdit, ThinkingDelta, Uri } from 'vscode'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { FinalizableChatResponseStream } from '../../../util/common/chatResponseStreamImpl'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; @@ -89,6 +89,10 @@ export class ResponseStreamWithLinkification implements FinalizableChatResponseS return this; } + voiceProgress(id: ChatResponseVoiceProgressStage, value: string): ChatResponseStream { + this.enqueue(() => this._progress.voiceProgress(id, value), false); + return this; + } reference(value: Uri | Location): ChatResponseStream { this.enqueue(() => this._progress.reference(value), false); diff --git a/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts b/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts index b3a2933a397..0e1b673b291 100644 --- a/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts +++ b/extensions/copilot/src/extension/prompt/node/defaultIntentRequestHandler.ts @@ -40,6 +40,7 @@ import { assertType, Mutable } from '../../../util/vs/base/common/types'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { ChatResponseMarkdownPart, ChatResponseProgressPart, ChatResponseTextEditPart, LanguageModelToolResult2 } from '../../../vscodeTypes'; import { CodeBlocksMetadata, CodeBlockTrackingChatResponseStream } from '../../codeBlocks/node/codeBlockProcessor'; +import { Intent } from '../../common/constants'; import { CopilotInteractiveEditorResponse, InteractionOutcomeComputer } from '../../inlineChat/node/promptCraftingTypes'; import { formatHookErrorMessage, HookAbortError, isHookAbortError, processHookResults } from '../../intents/node/hookResultProcessor'; import { EmptyPromptError, IToolCallingBuiltPromptEvent, IToolCallingLoopOptions, IToolCallingResponseEvent, IToolCallLoopResult, ToolCallingLoop, ToolCallingLoopFetchOptions, ToolCallLimitBehavior } from '../../intents/node/toolCallingLoop'; @@ -332,6 +333,7 @@ export class DefaultIntentRequestHandler { onHitToolCallLimit: this.handlerOptions.confirmOnMaxToolIterations !== false ? ToolCallLimitBehavior.Confirm : ToolCallLimitBehavior.Stop, request: this.request, + enableVoiceProgress: this.intent.id === Intent.Agent, documentContext: this.documentContext, streamParticipants: this.makeResponseStreamParticipants(intentInvocation), temperature: this.handlerOptions.temperature ?? this.options.temperature, diff --git a/extensions/copilot/src/extension/prompt/node/pseudoStartStopConversationCallback.ts b/extensions/copilot/src/extension/prompt/node/pseudoStartStopConversationCallback.ts index 48a57cbba6b..74955ad9da7 100644 --- a/extensions/copilot/src/extension/prompt/node/pseudoStartStopConversationCallback.ts +++ b/extensions/copilot/src/extension/prompt/node/pseudoStartStopConversationCallback.ts @@ -39,7 +39,7 @@ export class PseudoStopStartResponseProcessor implements IResponseProcessor { constructor( private readonly stopStartMappings: readonly StartStopMapping[], private readonly processNonReportedDelta: ((deltas: IResponseDelta[]) => string[]) | undefined, - private readonly options?: { subagentInvocationId?: string } + private readonly options?: { subagentInvocationId?: string; hiddenToolNames?: ReadonlySet } ) { } async processResponse(_context: IResponseProcessorContext, inputStream: AsyncIterable, outputStream: ChatResponseStream, token: CancellationToken): Promise { @@ -98,6 +98,9 @@ export class PseudoStopStartResponseProcessor implements IResponseProcessor { if (delta.beginToolCalls?.length) { for (const beginCall of delta.beginToolCalls) { + if (this.options?.hiddenToolNames?.has(beginCall.name)) { + continue; + } progress.beginToolInvocation(beginCall.id ?? '', getContributedToolName(beginCall.name), { subagentInvocationId: this.options?.subagentInvocationId }); } } @@ -105,7 +108,7 @@ export class PseudoStopStartResponseProcessor implements IResponseProcessor { if (delta.copilotToolCallStreamUpdates?.length) { const now = Date.now(); for (const update of delta.copilotToolCallStreamUpdates) { - if (!update.name) { + if (!update.name || this.options?.hiddenToolNames?.has(update.name)) { continue; } const toolId = update.id ?? ''; diff --git a/extensions/copilot/src/extension/prompt/node/test/defaultIntentRequestHandler.spec.ts b/extensions/copilot/src/extension/prompt/node/test/defaultIntentRequestHandler.spec.ts index 1d1131a3e39..54bc2da7331 100644 --- a/extensions/copilot/src/extension/prompt/node/test/defaultIntentRequestHandler.spec.ts +++ b/extensions/copilot/src/extension/prompt/node/test/defaultIntentRequestHandler.spec.ts @@ -6,7 +6,7 @@ import { Raw, RenderPromptResult } from '@vscode/prompt-tsx'; import { afterEach, beforeEach, expect, suite, test, vi } from 'vitest'; -import type { ChatLanguageModelToolReference, ChatPromptReference, ChatRequest, ExtendedChatResponsePart, LanguageModelChat } from 'vscode'; +import type { ChatLanguageModelToolReference, ChatPromptReference, ChatRequest, ExtendedChatResponsePart, LanguageModelChat, LanguageModelToolInformation } from 'vscode'; import { IChatMLFetcher } from '../../../../platform/chat/common/chatMLFetcher'; import { toTextPart } from '../../../../platform/chat/common/globalStringUtils'; import { StaticChatMLFetcher } from '../../../../platform/chat/test/common/staticChatMLFetcher'; @@ -25,10 +25,12 @@ import { isObject, isUndefinedOrNull } from '../../../../util/vs/base/common/typ import { generateUuid } from '../../../../util/vs/base/common/uuid'; import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; -import { ChatLocation, ChatResponseConfirmationPart, ChatResponseMarkdownPart, LanguageModelTextPart, LanguageModelToolResult, Uri } from '../../../../vscodeTypes'; +import { ChatLocation, ChatResponseConfirmationPart, ChatResponseMarkdownPart, ChatResponseVoiceProgressPart, LanguageModelTextPart, LanguageModelToolResult, Uri } from '../../../../vscodeTypes'; +import { Intent } from '../../../common/constants'; import { ToolCallingLoop } from '../../../intents/node/toolCallingLoop'; import { ToolResultMetadata } from '../../../prompts/node/panel/toolCalling'; import { createExtensionUnitTestingServices } from '../../../test/node/services'; +import { ToolName } from '../../../tools/common/toolNames'; import { Conversation, Turn } from '../../common/conversation'; import { IBuildPromptContext } from '../../common/intents'; import { ToolCallRound } from '../../common/toolCallRound'; @@ -46,6 +48,7 @@ suite('defaultIntentRequestHandler', () => { let endpoint: IChatEndpoint; let turnIdCounter = 0; let builtPrompts: IBuildPromptContext[] = []; + let availableTools: LanguageModelToolInformation[] = []; const sessionId = 'some-session-id'; const getTurnId = () => `turn-id-${turnIdCounter}`; @@ -62,6 +65,7 @@ suite('defaultIntentRequestHandler', () => { accessor = services.createTestingAccessor(); endpoint = accessor.get(IInstantiationService).createInstance(MockEndpoint, undefined); builtPrompts = []; + availableTools = []; response = []; promptResult = nullRenderPromptResult(); turnIdCounter = 0; @@ -89,7 +93,7 @@ suite('defaultIntentRequestHandler', () => { } class TestIntent implements IIntent { - id = 'test'; + constructor(readonly id: string = 'test') { } description = 'test intent'; locations = [ChatLocation.Panel]; invoke(): Promise { @@ -118,6 +122,10 @@ suite('defaultIntentRequestHandler', () => { return promptResult; } + + async getAvailableTools(): Promise { + return availableTools; + } } class TestChatRequest implements ChatRequest { @@ -127,6 +135,7 @@ suite('defaultIntentRequestHandler', () => { attempt = 1; enableCommandDetection = false; isParticipantDetected = false; + isVoiceModeInput?: boolean; location = ChatLocation.Panel; location2 = undefined; prompt = 'hello world!'; @@ -146,8 +155,9 @@ suite('defaultIntentRequestHandler', () => { const makeHandler = ({ request = new TestChatRequest(), - turns = [] - }: { request?: ChatRequest; turns?: Turn[] } = {}) => { + turns = [], + intent = new TestIntent(), + }: { request?: ChatRequest; turns?: Turn[]; intent?: IIntent } = {}) => { turns.push(new Turn( getTurnId(), { type: 'user', message: request.prompt }, @@ -157,7 +167,7 @@ suite('defaultIntentRequestHandler', () => { const instaService = accessor.get(IInstantiationService); return instaService.createInstance( DefaultIntentRequestHandler, - new TestIntent(), + intent, new Conversation(sessionId, turns), request, responseStream, @@ -305,6 +315,47 @@ suite('defaultIntentRequestHandler', () => { ]); }); + test('voice editAgent emits investigating fallback through the actual handler', async () => { + const request = new TestChatRequest(); + request.isVoiceModeInput = true; + availableTools = [{ + name: ToolName.ReadFile, + description: 'Read a file.', + inputSchema: { type: 'object' }, + tags: [], + source: undefined, + }]; + const requestSpy = vi.spyOn(endpoint, 'makeChatRequest2'); + const handler = makeHandler({ request, intent: new TestIntent(Intent.Agent) }); + chatResponse[0] = [{ + text: '', + copilotToolCalls: [{ + arguments: '{}', + name: ToolName.ReadFile, + id: 'read_call', + }], + }]; + chatResponse[1] = 'done'; + const toolResult = new LanguageModelToolResult([new LanguageModelTextPart('read result')]); + promptResult = { + ...nullRenderPromptResult(), + messages: [{ role: Raw.ChatRole.User, content: [toTextPart('hello world!')] }], + metadata: promptResultMetadata([new ToolResultMetadata('read_call__vscode-0', toolResult)]), + }; + + await handler.getResult(); + + expect({ + availableTools: requestSpy.mock.calls.at(0)?.[0]?.requestOptions?.tools?.map(tool => tool.function.name), + voiceProgress: response + .filter(part => part instanceof ChatResponseVoiceProgressPart) + .map(part => ({ id: part.id, value: part.value })), + }).toEqual({ + availableTools: [ToolName.ReadFile, 'report_voice_progress'], + voiceProgress: [expect.objectContaining({ id: 'investigating' })], + }); + }); + function fillWithToolCalls(insertN = 20) { promptResult = []; for (let i = 0; i < insertN; i++) { diff --git a/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx index 286a9fde4fe..ef7f92fedb3 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/agentPrompt.tsx @@ -128,6 +128,7 @@ export class AgentPrompt extends PromptElement { } ; const isAutopilot = this.props.promptContext.request?.permissionLevel === 'autopilot'; + const isVoiceModeInput = this.props.promptContext.request?.isVoiceModeInput && !this.props.promptContext.request.subAgentInvocationId; const sessionResource = this.props.promptContext.request?.sessionResource; const sessionId = sessionResource ? sessionResourceToId(sessionResource) : undefined; const debugTargetSessionIds = extractDebugTargetSessionIds([...this.props.promptContext.chatVariables].map(v => v.reference)); @@ -140,6 +141,11 @@ export class AgentPrompt extends PromptElement { When you have fully completed the task, call the task_complete tool to signal that you are done.
IMPORTANT: Before calling task_complete, you MUST provide a brief text summary of what was accomplished in your message. The task is not complete until both the summary and the task_complete call are present. } + {isVoiceModeInput && + Voice Mode is active, and you are GitHub Copilot speaking directly to the user. Keep the final response concise and easy to understand aloud. Do not expose internal reasoning.
+ You MUST call the report_voice_progress tool in the same response as your first real work tool calls, using investigating before reading or searching. Call it again at later meaningful stage changes, not for every operation, and in parallel with actual work when possible. Use planning when deciding the approach, editing while making changes, validating while running tests, builds, lint, or checks, and recovering after a concrete failure or change of approach.
+ Each summary must be a concise factual update in plain speech, at most 240 characters, with no markdown, paths, commands, identifiers, secrets, reasoning, or raw source and tool output. Do not repeat the acknowledgement or final response. Questions, confirmations, questionnaires, and the final response use their existing structured flows instead. +
} {templateVariablesContext.length > 0 && {templateVariablesContext}} {await this.getOrCreateGlobalAgentContext(this.props.endpoint)} diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx b/extensions/copilot/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx index 9f6a7e5ff8d..4256aa6b70e 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/test/agentPrompt.spec.tsx @@ -167,6 +167,35 @@ testFamilies.forEach(family => { }, undefined)).toMatchFileSnapshot(getSnapshotFile('simple_case')); }); + if (family === 'default') { + test('voice progress guidance appears only for top-level voice requests', async () => { + const promptContext = { + chatVariables: new ChatVariablesCollection(), + history: [], + query: 'hello', + }; + const topLevelVoicePrompt = await agentPromptToString(accessor, { + ...promptContext, + request: { isVoiceModeInput: true } as IBuildPromptContext['request'], + }); + const subagentVoicePrompt = await agentPromptToString(accessor, { + ...promptContext, + request: { isVoiceModeInput: true, subAgentInvocationId: 'subagent' } as IBuildPromptContext['request'], + }); + const typedPrompt = await agentPromptToString(accessor, promptContext); + + expect({ + topLevelVoice: topLevelVoicePrompt.includes('You MUST call the report_voice_progress tool in the same response as your first real work tool calls'), + subagentVoice: subagentVoicePrompt.includes('You MUST call the report_voice_progress tool in the same response as your first real work tool calls'), + typed: typedPrompt.includes('You MUST call the report_voice_progress tool in the same response as your first real work tool calls'), + }).toEqual({ + topLevelVoice: true, + subagentVoice: false, + typed: false, + }); + }); + } + test('all tools', async () => { const toolsService = accessor.get(IToolsService); await expect(await agentPromptToString(accessor, { diff --git a/extensions/copilot/src/extension/test/node/pseudoStartStopConversationCallback.spec.ts b/extensions/copilot/src/extension/test/node/pseudoStartStopConversationCallback.spec.ts index 34b1b0cf072..14b8936f8e7 100644 --- a/extensions/copilot/src/extension/test/node/pseudoStartStopConversationCallback.spec.ts +++ b/extensions/copilot/src/extension/test/node/pseudoStartStopConversationCallback.spec.ts @@ -206,6 +206,46 @@ suite('Tool stream throttling', () => { assert.strictEqual(updateCalls[0].toolCallId, 'tool1'); }); + test('hidden local tools do not create or update tool cards', async () => { + const responseSource = new AsyncIterableSource(); + const beginCalls: { toolCallId: string; toolName: string }[] = []; + const hiddenStream = new ChatResponseStreamImpl( + () => { }, + () => { }, + undefined, + (toolCallId, toolName) => beginCalls.push({ toolCallId, toolName }), + (toolCallId, streamData) => updateCalls.push({ toolCallId, streamData }), + ); + const processor = new PseudoStopStartResponseProcessor([], undefined, { + hiddenToolNames: new Set(['report_voice_progress']), + }); + + responseSource.emitOne({ + delta: { + text: '', + beginToolCalls: [ + { id: 'hidden', name: 'report_voice_progress' }, + { id: 'visible', name: 'visible_tool' }, + ], + copilotToolCallStreamUpdates: [ + { id: 'hidden', name: 'report_voice_progress', arguments: '{"stage":"editing"}' }, + { id: 'visible', name: 'visible_tool', arguments: '{"value":1}' }, + ], + }, + }); + responseSource.resolve(); + + await processor.doProcessResponse(responseSource.asyncIterable, hiddenStream, CancellationToken.None); + + assert.deepStrictEqual({ + beginCalls, + updateCalls: updateCalls.map(call => call.toolCallId), + }, { + beginCalls: [{ toolCallId: 'visible', toolName: 'visible_tool' }], + updateCalls: ['visible'], + }); + }); + test('rapid updates within throttle window are throttled', async () => { const responseSource = new AsyncIterableSource(); const processor = new PseudoStopStartResponseProcessor([], undefined); diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts index b835a18866d..525543dc72d 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts @@ -14,7 +14,7 @@ import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/do import { Edits } from '../../../../platform/inlineEdits/common/dataTypes/edit'; import { ImportChanges } from '../../../../platform/inlineEdits/common/dataTypes/importFilteringOptions'; import { LanguageId } from '../../../../platform/inlineEdits/common/dataTypes/languageId'; -import { DEFAULT_OPTIONS, EarlyDivergenceCancellationMode, LanguageContextLanguages, LintOptionShowCode, LintOptionWarning, ModelConfiguration, PatchModelPrediction, PromptingStrategy, ResponseFormat } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; +import { AggressivenessLevel, DEFAULT_OPTIONS, EarlyDivergenceCancellationMode, LanguageContextLanguages, LintOptionShowCode, LintOptionWarning, ModelConfiguration, PatchModelPrediction, PromptingStrategy, ResponseFormat } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; import { InlineEditRequestLogContext } from '../../../../platform/inlineEdits/common/inlineEditLogContext'; import { IInlineEditsModelService } from '../../../../platform/inlineEdits/common/inlineEditsModelService'; import { NoNextEditReason, StatelessNextEditDocument, StatelessNextEditRequest, StreamedEdit, WithStatelessProviderTelemetry } from '../../../../platform/inlineEdits/common/statelessNextEditProvider'; @@ -1047,6 +1047,40 @@ describe('XtabProvider integration', () => { expect(getMessageText(systemMessage!)).toBe(xtab275SystemPrompt); }); + it('applies configured aggressiveness only to aggressiveness strategies', async () => { + const lines = ['const x = 1;', 'const y = 2;']; + const captureUserPrompt = async (promptingStrategy: PromptingStrategy, aggressivenessLevel: AggressivenessLevel) => { + mockModelService.setSelectedConfig({ promptingStrategy }); + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabAggressivenessLevel, aggressivenessLevel); + streamingFetcher.setStreamingLines(lines); + + const gen = createProvider().provideNextEdit(createRequestWithEdit(lines, { insertionOffset: 3, insertedText: 'a' }), createMockLogger(), createLogContext(), CancellationToken.None); + await AsyncIterUtils.drainUntilReturn(gen); + + const messages = streamingFetcher.capturedOptions.at(-1)?.messages; + const userMessage = messages?.find(message => message.role === Raw.ChatRole.User); + expect(userMessage).toBeDefined(); + return getMessageText(userMessage!); + }; + + const nonAggressiveLow = await captureUserPrompt(PromptingStrategy.Xtab275, AggressivenessLevel.Low); + const nonAggressiveHigh = await captureUserPrompt(PromptingStrategy.Xtab275, AggressivenessLevel.High); + const aggressiveLow = await captureUserPrompt(PromptingStrategy.XtabAggressiveness, AggressivenessLevel.Low); + const aggressiveHigh = await captureUserPrompt(PromptingStrategy.XtabAggressiveness, AggressivenessLevel.High); + + expect({ + nonAggressivePromptsMatch: nonAggressiveLow === nonAggressiveHigh, + nonAggressivePromptHasLevel: nonAggressiveLow.includes('<|aggressive|>'), + aggressiveLowHasLevel: aggressiveLow.includes('<|aggressive|>low<|/aggressive|>'), + aggressiveHighHasLevel: aggressiveHigh.includes('<|aggressive|>high<|/aggressive|>'), + }).toEqual({ + nonAggressivePromptsMatch: true, + nonAggressivePromptHasLevel: false, + aggressiveLowHasLevel: true, + aggressiveHighHasLevel: true, + }); + }); + it('retries with default model after NotFound response', async () => { const provider = createProvider(); @@ -1977,6 +2011,30 @@ describe('XtabProvider integration', () => { // ======================================================================== describe('debounce behavior', () => { + it('does not change timing for a non-aggressiveness strategy when user eagerness is default', async () => { + mockModelService.setSelectedConfig({ promptingStrategy: PromptingStrategy.Xtab275 }); + const setBaseDebounceTime = vi.spyOn(DelaySession.prototype, 'setBaseDebounceTime'); + const setExpectedTotalTime = vi.spyOn(DelaySession.prototype, 'setExpectedTotalTime'); + + try { + const lines = ['const x = 1;', 'const y = 2;']; + streamingFetcher.setStreamingLines(lines); + const gen = createProvider().provideNextEdit(createRequestWithEdit(lines, { insertionOffset: 3, insertedText: 'a' }), createMockLogger(), createLogContext(), CancellationToken.None); + await AsyncIterUtils.drainUntilReturn(gen); + + expect({ + setBaseDebounceTimeCalls: setBaseDebounceTime.mock.calls.length, + setExpectedTotalTimeCalls: setExpectedTotalTime.mock.calls.length, + }).toEqual({ + setBaseDebounceTimeCalls: 0, + setExpectedTotalTimeCalls: 0, + }); + } finally { + setBaseDebounceTime.mockRestore(); + setExpectedTotalTime.mockRestore(); + } + }); + it('debounce is skipped in simulation tests', async () => { // Override the simulation test context to indicate we're in sim tests const testingServiceCollection = createExtensionUnitTestingServices(disposables); diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index ada9b967f22..9b819f82618 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -907,7 +907,7 @@ export namespace ConfigKey { export const InlineEditsXtabOnlyMergeConflictLines = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.onlyMergeConflictLines', ConfigType.ExperimentBased, false); export const InlineEditsXtabDuplicateAdditionsMode = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.diffPatch.duplicateAdditionsMode', ConfigType.ExperimentBased, DuplicateAdditionsMode.Off, DuplicateAdditionsMode.VALIDATOR); export const InlineEditsXtabSplitPatchOnDiff = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.diffPatch.splitOnDiff', ConfigType.ExperimentBased, false, vBoolean()); - export const InlineEditsXtabAggressivenessLevel = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.aggressivenessLevel', ConfigType.ExperimentBased, undefined); + export const InlineEditsXtabAggressivenessLevel = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.aggressivenessLevel', ConfigType.ExperimentBased, xtabPromptOptions.AggressivenessLevel.Medium); export const InlineEditsAggressivenessLowMinResponseTimeMs = defineTeamInternalSetting('chat.advanced.inlineEdits.aggressiveness.lowMinResponseTimeMs', ConfigType.ExperimentBased, 1500); export const InlineEditsAggressivenessMediumMinResponseTimeMs = defineTeamInternalSetting('chat.advanced.inlineEdits.aggressiveness.mediumMinResponseTimeMs', ConfigType.ExperimentBased, 700); export const InlineEditsAggressivenessHighDebounceMs = defineTeamInternalSetting('chat.advanced.inlineEdits.aggressiveness.highDebounceMs', ConfigType.ExperimentBased, 0); diff --git a/extensions/copilot/src/platform/ignore/node/remoteContentExclusion.ts b/extensions/copilot/src/platform/ignore/node/remoteContentExclusion.ts index 7d51430ecbc..93a210110ab 100644 --- a/extensions/copilot/src/platform/ignore/node/remoteContentExclusion.ts +++ b/extensions/copilot/src/platform/ignore/node/remoteContentExclusion.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { RequestType } from '@vscode/copilot-api'; -import { minimatch } from 'minimatch'; +import { Minimatch } from 'minimatch'; import { createSha256Hash } from '../../../util/common/crypto'; import { coalesce } from '../../../util/vs/base/common/arrays'; -import { Limiter, raceCancellationError } from '../../../util/vs/base/common/async'; +import { DeferredPromise, Limiter, raceCancellationError, timeout } from '../../../util/vs/base/common/async'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { IDisposable } from '../../../util/vs/base/common/lifecycle'; import { ResourceMap } from '../../../util/vs/base/common/map'; @@ -18,9 +18,13 @@ import { IFileSystemService } from '../../filesystem/common/fileSystemService'; import { readFileFromTextBufferOrFS } from '../../filesystem/node/fileSystemServiceImpl'; import { IGitService, RepoContext, normalizeFetchUrl } from '../../git/common/gitService'; import { ILogService } from '../../log/common/logService'; -import { Response } from '../../networking/common/fetcherService'; import { IRequestLogger } from '../../requestLogger/common/requestLogger'; import { IWorkspaceService } from '../../workspace/common/workspaceService'; +import { composeFetchMiddleware } from '../../../shared-fetch-utils/common/advancedFetcher'; +import { FetchBlockedError, type HttpFetchFn, type HttpResponse } from '../../../shared-fetch-utils/common/fetchTypes'; +import { authBlockedMiddleware } from '../../../shared-fetch-utils/common/middleware/authBlockedMiddleware'; +import { rateLimitBackoffMiddleware } from '../../../shared-fetch-utils/common/middleware/rateLimitBackoffMiddleware'; +import { serverErrorBackoffMiddleware } from '../../../shared-fetch-utils/common/middleware/serverErrorBackoffMiddleware'; type ContentExclusionRule = { paths: string[]; @@ -36,20 +40,71 @@ type ContentExclusionResponse = { type RepoMetadata = { repoRootPath: string; fetchUrls: string[] }; -const NON_GIT_FILE_KEY = 'non-git-file'; +/** Rules for a single repo, along with when they were fetched so they can expire individually. */ +type CachedRules = { + patterns: string[]; + ifAnyMatch: RegExp[]; + ifNoneMatch: RegExp[]; + fetchedAt: number; +}; /** - * Fetches content exclusion policies from GH remotes + * A memoised {@link RemoteContentExclusion.isIgnored} result, tagged with the rule generation it + * was computed against so it can be discarded when the rules behind it change. + */ +type CachedVerdict = { verdict: boolean; generation: number }; + +/** A repo awaiting rules, shared by every caller that asks for it while the fetch is outstanding. */ +type PendingFetch = { readonly deferred: DeferredPromise; dispatched: boolean }; + +const NON_GIT_FILE_KEY = 'non-git-file'; + +const MINIMATCH_OPTIONS = { + nocase: true, + matchBase: true, + nonegate: true, + dot: true +}; + +/** Max repos the content exclusion endpoint accepts in a single request. */ +const REPOS_PER_REQUEST = 10; +/** How many batches may be in flight at once. */ +const MAX_CONCURRENT_BATCHES = 5; +/** Window used to collect repos before dispatching, so bursts collapse into full batches. */ +const BATCH_WINDOW_MS = 50; +/** How long fetched rules stay valid before they are refreshed on next use. */ +const RULE_TTL_MS = 30 * 60 * 1000; + +/** + * Fetches content exclusion policies from GH remotes. + * + * The rules endpoint lives on api.github.com, so it shares the caller's regular REST rate limit + * budget. Requests are therefore coalesced per repo, batched, and backed off on failure. */ export class RemoteContentExclusion implements IDisposable { - // The cache which maps remote fetch url to the minimatch patterns, order of patterns matters here - private _contentExclusionCache: Map = new Map(); - private _contentExclusionFetchPromise: Promise | null = null; + // Rules keyed by remote fetch url. Only ever holds successfully fetched rules, so a failed + // request can never be mistaken for "this repo has no exclusions". + private readonly _contentExclusionCache: Map = new Map(); + // Repos waiting to be fetched, along with the promise every caller for that repo shares. Entries + // stay registered until their request settles, so a lookup arriving mid-flight joins it. + private readonly _pendingRepos: Map = new Map(); + private readonly _batchLimiter: Limiter; + private _scheduledDrain: Promise | undefined; + private _disposed = false; + // Flattened, precompiled view of every glob rule so isIgnored does not recompile per call. + private _compiledGlobs: Minimatch[] = []; + private _regexRuleCount = 0; + // Bumped whenever the rules change, which retires every verdict memoised against them. + private _rulesGeneration = 0; + // When the soonest expiring rule set goes stale. Memoised verdicts are only trusted before this. + private _earliestRuleExpiry = 0; // This caches the ignore results as they can be expensive to compute and a single render can request results 100s of times - private _ignoreGlobResultCache: ResourceMap = new ResourceMap(); + private _ignoreGlobResultCache: ResourceMap = new ResourceMap(); // Map of the hash of file contents to the result of the regex check - private _ignoreRegexResultCache: Map = new Map(); - private _lastRuleFetch = 0; + private _ignoreRegexResultCache: Map = new Map(); + // Requests go through the shared middleware stack so rate limit and server error backoff are + // handled the same way as every other cached CAPI-client value. + private readonly _fetchExclusionRules: HttpFetchFn; private _disposables: IDisposable[] = []; private readonly _fileReadLimiter: Limiter; // Cache of repository root paths to their metadata to avoid calling getRepositoryFetchUrls for every file @@ -63,11 +118,10 @@ export class RemoteContentExclusion implements IDisposable { private readonly _capiClientService: ICAPIClientService, private readonly _fileSystemService: IFileSystemService, private readonly _workspaceService: IWorkspaceService, - private readonly _requestLogger: IRequestLogger + private readonly _requestLogger: IRequestLogger, + // Injectable so tests can exercise rule expiry and backoff without waiting on the wall clock. + private readonly _now: () => number = Date.now ) { - // This is a specialized entry to store the global rules that apply to files outside of any git repository - // The other option was to maintain a separate cache for non git files but that would be redundant - this._contentExclusionCache.set(NON_GIT_FILE_KEY, { patterns: [], ifAnyMatch: [], ifNoneMatch: [] }); this._disposables.push(this._gitService.onDidCloseRepository((r) => { const repoInfo = this.getRepositoryInfo(r); if (!repoInfo) { @@ -78,22 +132,33 @@ export class RemoteContentExclusion implements IDisposable { for (const url of repoInfo.fetchUrls) { this._contentExclusionCache.delete(url); } + this.rebuildCompiledRules(); + // Dropping a repo's rules can flip verdicts that were memoised while they applied. + this.invalidateVerdicts(); })); this._fileReadLimiter = new Limiter(10); this._disposables.push(this._fileReadLimiter); + this._batchLimiter = new Limiter(MAX_CONCURRENT_BATCHES); + this._disposables.push(this._batchLimiter); + + this._fetchExclusionRules = composeFetchMiddleware( + // Order matters: the rate limit check sits inside the auth check so that a quota + // exhausted 403 is recognised by its headers and waits for the reset, instead of being + // misread as an auth failure and blocking the token for an hour. + authBlockedMiddleware(), + rateLimitBackoffMiddleware({ now: this._now }), + serverErrorBackoffMiddleware(), + )(request => this._capiClientService.makeRequest( + { headers: request.headers }, + { type: RequestType.ContentExclusion, repos: (request.state?.repos ?? []) as string[] } + )); } public async isIgnored(file: URI, token: CancellationToken = CancellationToken.None): Promise { - // 1. If glob is not ignored, but there is no regex we can return false as the URI will not change - // 2. If glob is not ignored, but there are regex we need to read file content which will happen lower in the regex code. - // 3. If glob is ignored, it will return true despite regex since the most restrictive exclusion takes the cake - if ((this._ignoreGlobResultCache.has(file) && !this.isRegexContextExclusionsEnabled) || this._ignoreGlobResultCache.get(file)) { - return this._ignoreGlobResultCache.get(file) ?? false; - } - // Any pending requests that may be in flight should be awaited before returning a result - if (this._contentExclusionFetchPromise) { - await raceCancellationError(this._contentExclusionFetchPromise, token); + const memoised = this.memoisedVerdict(file); + if (memoised !== undefined) { + return memoised; } // Try to find the repository from the cache first to avoid expensive git extension calls @@ -118,29 +183,18 @@ export class RemoteContentExclusion implements IDisposable { const fileName = file.path.toLowerCase().replace(repoMetadata.repoRootPath.toLowerCase(), ''); - // We're missing entries for this repository in the cache, so we fetch it. - // Or it has been more than 30 minutes so the current rules are stale - if (this.shouldFetchContentExclusionRules(repoMetadata) || (Date.now() - this._lastRuleFetch > 30 * 60 * 1000)) { - this._logService.trace(`Fetching content exclusions, due to ${this.shouldFetchContentExclusionRules(repoMetadata) ? 'repository change' : 'stale cache'}.`); - this._lastRuleFetch = Date.now(); - await raceCancellationError(this.makeContentExclusionRequest(), token); - } + // Only waits on the repos this file actually belongs to, so an unrelated in-flight batch + // cannot block this lookup. + const rulesLoaded = await raceCancellationError(this.ensureRulesLoaded(repoMetadata.fetchUrls), token); + // Captured up front so that a refresh landing while this verdict is being computed retires + // it, rather than it being stored as if it reflected the newer rules. + const generation = this._rulesGeneration; - const minimatchConfig = { - nocase: true, - matchBase: true, - nonegate: true, - dot: true - }; - - for (const { patterns } of this._contentExclusionCache.values()) { - for (const rule of patterns) { - const matchesPattern = minimatch(fileName, rule, minimatchConfig) || minimatch(file.path, rule, minimatchConfig); - if (matchesPattern) { - this._logService.debug(`File ${file.path} is ignored by content exclusion rule ${rule}`); - this._ignoreGlobResultCache.set(file, true); - return true; - } + for (const glob of this._compiledGlobs) { + if (glob.match(fileName) || glob.match(file.path)) { + this._logService.debug(`File ${file.path} is ignored by content exclusion rule ${glob.pattern}`); + this._ignoreGlobResultCache.set(file, { verdict: true, generation }); + return true; } } let fileContents: string = ''; @@ -157,8 +211,9 @@ export class RemoteContentExclusion implements IDisposable { fileContents = typeof fileContentOrBuffer === 'string' ? fileContentOrBuffer : new TextDecoder().decode(fileContentOrBuffer); fileContentHash = await createSha256Hash(fileContents); // Cache hit for these file contents, no need to run the regex patterns - if (this._ignoreRegexResultCache.has(fileContentHash)) { - return this._ignoreRegexResultCache.get(fileContentHash) ?? false; + const cachedRegexVerdict = this._ignoreRegexResultCache.get(fileContentHash); + if (cachedRegexVerdict && cachedRegexVerdict.generation === generation) { + return cachedRegexVerdict.verdict; } } catch { // We failed to read the file, so it should just be ignored as we have no idea what the contents are or if it exists @@ -168,151 +223,301 @@ export class RemoteContentExclusion implements IDisposable { } if (ifAnyMatch.length > 0 && fileContents && ifAnyMatch.some(pattern => pattern.test(fileContents))) { this._logService.debug(`File ${file.path} is ignored by content exclusion rule ifAnyMatch`); - this._ignoreRegexResultCache.set(fileContentHash, true); + this._ignoreRegexResultCache.set(fileContentHash, { verdict: true, generation }); return true; } if (ifNoneMatch.length > 0 && fileContents && !ifNoneMatch.some(pattern => pattern.test(fileContents))) { this._logService.debug(`File ${file.path} is ignored by content exclusion rule ifNoneMatch`); - this._ignoreRegexResultCache.set(fileContentHash, true); + this._ignoreRegexResultCache.set(fileContentHash, { verdict: true, generation }); return true; } } - this._ignoreGlobResultCache.set(file, false); - this._ignoreRegexResultCache.set(fileContentHash, false); + // Only memoise a negative verdict once every relevant rule set has actually loaded. Caching it + // after a failed fetch would leave the file permanently allowed. + if (rulesLoaded) { + this._ignoreGlobResultCache.set(file, { verdict: false, generation }); + // Only meaningful when regex rules forced us to read (and hash) the file. + if (fileContentHash) { + this._ignoreRegexResultCache.set(fileContentHash, { verdict: false, generation }); + } + } return false; } + /** + * Returns a memoised verdict when it can still be trusted. + * + * A verdict is only reusable while the rules behind it are both unchanged and unexpired, + * otherwise the file has to be re-evaluated so that policy changes are picked up. Skipping the + * expiry check here would pin a file to its first answer forever, since a cached verdict + * short-circuits the refresh that would notice new rules. + */ + private memoisedVerdict(file: URI): boolean | undefined { + const cached = this._ignoreGlobResultCache.get(file); + if (!cached || cached.generation !== this._rulesGeneration || this._now() >= this._earliestRuleExpiry) { + return undefined; + } + // An exclusion is the most restrictive answer, so a positive verdict stands on its own. A + // negative one is only final when no regex rule could still exclude the file on content. + return cached.verdict || !this.isRegexContextExclusionsEnabled ? cached.verdict : undefined; + } + /** * Returns whether or not there are regex context exclusions. */ public get isRegexContextExclusionsEnabled(): boolean { - return [...this._contentExclusionCache.values()].some(({ ifAnyMatch, ifNoneMatch }: { ifAnyMatch: RegExp[]; ifNoneMatch: RegExp[] }) => ifAnyMatch.length > 0 || ifNoneMatch.length > 0); + return this._regexRuleCount > 0; } + /** * Loads the content exclusion rules for the given repositories. Primarily used to load a bunch of repos at once prior to a search for example. * @param repoUris The list of repository URIs to load the content exclusion rules for */ public async loadRepos(repoUris: URI[]) { const repos = await Promise.all(repoUris.map(uri => this._gitService.getRepositoryFetchUrls(uri))); - const repoInfos = repos.map(repo => { + const fetchUrls: string[] = []; + for (const repo of repos) { const repoInfo = this.getRepositoryInfo(repo); // Populate the repo root cache for future lookups if (repoInfo) { this._repoRootCache.set(repoInfo.repoRootPath, repoInfo); + fetchUrls.push(...repoInfo.fetchUrls); } - return this.shouldFetchContentExclusionRules(repoInfo); - }); - if (repoInfos.some(info => info)) { - this._lastRuleFetch = Date.now(); - await this.makeContentExclusionRequest(); } + await this.ensureRulesLoaded(fetchUrls); } public async asMinimatchPatterns() { - await this._contentExclusionFetchPromise; - const patterns: string[] = Array.from(this._contentExclusionCache.values()).flatMap(({ patterns }) => patterns); - return patterns; + // Anything already queued must land first so callers see a complete pattern set. + await Promise.all([...this._pendingRepos.values()].map(pending => pending.deferred.p)); + return Array.from(this._contentExclusionCache.values()).flatMap(({ patterns }) => patterns); } public dispose() { + this._disposed = true; + // Released before the limiter is disposed: it drops queued work without ever running it, so + // anything still registered here would otherwise leave its callers waiting forever. + this.settlePending([...this._pendingRepos]); + this._pendingRepos.clear(); this._disposables.forEach(d => d.dispose()); this._disposables = []; this._contentExclusionCache.clear(); + this._compiledGlobs = []; + this._regexRuleCount = 0; + this._earliestRuleExpiry = 0; } - private shouldFetchContentExclusionRules(repoInfo: RepoMetadata | undefined): boolean { - if (!repoInfo) { - return false; + /** + * Ensures rules for the given repos are loaded, fetching only what is missing or expired. + * + * Callers asking for the same repo share a single request, and each caller only waits on the + * repos it asked for, so a large background load cannot stall an individual file check. + * + * @returns whether every required rule set is now available. `false` means at least one fetch + * failed, and the caller must not memoise a verdict derived from the incomplete rules. + */ + private async ensureRulesLoaded(fetchUrls: readonly string[]): Promise { + // Global/org rules are keyed under the non-git pseudo repo and can apply to any file. + const required = new Set(fetchUrls); + required.add(NON_GIT_FILE_KEY); + + const now = this._now(); + const waits: Promise[] = []; + for (const url of required) { + const cached = this._contentExclusionCache.get(url); + if (cached && now - cached.fetchedAt < RULE_TTL_MS) { + continue; + } + waits.push(this.enqueueRepo(url)); } - let shouldFetch = false; - for (const remoteRepoUrl of repoInfo?.fetchUrls ?? []) { - if (!this._contentExclusionCache.has(remoteRepoUrl)) { - shouldFetch = true; - this._contentExclusionCache.set(remoteRepoUrl, { patterns: [], ifAnyMatch: [], ifNoneMatch: [] }); + + if (waits.length > 0) { + await Promise.all(waits); + } + + for (const url of required) { + if (!this._contentExclusionCache.has(url)) { + return false; } } - return shouldFetch; + return true; + } + + /** Registers a repo for the next batch, joining an existing fetch when one is outstanding. */ + private enqueueRepo(url: string): Promise { + const existing = this._pendingRepos.get(url); + if (existing) { + // Queued or already in flight; share that result rather than issuing a duplicate request. + return existing.deferred.p; + } + const pending: PendingFetch = { deferred: new DeferredPromise(), dispatched: false }; + if (this._disposed) { + // Nothing will ever run, so release the caller instead of leaving it waiting. + pending.deferred.complete(undefined); + return pending.deferred.p; + } + this._pendingRepos.set(url, pending); + this.scheduleDrain(); + return pending.deferred.p; } /** - * A wrapper around the actual request - * TODO @lramos15 add cancellation to cancel the old request in flight - * @returns The promise which resolves when the request is complete + * Schedules a drain shortly after the first enqueue. The window is deliberately not reset by + * later enqueues so that a steady stream of repos cannot starve the fetch indefinitely. */ - private async makeContentExclusionRequest(): Promise { - if (this._contentExclusionFetchPromise) { - await this._contentExclusionFetchPromise; + private scheduleDrain(): void { + if (this._scheduledDrain) { + return; } + this._scheduledDrain = (async () => { + await timeout(BATCH_WINDOW_MS); + this._scheduledDrain = undefined; + this.drainPendingRepos(); + })(); + } + + /** Dispatches everything queued but not yet sent as batched, concurrency limited requests. */ + private drainPendingRepos(): void { + if (this._disposed) { + return; + } + const batchable = [...this._pendingRepos].filter(([, pending]) => !pending.dispatched); + if (batchable.length === 0) { + return; + } + // Entries deliberately stay in the map until their request settles, so a lookup arriving + // while the request is slow joins it instead of queueing the same repo again. + batchable.forEach(([, pending]) => { pending.dispatched = true; }); + + for (let i = 0; i < batchable.length; i += REPOS_PER_REQUEST) { + const batch = batchable.slice(i, i + REPOS_PER_REQUEST); + this._batchLimiter.queue(() => this.fetchRulesForBatch(batch)); + } + } + + /** + * Fetches one batch of repos. Rules are only cached on success, so a transient failure is retried + * later rather than being remembered as "this repo has no exclusions". + */ + private async fetchRulesForBatch(batch: [string, PendingFetch][]): Promise { + const repos = batch.map(([repo]) => repo); + const startTime = this._now(); try { - this._contentExclusionFetchPromise = this._contentExclusionRequest(); - await this._contentExclusionFetchPromise; - this._contentExclusionFetchPromise = null; - } catch { - this._contentExclusionFetchPromise = null; - } - } + const ghToken = (await this._authService.getGitHubSession('any', { silent: true }))?.accessToken; + const response = await this._fetchExclusionRules({ + url: `capi:${RequestType.ContentExclusion}`, + headers: { 'Authorization': `token ${ghToken}` }, + method: 'GET', + state: { repos } + }); - - /** - * The actual function that fetches the content exclusion rules from the GH API. - * Not recommended to call directly and instead use {@link makeContentExclusionRequest} as that ensures only one call is pending at any time - */ - private async _contentExclusionRequest(): Promise { - // Clear the result cache as new rules will come and therefore it is no longer valid - this._ignoreGlobResultCache.clear(); - const startTime = Date.now(); - const capiClientService = this._capiClientService; - const ghToken = (await this._authService.getGitHubSession('any', { silent: true }))?.accessToken; - const remoteFetchUrls = Array.from(this._contentExclusionCache.keys()); - const updateRulesForRepos = async (reposToFetch: string[]) => { - - const response = await capiClientService.makeRequest({ - headers: { - 'Authorization': `token ${ghToken}` - }, - }, { type: RequestType.ContentExclusion, repos: reposToFetch }); - - if (!response.ok) { - this._logService.error(`Failed to fetch content exclusion rules: ${response?.statusText}`); + if (response.status < 200 || response.status >= 300) { + this._logService.error(`Failed to fetch content exclusion rules for ${repos.length} repo(s): ${response.status}`); return; } - const data: ContentExclusionResponse[] = await response.json(); - for (let j = 0; j < data.length; j++) { - const patterns = data[j].rules.map(rule => rule.paths).flat(); - const ifAnyMatch = coalesce(data[j].rules.map(rule => rule.ifAnyMatch).flat()).map(pattern => stringToRegex(pattern)); - const ifNoneMatch = coalesce(data[j].rules.map(rule => rule.ifNoneMatch).flat()).map(pattern => stringToRegex(pattern)); - const repo = reposToFetch[j]; - const rulesForRepo = { patterns, ifAnyMatch, ifNoneMatch }; - this._contentExclusionCache.set(repo, rulesForRepo); - this._logService.trace(`Fetched content exclusion rules for ${repo}: ${JSON.stringify(rulesForRepo)}`); + + this.applyRules(repos, await response.json() as ContentExclusionResponse[], startTime); + } catch (err) { + if (err instanceof FetchBlockedError) { + // A middleware is deliberately holding requests back. The repos stay uncached and are + // picked up again once the block lifts. + this._logService.warn(`Deferred content exclusion fetch for ${repos.length} repo(s): ${err.message}`); + } else { + this._logService.error(`Failed to fetch content exclusion rules: ${err}`); } - }; + } finally { + // Waiters always resume. On failure the repo stays uncached so it is fetched again later. + this.settlePending(batch); + } + } - // This is needed to fetch the global rules that could apply to non git files - if (remoteFetchUrls.length === 0) { - await updateRulesForRepos([]); + /** Releases a batch's waiters, deregistering entries that still belong to this attempt. */ + private settlePending(batch: readonly [string, PendingFetch][]): void { + for (const [url, pending] of batch) { + if (this._pendingRepos.get(url) === pending) { + this._pendingRepos.delete(url); + } + pending.deferred.complete(undefined); + } + } + + private applyRules(repos: string[], data: ContentExclusionResponse[], startTime: number): void { + const fetchedAt = this._now(); + const loggedRules: { patterns: string[]; ifAnyMatch: string[]; ifNoneMatch: string[] }[] = []; + let rulesChanged = false; + + for (let i = 0; i < repos.length; i++) { + // A missing entry means the server reported no rules for that repo. That is still a + // definitive answer, so it is cached to avoid refetching the repo forever. + const rules = data[i]?.rules ?? []; + const patterns = rules.flatMap(rule => rule.paths); + const ifAnyMatch = this.toRegexes(rules.flatMap(rule => rule.ifAnyMatch)); + const ifNoneMatch = this.toRegexes(rules.flatMap(rule => rule.ifNoneMatch)); + const previous = this._contentExclusionCache.get(repos[i]); + // Compared against what was there before, because rules being *removed* changes verdicts + // just as much as rules being added. + rulesChanged ||= !previous || !isSameRuleSet(previous, { patterns, ifAnyMatch, ifNoneMatch }); + this._contentExclusionCache.set(repos[i], { patterns, ifAnyMatch, ifNoneMatch, fetchedAt }); + loggedRules.push({ + patterns, + ifAnyMatch: ifAnyMatch.map(r => r.toString()), + ifNoneMatch: ifNoneMatch.map(r => r.toString()) + }); } - // Process in batches of 10 as that's the max content exclusion rules we can fetch at a time - for (let i = 0; i < remoteFetchUrls.length; i += 10) { - const batch = remoteFetchUrls.slice(i, i + 10); - await updateRulesForRepos(batch); - } - this._lastRuleFetch = Date.now(); - this._logService.info(`Fetched content exclusion rules in ${Date.now() - startTime}ms`); + this.rebuildCompiledRules(); - // Log the fetched rules to the request logger for debugging visibility - const repos = Array.from(this._contentExclusionCache.keys()); - const rules = repos.map(repo => { - const entry = this._contentExclusionCache.get(repo)!; - return { - patterns: entry.patterns, - ifAnyMatch: entry.ifAnyMatch.map(r => r.toString()), - ifNoneMatch: entry.ifNoneMatch.map(r => r.toString()) - }; - }); - this._requestLogger.logContentExclusionRules(repos, rules, Date.now() - startTime); + if (rulesChanged) { + this.invalidateVerdicts(); + } + + const duration = this._now() - startTime; + this._logService.info(`Fetched content exclusion rules for ${repos.length} repo(s) in ${duration}ms`); + this._requestLogger.logContentExclusionRules(repos, loggedRules, duration); + } + + /** Retires every memoised verdict, since the rules they were computed against no longer hold. */ + private invalidateVerdicts(): void { + this._rulesGeneration++; + this._ignoreGlobResultCache.clear(); + this._ignoreRegexResultCache.clear(); + } + + /** Rebuilds the flattened matcher list that {@link isIgnored} walks. */ + private rebuildCompiledRules(): void { + const globs: Minimatch[] = []; + let regexRuleCount = 0; + let earliestExpiry = Number.POSITIVE_INFINITY; + for (const { patterns, ifAnyMatch, ifNoneMatch, fetchedAt } of this._contentExclusionCache.values()) { + for (const pattern of patterns) { + try { + globs.push(new Minimatch(pattern, MINIMATCH_OPTIONS)); + } catch (err) { + this._logService.warn(`Skipping malformed content exclusion pattern '${pattern}': ${err}`); + } + } + regexRuleCount += ifAnyMatch.length + ifNoneMatch.length; + earliestExpiry = Math.min(earliestExpiry, fetchedAt + RULE_TTL_MS); + } + this._compiledGlobs = globs; + this._regexRuleCount = regexRuleCount; + // Zero while nothing is cached, which keeps memoised verdicts from being trusted before any + // rules have been loaded. + this._earliestRuleExpiry = this._contentExclusionCache.size > 0 ? earliestExpiry : 0; + } + + /** Compiles regex rules, skipping any the server sent that cannot be parsed. */ + private toRegexes(patterns: (string | undefined)[]): RegExp[] { + const compiled: RegExp[] = []; + for (const pattern of coalesce(patterns)) { + try { + compiled.push(stringToRegex(pattern)); + } catch (err) { + this._logService.warn(`Skipping malformed content exclusion regex '${pattern}': ${err}`); + } + } + return compiled; } @@ -357,10 +562,20 @@ export class RemoteContentExclusion implements IDisposable { } } +/** Compares two rule sets by content, so an unchanged refresh does not retire memoised verdicts. */ +function isSameRuleSet(a: Omit, b: Omit): boolean { + return equalStrings(a.patterns, b.patterns) + && equalStrings(a.ifAnyMatch.map(String), b.ifAnyMatch.map(String)) + && equalStrings(a.ifNoneMatch.map(String), b.ifNoneMatch.map(String)); +} + +function equalStrings(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + /** * Convert a given string /pattern/flags to a RegExp object - */ -function stringToRegex(str: string): RegExp { + */function stringToRegex(str: string): RegExp { // Handle Regex format of `pattern` vs /pattern/ if (!str.startsWith('/') && !str.endsWith('/')) { return new RegExp(str); diff --git a/extensions/copilot/src/platform/ignore/node/test/mockCAPIClientService.ts b/extensions/copilot/src/platform/ignore/node/test/mockCAPIClientService.ts index 5c52d5ebac5..622a6f61bb3 100644 --- a/extensions/copilot/src/platform/ignore/node/test/mockCAPIClientService.ts +++ b/extensions/copilot/src/platform/ignore/node/test/mockCAPIClientService.ts @@ -4,11 +4,45 @@ *--------------------------------------------------------------------------------------------*/ import type { FetchOptions, RequestMetadata } from '@vscode/copilot-api'; -import { Response } from '../../../networking/common/fetcherService'; +import { HeadersImpl, Response } from '../../../networking/common/fetcherService'; + +/** Shape of the content exclusion payload the endpoint returns for a single repo. */ +export type MockExclusionRules = { + paths?: string[]; + ifAnyMatch?: string[]; + ifNoneMatch?: string[]; +}; + +/** Builds a successful content exclusion response for the requested repos. */ +export function rulesResponse(rulesByRepo: ReadonlyMap, repos: string[]): Partial { + const payload = repos.map(repo => { + const rules = rulesByRepo.get(repo); + return { + last_updated_at: 0, + rules: rules ? [{ paths: rules.paths ?? [], ifAnyMatch: rules.ifAnyMatch, ifNoneMatch: rules.ifNoneMatch, source: { name: repo, type: 'Repository' } }] : [] + }; + }); + return { ok: true, status: 200, statusText: 'OK', json: () => Promise.resolve(payload) }; +} + +/** Builds a failing response, optionally carrying GitHub's rate limit headers. */ +export function failureResponse(status: number, headers: Record = {}): Partial { + return { + ok: false, + status, + statusText: status === 403 ? 'Forbidden' : 'Error', + headers: new HeadersImpl(headers) + }; +} + +/** Builds a rate limited response of the shape api.github.com returns. */ +export function rateLimitedResponse(retryAfterSeconds: number): Partial { + return failureResponse(429, { 'retry-after': String(retryAfterSeconds) }); +} /** * A mock implementation of ICAPIClientService for testing. - * Returns an empty successful response by default. + * Records every request so tests can assert on batching and coalescing behaviour. * Note: Does not fully implement ICAPIClientService - only the methods needed for tests. */ export class MockCAPIClientService { @@ -16,25 +50,70 @@ export class MockCAPIClientService { abExpContext: string | undefined = undefined; - private _mockResponse: Response = { + /** Each entry is the list of repos sent in one request, in dispatch order. */ + readonly requestedBatches: string[][] = []; + + private _responder: (repos: string[]) => Partial = () => ({}); + private _gate: Promise | undefined; + private _openGate: (() => void) | undefined; + + private readonly _defaultResponse: Response = { ok: true, status: 200, statusText: 'OK', - headers: new Map(), + headers: new HeadersImpl({}), text: () => Promise.resolve('[]'), json: () => Promise.resolve([]), arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), body: null, } as unknown as Response; - /** - * Sets the mock response to return from makeRequest. - */ - setMockResponse(response: Partial): void { - this._mockResponse = { ...this._mockResponse, ...response } as Response; + get requestCount(): number { + return this.requestedBatches.length; } - makeRequest(_request: FetchOptions, _requestMetadata: RequestMetadata): Promise { - return Promise.resolve(this._mockResponse as unknown as T); + /** Every repo requested across all batches, including any duplicates. */ + get requestedRepos(): string[] { + return this.requestedBatches.flat(); + } + + /** How many times the given repo was asked for. */ + timesRequested(repo: string): number { + return this.requestedRepos.filter(candidate => candidate === repo).length; + } + + reset(): void { + this.requestedBatches.length = 0; + } + + /** + * Sets a responder invoked with the repos of each request, so per-repo rules and + * per-attempt failures can be simulated. + */ + setResponder(responder: (repos: string[]) => Partial): void { + this._responder = responder; + } + + /** Holds every subsequent request open until {@link releaseRequests}, to model a slow endpoint. */ + blockRequests(): void { + this._gate = new Promise(resolve => { this._openGate = resolve; }); + } + + releaseRequests(): void { + this._openGate?.(); + this._gate = undefined; + this._openGate = undefined; + } + + makeRequest(_request: FetchOptions, requestMetadata: RequestMetadata): Promise { + const repos = 'repos' in requestMetadata ? requestMetadata.repos : []; + // Recorded before awaiting the gate so tests can observe requests while they are in flight. + this.requestedBatches.push([...repos]); + const gate = this._gate; + if (!gate) { + return Promise.resolve({ ...this._defaultResponse, ...this._responder(repos) } as unknown as T); + } + return gate.then(() => ({ ...this._defaultResponse, ...this._responder(repos) }) as unknown as T); } } + diff --git a/extensions/copilot/src/platform/ignore/node/test/remoteContentExclusion.spec.ts b/extensions/copilot/src/platform/ignore/node/test/remoteContentExclusion.spec.ts index f799dca5f8f..5dfa9902dea 100644 --- a/extensions/copilot/src/platform/ignore/node/test/remoteContentExclusion.spec.ts +++ b/extensions/copilot/src/platform/ignore/node/test/remoteContentExclusion.spec.ts @@ -14,10 +14,13 @@ import { NullRequestLogger } from '../../../requestLogger/node/nullRequestLogger import { TestLogService } from '../../../testing/common/testLogService'; import { RemoteContentExclusion } from '../remoteContentExclusion'; import { MockAuthenticationService } from './mockAuthenticationService'; -import { MockCAPIClientService } from './mockCAPIClientService'; +import { MockCAPIClientService, failureResponse, rateLimitedResponse, rulesResponse, type MockExclusionRules } from './mockCAPIClientService'; import { MockGitService } from './mockGitService'; import { MockWorkspaceService } from './mockWorkspaceService'; +/** Key the implementation uses for global rules that apply outside any git repository. */ +const NON_GIT_FILE_KEY = 'non-git-file'; + suite('RemoteContentExclusion', () => { let remoteContentExclusion: RemoteContentExclusion; let mockGitService: MockGitService; @@ -26,8 +29,42 @@ suite('RemoteContentExclusion', () => { let mockCAPIClientService: MockCAPIClientService; let mockFileSystemService: MockFileSystemService; let mockWorkspaceService: MockWorkspaceService; + let now: number; + + function remoteFor(repoRoot: string): string { + return `https://github.com/org/${repoRoot.split('/').pop()}.git`; + } + + /** Routes each file to the repo whose root is the longest matching prefix of its path. */ + function routeToRepos(repoRoots: string[]): void { + const byLongestRoot = [...repoRoots].sort((a, b) => b.length - a.length); + mockGitService.getRepositoryFetchUrls = vi.fn().mockImplementation((uri: URI) => { + mockGitService.getRepositoryFetchUrlsCallCount++; + const root = byLongestRoot.find(candidate => uri.path === candidate || uri.path.startsWith(candidate + '/')); + return Promise.resolve(root ? { rootUri: URI.file(root), remoteFetchUrls: [remoteFor(root)] } : undefined); + }); + } + + function respondWithRules(rules: Record): void { + const byRepo = new Map(Object.entries(rules).map(([repoRoot, value]) => [remoteFor(repoRoot), value])); + mockCAPIClientService.setResponder(repos => rulesResponse(byRepo, repos)); + } + + /** Waits until the mock has recorded at least `count` requests, or gives up. */ + async function waitForRequests(count: number): Promise { + const deadline = Date.now() + 2000; + while (mockCAPIClientService.requestCount < count && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + } + + /** Gives any scheduled batching window time to elapse and dispatch. */ + function settleBatchWindow(): Promise { + return new Promise(resolve => setTimeout(resolve, 250)); + } beforeEach(() => { + now = Date.UTC(2026, 0, 1); mockGitService = new MockGitService(); mockLogService = new TestLogService(); mockAuthService = new MockAuthenticationService(); @@ -45,7 +82,8 @@ suite('RemoteContentExclusion', () => { mockCAPIClientService as unknown as ICAPIClientService, mockFileSystemService, mockWorkspaceService, - new NullRequestLogger() + new NullRequestLogger(), + () => now ); }); @@ -232,4 +270,227 @@ suite('RemoteContentExclusion', () => { expect(mockGitService.getRepositoryFetchUrlsCallCount).toBe(0); }); }); + + describe('request coalescing', () => { + test('batches concurrent lookups instead of refreshing every repo per caller', async () => { + const repoRoots = Array.from({ length: 25 }, (_, i) => `/workspace/repo${i}`); + routeToRepos(repoRoots); + + await Promise.all(repoRoots.map(root => remoteContentExclusion.isIgnored(URI.file(`${root}/src/file.ts`), CancellationToken.None))); + + // 25 repos plus the non-git pseudo repo, sent 10 per request, each asked for exactly once. + expect({ + requests: mockCAPIClientService.requestCount, + reposSent: mockCAPIClientService.requestedRepos.length, + uniqueReposSent: new Set(mockCAPIClientService.requestedRepos).size + }).toEqual({ requests: 3, reposSent: 26, uniqueReposSent: 26 }); + }); + + test('only fetches repos that are missing from the cache', async () => { + routeToRepos(['/workspace/repo-a', '/workspace/repo-b']); + + await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/one.ts'), CancellationToken.None); + mockCAPIClientService.reset(); + + // Same repo again: everything needed is already cached. + await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/two.ts'), CancellationToken.None); + const afterCachedRepo = mockCAPIClientService.requestedRepos; + + // New repo: only the new remote is requested, not the whole cache. + await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-b/one.ts'), CancellationToken.None); + + expect({ afterCachedRepo, afterNewRepo: mockCAPIClientService.requestedRepos }).toEqual({ + afterCachedRepo: [], + afterNewRepo: [remoteFor('/workspace/repo-b')] + }); + }); + + test('caches an empty ruleset so repos without rules are not refetched', async () => { + routeToRepos(['/workspace/repo-a']); + + // The default responder reports no rules for the requested repos. + await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/one.ts'), CancellationToken.None); + mockCAPIClientService.reset(); + + await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/two.ts'), CancellationToken.None); + + expect(mockCAPIClientService.requestCount).toBe(0); + }); + + test('refreshes rules once they expire', async () => { + routeToRepos(['/workspace/repo-a']); + + await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/one.ts'), CancellationToken.None); + mockCAPIClientService.reset(); + + now += 31 * 60 * 1000; + await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/two.ts'), CancellationToken.None); + + expect([...mockCAPIClientService.requestedRepos].sort()).toEqual([NON_GIT_FILE_KEY, remoteFor('/workspace/repo-a')].sort()); + }); + }); + + describe('failure handling', () => { + test('retries a repo whose fetch failed rather than treating it as unrestricted', async () => { + routeToRepos(['/workspace/repo-a']); + + let attempts = 0; + mockCAPIClientService.setResponder(repos => { + attempts++; + return attempts === 1 + ? rateLimitedResponse(60) + : rulesResponse(new Map([[remoteFor('/workspace/repo-a'), { paths: ['**/secret.ts'] }]]), repos); + }); + + const secret = URI.file('/workspace/repo-a/secret.ts'); + const whileFailing = await remoteContentExclusion.isIgnored(secret, CancellationToken.None); + + // Past the backoff window the rules load and the same file is now correctly excluded. + now += 5 * 60 * 1000; + const afterRecovery = await remoteContentExclusion.isIgnored(secret, CancellationToken.None); + + expect({ whileFailing, afterRecovery }).toEqual({ whileFailing: false, afterRecovery: true }); + }); + + test('stops issuing requests while the backoff is in effect', async () => { + routeToRepos(['/workspace/repo-a']); + mockCAPIClientService.setResponder(() => rateLimitedResponse(600)); + + await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/one.ts'), CancellationToken.None); + const afterFirst = mockCAPIClientService.requestCount; + + await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/two.ts'), CancellationToken.None); + + expect({ afterFirst, afterSecond: mockCAPIClientService.requestCount }).toEqual({ afterFirst: 1, afterSecond: 1 }); + }); + + test('waits for the reported reset window when rate limited', async () => { + routeToRepos(['/workspace/repo-a']); + const resetEpochSeconds = Math.floor((now + 10 * 60 * 1000) / 1000); + mockCAPIClientService.setResponder(() => failureResponse(403, { + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': String(resetEpochSeconds) + })); + + await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/one.ts'), CancellationToken.None); + + // Still inside the window the server reported, so no further calls are made. + now += 5 * 60 * 1000; + await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/two.ts'), CancellationToken.None); + const duringWindow = mockCAPIClientService.requestCount; + + // Retrying after the reset also proves the quota 403 was classified as a rate limit + // rather than an auth failure, which would have blocked the token for an hour. + now += 6 * 60 * 1000; + await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/three.ts'), CancellationToken.None); + + expect({ duringWindow, afterWindow: mockCAPIClientService.requestCount }).toEqual({ duringWindow: 1, afterWindow: 2 }); + }); + }); + + describe('rule matching', () => { + test('excludes files matching a fetched glob rule', async () => { + routeToRepos(['/workspace/repo-a']); + respondWithRules({ '/workspace/repo-a': { paths: ['**/*.env'] } }); + + expect({ + env: await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/config.env'), CancellationToken.None), + source: await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/index.ts'), CancellationToken.None) + }).toEqual({ env: true, source: false }); + }); + + test('ignores malformed patterns rather than failing every lookup', async () => { + routeToRepos(['/workspace/repo-a']); + respondWithRules({ '/workspace/repo-a': { paths: ['**/*.env'], ifAnyMatch: ['/(unclosed/'] } }); + + expect(await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/config.env'), CancellationToken.None)).toBe(true); + }); + }); + + describe('picking up rule changes', () => { + test('re-evaluates a file once its rules expire', async () => { + routeToRepos(['/workspace/repo-a']); + respondWithRules({}); + + const file = URI.file('/workspace/repo-a/secret.ts'); + const beforeRuleAdded = await remoteContentExclusion.isIgnored(file, CancellationToken.None); + + // The server starts excluding the file after the first verdict was memoised. + respondWithRules({ '/workspace/repo-a': { paths: ['**/secret.ts'] } }); + now += 31 * 60 * 1000; + const afterRuleAdded = await remoteContentExclusion.isIgnored(file, CancellationToken.None); + + expect({ beforeRuleAdded, afterRuleAdded }).toEqual({ beforeRuleAdded: false, afterRuleAdded: true }); + }); + + test('stops excluding a file once the server removes the last rule', async () => { + routeToRepos(['/workspace/repo-a']); + respondWithRules({ '/workspace/repo-a': { paths: ['**/secret.ts'] } }); + + const file = URI.file('/workspace/repo-a/secret.ts'); + const whileExcluded = await remoteContentExclusion.isIgnored(file, CancellationToken.None); + + // Replacing the rules with an empty set must retire the memoised exclusion. + respondWithRules({}); + now += 31 * 60 * 1000; + const afterRuleRemoved = await remoteContentExclusion.isIgnored(file, CancellationToken.None); + + expect({ whileExcluded, afterRuleRemoved }).toEqual({ whileExcluded: true, afterRuleRemoved: false }); + }); + + test('keeps memoised verdicts when a refresh returns identical rules', async () => { + routeToRepos(['/workspace/repo-a']); + respondWithRules({ '/workspace/repo-a': { paths: ['**/secret.ts'] } }); + + await remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/secret.ts'), CancellationToken.None); + const other = URI.file('/workspace/repo-a/index.ts'); + await remoteContentExclusion.isIgnored(other, CancellationToken.None); + + // An unchanged refresh should not force previously computed verdicts to be recomputed. + now += 31 * 60 * 1000; + await remoteContentExclusion.isIgnored(other, CancellationToken.None); + mockGitService.getRepositoryFetchUrlsCallCount = 0; + const afterUnchangedRefresh = await remoteContentExclusion.isIgnored(other, CancellationToken.None); + + expect({ afterUnchangedRefresh, gitLookups: mockGitService.getRepositoryFetchUrlsCallCount }).toEqual({ afterUnchangedRefresh: false, gitLookups: 0 }); + }); + }); + + describe('in-flight requests', () => { + test('joins a slow in-flight request instead of issuing a duplicate', async () => { + routeToRepos(['/workspace/repo-a']); + mockCAPIClientService.blockRequests(); + + const first = remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/one.ts'), CancellationToken.None); + await waitForRequests(1); + const whileDispatched = mockCAPIClientService.requestCount; + + // A second lookup for the same repo arrives while the request is still open. + const second = remoteContentExclusion.isIgnored(URI.file('/workspace/repo-a/two.ts'), CancellationToken.None); + await settleBatchWindow(); + const afterSecondLookup = mockCAPIClientService.requestCount; + + mockCAPIClientService.releaseRequests(); + await Promise.all([first, second]); + + expect({ whileDispatched, afterSecondLookup }).toEqual({ whileDispatched: 1, afterSecondLookup: 1 }); + }); + + test('releases callers waiting on queued batches when disposed', async () => { + // More batches than the limiter runs concurrently, so some are still queued on dispose. + const repoRoots = Array.from({ length: 80 }, (_, i) => `/workspace/repo${i}`); + routeToRepos(repoRoots); + mockCAPIClientService.blockRequests(); + + const lookups = Promise.all(repoRoots.map(root => remoteContentExclusion.isIgnored(URI.file(`${root}/file.ts`), CancellationToken.None))); + await waitForRequests(1); + + remoteContentExclusion.dispose(); + + // The limiter drops queued batches without running them, so their waiters must be + // settled by dispose or these lookups would never resolve. + await expect(lookups).resolves.toHaveLength(80); + mockCAPIClientService.releaseRequests(); + }); + }); }); diff --git a/extensions/copilot/src/shared-fetch-utils/common/middleware/rateLimitBackoffMiddleware.ts b/extensions/copilot/src/shared-fetch-utils/common/middleware/rateLimitBackoffMiddleware.ts new file mode 100644 index 00000000000..0830e48c68e --- /dev/null +++ b/extensions/copilot/src/shared-fetch-utils/common/middleware/rateLimitBackoffMiddleware.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { FetchBlockedError, type FetchMiddleware, type HttpHeaders } from '../fetchTypes'; + +export class RateLimitBackoffError extends FetchBlockedError { + constructor(retryAfterMs: number) { + super(`Rate limited, backing off for ${Math.round(retryAfterMs / 1000)}s`, retryAfterMs); + } +} + +export interface RateLimitBackoffOptions { + /** Delay applied to the first rate limit when the server sends no hint. */ + readonly initialDelayMs?: number; + readonly maxDelayMs?: number; + readonly multiplier?: number; + /** Injectable clock, primarily so tests do not have to wait on the wall clock. */ + readonly now?: () => number; +} + +/** + * Blocks subsequent requests once the server reports a rate limit, so a client that shares a + * quota with other callers cannot dig itself deeper. + * + * The wait is taken from the server whenever it says so, via `Retry-After` or GitHub's + * `x-ratelimit-remaining`/`x-ratelimit-reset` pair, and otherwise falls back to an + * exponentially increasing delay. Either way the wait is capped at {@link maxDelayMs}. The + * backoff resets on the first response that is not rate limited. + * + * This complements {@link serverErrorBackoffMiddleware}, which covers `5xx` responses. + */ +export function rateLimitBackoffMiddleware(options?: RateLimitBackoffOptions): FetchMiddleware { + const { + initialDelayMs = 60_000, + maxDelayMs = 15 * 60_000, + multiplier = 2, + now = Date.now, + } = options ?? {}; + let consecutiveRateLimits = 0; + let blockedUntil = 0; + + return (next) => async (request) => { + if (now() < blockedUntil) { + throw new RateLimitBackoffError(blockedUntil - now()); + } + + const response = await next(request); + + if (!isRateLimited(response.status, response.headers)) { + // A response that was already in flight when a concurrent request hit a rate limit must + // not clear that newer block, otherwise later calls reach the server during the window + // the server asked us to wait out. + if (now() >= blockedUntil) { + consecutiveRateLimits = 0; + blockedUntil = 0; + } + return response; + } + + consecutiveRateLimits++; + const hinted = retryAfterFromHeaders(response.headers, now); + const backoff = hinted ?? initialDelayMs * Math.pow(multiplier, consecutiveRateLimits - 1); + // `maxDelayMs` caps the server's hint too, so a bogus or hostile `Retry-After` cannot stall + // the client indefinitely. Retrying a little early simply re-arms the backoff. + const delay = Math.min(backoff, maxDelayMs); + blockedUntil = now() + delay; + throw new RateLimitBackoffError(delay); + }; +} + +function isRateLimited(status: number, headers: HttpHeaders): boolean { + if (status === 429) { + return true; + } + // GitHub reports an exhausted primary rate limit as a 403 carrying the quota headers, which + // has to be told apart from a plain authorization failure. + return status === 403 && readHeader(headers, 'x-ratelimit-remaining') === '0'; +} + +function retryAfterFromHeaders(headers: HttpHeaders, now: () => number): number | undefined { + const retryAfter = Number(readHeader(headers, 'retry-after')); + if (Number.isFinite(retryAfter) && retryAfter > 0) { + return retryAfter * 1000; + } + const reset = Number(readHeader(headers, 'x-ratelimit-reset')); + if (Number.isFinite(reset) && reset > 0) { + return Math.max(0, reset * 1000 - now()); + } + return undefined; +} + +/** HTTP header names are case insensitive, but not every headers implementation normalises them. */ +function readHeader(headers: HttpHeaders, lowerCaseName: string): string | undefined { + const canonical = lowerCaseName.replace(/(^|-)([a-z])/g, (_, separator: string, char: string) => separator + char.toUpperCase()); + return headers.get(lowerCaseName) ?? headers.get(canonical) ?? undefined; +} diff --git a/extensions/copilot/src/shared-fetch-utils/common/test/rateLimitBackoffMiddleware.spec.ts b/extensions/copilot/src/shared-fetch-utils/common/test/rateLimitBackoffMiddleware.spec.ts new file mode 100644 index 00000000000..868185b888c --- /dev/null +++ b/extensions/copilot/src/shared-fetch-utils/common/test/rateLimitBackoffMiddleware.spec.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, it } from 'vitest'; +import type { HttpHeaders, HttpRequest, HttpResponse } from '../fetchTypes'; +import { RateLimitBackoffError, rateLimitBackoffMiddleware } from '../middleware/rateLimitBackoffMiddleware'; + +function makeHeaders(entries: Record = {}): HttpHeaders { + const map = new Map(Object.entries(entries).map(([key, value]) => [key.toLowerCase(), value])); + return { get: (name: string) => map.get(name.toLowerCase()) ?? null }; +} + +function makeResponse(status: number, headers: Record = {}): HttpResponse { + return { + status, + headers: makeHeaders(headers), + body: null, + async text() { return ''; }, + async json() { return {}; }, + }; +} + +const request: HttpRequest = { url: 'https://api.github.com/example', headers: {} }; + +describe('rateLimitBackoffMiddleware', () => { + let now: number; + let calls: number; + + beforeEach(() => { + now = Date.UTC(2026, 0, 1); + calls = 0; + }); + + /** Wires the middleware around a stub that always returns the given response. */ + function withResponse(response: HttpResponse) { + return rateLimitBackoffMiddleware({ now: () => now })(async () => { + calls++; + return response; + }); + } + + async function expectBlocked(fetchFn: (request: HttpRequest) => Promise): Promise { + try { + await fetchFn(request); + throw new Error('expected the request to be blocked'); + } catch (err) { + expect(err).toBeInstanceOf(RateLimitBackoffError); + return (err as RateLimitBackoffError).retryAfterMs; + } + } + + it('passes successful responses straight through', async () => { + const fetchFn = withResponse(makeResponse(200)); + + expect((await fetchFn(request)).status).toBe(200); + }); + + it('leaves a plain 403 alone so auth failures are not mistaken for rate limits', async () => { + const fetchFn = withResponse(makeResponse(403)); + + expect((await fetchFn(request)).status).toBe(403); + }); + + it('blocks further requests after a 429 and honours retry-after', async () => { + const fetchFn = withResponse(makeResponse(429, { 'retry-after': '120' })); + + const firstDelay = await expectBlocked(fetchFn); + // The second attempt is refused locally, without reaching the server. + const callsAfterBlock = calls; + await expectBlocked(fetchFn); + + expect({ firstDelay, callsAfterBlock, callsNow: calls }).toEqual({ firstDelay: 120_000, callsAfterBlock: 1, callsNow: 1 }); + }); + + it('treats an exhausted quota 403 as a rate limit and waits for the reset', async () => { + const resetEpochSeconds = Math.floor((now + 10 * 60_000) / 1000); + const fetchFn = withResponse(makeResponse(403, { + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': String(resetEpochSeconds) + })); + + const delay = await expectBlocked(fetchFn); + + // Still blocked partway through the window, allowed through once it passes. + now += 5 * 60_000; + await expectBlocked(fetchFn); + const callsDuringWindow = calls; + + now += 6 * 60_000; + await expectBlocked(fetchFn); + + expect({ delay, callsDuringWindow, callsAfterWindow: calls }).toEqual({ delay: 10 * 60_000, callsDuringWindow: 1, callsAfterWindow: 2 }); + }); + + it('backs off exponentially when the server sends no hint', async () => { + const fetchFn = withResponse(makeResponse(429)); + + const delays: number[] = []; + for (let attempt = 0; attempt < 3; attempt++) { + delays.push(await expectBlocked(fetchFn)); + now += delays[delays.length - 1]; + } + + expect(delays).toEqual([60_000, 120_000, 240_000]); + }); + + it('caps the server hint so a bogus retry-after cannot stall the client', async () => { + const fetchFn = rateLimitBackoffMiddleware({ maxDelayMs: 600_000, now: () => now })(async () => { + calls++; + return makeResponse(429, { 'retry-after': '86400' }); + }); + + expect(await expectBlocked(fetchFn)).toBe(600_000); + }); + + it('keeps a newer block when an older successful response lands afterwards', async () => { + let releaseSlowResponse = () => { }; + const slowResponse = new Promise(resolve => { releaseSlowResponse = resolve; }); + let isFirstCall = true; + const fetchFn = rateLimitBackoffMiddleware({ now: () => now })(async () => { + calls++; + if (isFirstCall) { + isFirstCall = false; + await slowResponse; + return makeResponse(200); + } + return makeResponse(429, { 'retry-after': '120' }); + }); + + // A slow success is still in flight when a second request is rate limited. + const inFlight = fetchFn(request); + await expectBlocked(fetchFn); + releaseSlowResponse(); + await inFlight; + + // The block established by the newer 429 must survive the older success. + const delayAfterSuccess = await expectBlocked(fetchFn); + + expect({ delayAfterSuccess, calls }).toEqual({ delayAfterSuccess: 120_000, calls: 2 }); + }); + + it('resets the backoff once a request succeeds', async () => { + let status = 429; + const fetchFn = rateLimitBackoffMiddleware({ now: () => now })(async () => { + calls++; + return makeResponse(status); + }); + + await expectBlocked(fetchFn); + now += 60_000; + + status = 200; + await fetchFn(request); + + // Back to the initial delay rather than continuing to double. + status = 429; + expect(await expectBlocked(fetchFn)).toBe(60_000); + }); +}); diff --git a/extensions/copilot/src/util/common/chatResponseStreamImpl.ts b/extensions/copilot/src/util/common/chatResponseStreamImpl.ts index 07db8ae9aa7..80cbbe79951 100644 --- a/extensions/copilot/src/util/common/chatResponseStreamImpl.ts +++ b/extensions/copilot/src/util/common/chatResponseStreamImpl.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { ChatResponseReferencePartStatusKind } from '@vscode/prompt-tsx'; -import type { ChatQuestion, ChatResponseFileTree, ChatResponseStream, ChatResultUsage, ChatToolInvocationStreamData, ChatVulnerability, ChatWorkspaceFileEdit, Command, ExtendedChatResponsePart, Location, NotebookEdit, Progress, ThinkingDelta, Uri } from 'vscode'; -import { ChatHookType, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, MarkdownString, TextEdit } from '../../vscodeTypes'; +import type { ChatQuestion, ChatResponseFileTree, ChatResponseStream, ChatResponseVoiceProgressStage, ChatResultUsage, ChatToolInvocationStreamData, ChatVulnerability, ChatWorkspaceFileEdit, Command, ExtendedChatResponsePart, Location, NotebookEdit, Progress, ThinkingDelta, Uri } from 'vscode'; +import { ChatHookType, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseVoiceProgressPart, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, MarkdownString, TextEdit } from '../../vscodeTypes'; import type { ThemeIcon } from '../vs/base/common/themables'; @@ -135,6 +135,10 @@ export class ChatResponseStreamImpl implements FinalizableChatResponseStream { this._push(new ChatResponseHookPart(hookType, stopReason, systemMessage)); } + voiceProgress(id: ChatResponseVoiceProgressStage, value: string): void { + this._push(new ChatResponseVoiceProgressPart(id, value)); + } + button(command: Command): void { this._push(new ChatResponseCommandButtonPart(command)); } diff --git a/extensions/copilot/src/util/common/test/shims/chatTypes.ts b/extensions/copilot/src/util/common/test/shims/chatTypes.ts index b585aae679c..066fad29fa7 100644 --- a/extensions/copilot/src/util/common/test/shims/chatTypes.ts +++ b/extensions/copilot/src/util/common/test/shims/chatTypes.ts @@ -80,6 +80,13 @@ export class ChatResponseHookPart { } } +export class ChatResponseVoiceProgressPart { + constructor( + readonly id: vscode.ChatResponseVoiceProgressStage, + readonly value: string, + ) { } +} + export class ChatResponseExternalEditPart { applied: Thenable; didGetApplied!: (value: string) => void; diff --git a/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts b/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts index a2aa56f6953..14966f1bf5c 100644 --- a/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts +++ b/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts @@ -18,7 +18,7 @@ import { SnippetString } from '../../../vs/workbench/api/common/extHostTypes/sni import { SnippetTextEdit } from '../../../vs/workbench/api/common/extHostTypes/snippetTextEdit'; import { SymbolInformation, SymbolKind } from '../../../vs/workbench/api/common/extHostTypes/symbolInformation'; import { EndOfLine, TextEdit } from '../../../vs/workbench/api/common/extHostTypes/textEdit'; -import { AISearchKeyword, ChatErrorLevel, ChatInputNotificationSeverity, ChatQuestion, ChatQuestionType, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRequestTurn2, ChatResponseAnchorPart, ChatResponseAutoModeResolutionPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseQuestionCarouselPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, ChatSessionStatus, ChatSubagentToolInvocationData, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, McpHttpServerDefinition, McpStdioServerDefinition, McpToolInvocationContentData, TextSearchMatch2 } from './chatTypes'; +import { AISearchKeyword, ChatErrorLevel, ChatInputNotificationSeverity, ChatQuestion, ChatQuestionType, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRequestTurn2, ChatResponseAnchorPart, ChatResponseAutoModeResolutionPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseQuestionCarouselPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseVoiceProgressPart, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, ChatSessionStatus, ChatSubagentToolInvocationData, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, McpHttpServerDefinition, McpStdioServerDefinition, McpToolInvocationContentData, TextSearchMatch2 } from './chatTypes'; import { TextDocumentChangeReason, TextEditorSelectionChangeKind, WorkspaceEdit } from './editing'; import { ChatLocation, ChatVariableLevel, DiagnosticSeverity, ExtensionMode, FileType, TextEditorCursorStyle, TextEditorLineNumbersStyle, TextEditorRevealType } from './enums'; import { t } from './l10n'; @@ -60,6 +60,7 @@ const shim: typeof vscodeTypes = { ChatResponseWarningPart, ChatResponseInfoPart, ChatResponseHookPart, + ChatResponseVoiceProgressPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseCodeCitationPart, diff --git a/extensions/copilot/src/vscodeTypes.ts b/extensions/copilot/src/vscodeTypes.ts index a931ec06d7c..630b4a1e33f 100644 --- a/extensions/copilot/src/vscodeTypes.ts +++ b/extensions/copilot/src/vscodeTypes.ts @@ -27,6 +27,7 @@ export import ChatResponseClearToPreviousToolInvocationReason = vscode.ChatRespo export import ChatResponseMarkdownPart = vscode.ChatResponseMarkdownPart; export import ChatResponseThinkingProgressPart = vscode.ChatResponseThinkingProgressPart; export import ChatResponseHookPart = vscode.ChatResponseHookPart; +export import ChatResponseVoiceProgressPart = vscode.ChatResponseVoiceProgressPart; export import ChatHookType = vscode.ChatHookType; export import ChatResponseFileTreePart = vscode.ChatResponseFileTreePart; export import ChatResponseAnchorPart = vscode.ChatResponseAnchorPart; diff --git a/extensions/markdown-language-features/markdown-editor-src/editor.ts b/extensions/markdown-language-features/markdown-editor-src/editor.ts index cbad01b0f3a..7e1f83441ba 100644 --- a/extensions/markdown-language-features/markdown-editor-src/editor.ts +++ b/extensions/markdown-language-features/markdown-editor-src/editor.ts @@ -65,8 +65,12 @@ class Editor extends Disposable { break; } case 'update': { + // `replaceSourceText` (not `sourceText.set`) applies authoritative host + // text: it maps the selection through the change and clears stale + // pending-paragraph state, so the caret stays valid after an undo shrinks + // the document. The guard stops this echoing back as a user edit. this.isUpdatingFromExtension = true; - this.model.sourceText.set(new StringValue(message.content), undefined); + this.model.replaceSourceText(new StringValue(message.content)); this.isUpdatingFromExtension = false; break; } @@ -157,7 +161,16 @@ class Editor extends Disposable { }, })); - this._register(new EditorController(model, view)); + // Wire history chords (undo/redo) to the extension so they run against the + // backing TextDocument's own undo stack. `record` is deliberately omitted: + // the TextDocument owns the history, and a second local stack would drift + // from the Edit menu, dirty state and hot exit. + this._register(new EditorController(model, view, { + historyStrategy: { + undo: () => this.#vscode.postMessage({ type: 'history', command: 'undo' }), + redo: () => this.#vscode.postMessage({ type: 'history', command: 'redo' }), + }, + })); host.appendChild(view.element); // Render comments as the VS Code V2 markdown cards. The card colours come diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index 81cd695b3d7..b1992cfe641 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-37", + "@vscode/markdown-editor": "^0.0.2-40", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", "katex": "^0.16.33", @@ -632,9 +632,9 @@ "integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA==" }, "node_modules/@vscode/markdown-editor": { - "version": "0.0.2-37", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-37.tgz", - "integrity": "sha512-Glln7RyQ7dIl2v3OwiAGAcD+v3SdWjHRKdPr7XniVgVNOclnZeVsF29B67h4uDYSc2qEaqmAaGKXMG+0d+BQNA==", + "version": "0.0.2-40", + "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-40.tgz", + "integrity": "sha512-NPUmKKHDvauUM61SQpruezqVSi5Ly/+zmGZG89MFk0LZDYTklhGEiPhxQHvH6e3m+qaVbY268OkbajGEYRq7OQ==", "license": "MIT", "dependencies": { "@vscode/codicons": "^0.0.45", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index f50f3a85734..902ad09437c 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -909,7 +909,7 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-37", + "@vscode/markdown-editor": "^0.0.2-40", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", "katex": "^0.16.33", diff --git a/extensions/markdown-language-features/src/preview/lineDiff.ts b/extensions/markdown-language-features/src/preview/lineDiff.ts index bd5c1d98781..855e93f2e78 100644 --- a/extensions/markdown-language-features/src/preview/lineDiff.ts +++ b/extensions/markdown-language-features/src/preview/lineDiff.ts @@ -9,6 +9,7 @@ import type { MarkdownPreviewChangeIndicator, MarkdownPreviewInnerChange, Markdo interface LineChanges { readonly added: readonly number[]; readonly deleted: readonly number[]; + readonly changedLineRanges: readonly ChangedLineRange[]; readonly originalToModified: readonly number[]; readonly modifiedToOriginal: readonly number[]; readonly originalInnerChanges: readonly MarkdownPreviewInnerChange[]; @@ -21,7 +22,7 @@ interface LineMappings { readonly modifiedToOriginal: number[]; } -type ChangedLineRange = Pick; +export type ChangedLineRange = Pick; export class MarkdownPreviewLineDiffProvider { @@ -55,6 +56,10 @@ export class MarkdownPreviewLineDiffProvider { return added.length || innerChanges.length || changeIndicators.length ? { added, innerChanges, changeIndicators } : undefined; } + public async getChangedLineRanges(): Promise { + return (await this.#getLineChanges()).changedLineRanges; + } + public async translateOriginalLineToModified(line: number): Promise { return translateLine(line, (await this.#getLineChanges()).originalToModified, this.#modifiedDocument.lineCount); } @@ -143,7 +148,7 @@ async function computeLineChanges(originalDocument: vscode.TextDocument, modifie const splitChangedLineRanges = splitChangedLineRangesByMarkdownBlocks(changedLineRanges, originalDocument, modifiedDocument); const changeIndicators = createChangeIndicators(splitChangedLineRanges, originalDocument, modifiedDocument, originalInnerChanges, modifiedInnerChanges); - return { added, deleted, originalInnerChanges, modifiedInnerChanges, changeIndicators, ...mappings }; + return { added, deleted, changedLineRanges, originalInnerChanges, modifiedInnerChanges, changeIndicators, ...mappings }; } function createChangeIndicators(ranges: readonly ChangedLineRange[], originalDocument: vscode.TextDocument, modifiedDocument: vscode.TextDocument, originalInnerChanges: readonly MarkdownPreviewInnerChange[], modifiedInnerChanges: readonly MarkdownPreviewInnerChange[]): MarkdownPreviewChangeIndicator[] { diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index 32ed30cfa1d..cbcdad0492a 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { Disposable } from '../util/dispose'; import { MdLinkOpener } from '../util/openDocumentLink'; import { getMarkdownLocalResourceRoots } from '../util/resources'; +import { ChangedLineRange, MarkdownPreviewLineDiffProvider } from './lineDiff'; /** * Experimental hybrid (WYSIWYG) Markdown editor backed by the @@ -43,6 +44,18 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT webviewPanel: vscode.WebviewPanel, token: vscode.CancellationToken, ): Promise { + await this.#resolveEditor(document, webviewPanel, token); + } + + public async resolveCustomTextEditorInlineDiff( + documents: vscode.CustomEditorDiffDocuments, + webviewPanel: vscode.WebviewPanel, + token: vscode.CancellationToken, + ): Promise { + await this.#resolveEditor(documents.modified, webviewPanel, token, documents.original); + } + + async #resolveEditor(document: vscode.TextDocument, webviewPanel: vscode.WebviewPanel, token: vscode.CancellationToken, originalDocument?: vscode.TextDocument): Promise { if (!vscode.workspace.isTrusted) { const cancel = { title: vscode.l10n.t("Cancel"), isCloseAffordance: true }; const openAnyway = { title: vscode.l10n.t("Open Anyway") }; @@ -61,9 +74,12 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT } } + if (token.isCancellationRequested) { + return; + } const webview = webviewPanel.webview; this.#configureWebview(document.uri, webview); - this.#wireSingle(document, webviewPanel); + this.#wireSingle(document, webviewPanel, originalDocument); } #configureWebview(documentUri: vscode.Uri, webview: vscode.Webview): void { @@ -76,7 +92,7 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT webview.html = this.#getHtml(documentUri, webview); } - #wireSingle(document: vscode.TextDocument, webviewPanel: vscode.WebviewPanel): void { + #wireSingle(document: vscode.TextDocument, webviewPanel: vscode.WebviewPanel, originalDocument?: vscode.TextDocument): void { const webview = webviewPanel.webview; let isUpdatingFromWebview = false; let editQueue = Promise.resolve(); @@ -93,6 +109,20 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT await this.#globalState.update(MarkdownEditorProvider.#readonlyStateKey, !!message.readonly); break; } + case 'history': { + // The TextDocument owns undo/redo, so route the chord to the built-in + // command; the active custom editor input scopes it to this resource's + // history, shared with the Edit menu and Command Palette. Drain any + // in-flight edit first and only act while this panel is active, so the + // chord cannot race a pending edit or land on a different document. + if (message.command === 'undo' || message.command === 'redo') { + await editQueue; + if (webviewPanel.active) { + await vscode.commands.executeCommand(message.command); + } + } + break; + } case 'openLink': { await this.#linkOpener.openDocumentLink(message.href as string, document.uri); break; @@ -129,7 +159,9 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT }); const highlight = this.#wireHighlight(webview); - const quickDiff = this.#wireQuickDiff(document, webview); + const quickDiff = originalDocument + ? this.#wireDocumentDiff(originalDocument, document, webview) + : this.#wireQuickDiff(document, webview); const comments = this.#wireComments(document, webview); const onDidGrantWorkspaceTrust = vscode.workspace.onDidGrantWorkspaceTrust(() => { this.#configureWebview(document.uri, webview); @@ -184,6 +216,32 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT return vscode.Disposable.from(diffProvider, onChange, onMessage, onDocumentChange); } + #wireDocumentDiff(originalDocument: vscode.TextDocument, modifiedDocument: vscode.TextDocument, webview: vscode.Webview): vscode.Disposable { + const lineDiffProvider = new MarkdownPreviewLineDiffProvider(originalDocument, modifiedDocument); + const postMarkers = async () => { + const originalVersion = originalDocument.version; + const modifiedVersion = modifiedDocument.version; + const changes = await lineDiffProvider.getChangedLineRanges(); + if (originalVersion !== originalDocument.version || modifiedVersion !== modifiedDocument.version) { + return; + } + webview.postMessage({ type: 'gutterMarkers', markers: lineRangesToGutterMarkers(modifiedDocument, changes) }); + }; + + const onMessage = webview.onDidReceiveMessage(message => { + if (message.type === 'ready') { + void postMarkers(); + } + }); + const onDocumentChange = vscode.workspace.onDidChangeTextDocument(event => { + if (event.document.uri.toString() === originalDocument.uri.toString() || event.document.uri.toString() === modifiedDocument.uri.toString()) { + void postMarkers(); + } + }); + + return vscode.Disposable.from(onMessage, onDocumentChange); + } + /** * Bridges the workbench's agent/session comments (the same store the code * editor renders its comments from) to the webview: existing comments are @@ -337,3 +395,20 @@ function toGutterMarkers(document: vscode.TextDocument, changes: readonly vscode } return markers; } + +export function lineRangesToGutterMarkers(document: vscode.TextDocument, changes: readonly ChangedLineRange[]): GutterMarkerMessage[] { + return changes.map(change => { + if (change.modifiedRange.isEmpty) { + const offset = document.offsetAt(change.modifiedRange.start); + return { start: offset, endExclusive: offset, type: 'deleted' }; + } + + const start = document.offsetAt(change.modifiedRange.start); + const endExclusive = document.offsetAt(document.lineAt(change.modifiedRange.end.line - 1).range.end); + return { + start, + endExclusive, + type: change.originalRange.isEmpty ? 'added' : 'modified', + }; + }); +} diff --git a/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts b/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts new file mode 100644 index 00000000000..6faec966a64 --- /dev/null +++ b/extensions/markdown-language-features/src/test/markdownEditorProvider.test.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import 'mocha'; +import * as vscode from 'vscode'; +import { lineRangesToGutterMarkers } from '../preview/markdownEditorProvider'; + +suite('Markdown editor diff', () => { + test('maps modified-side line changes to quick diff gutter markers', async () => { + const document = await vscode.workspace.openTextDocument({ language: 'markdown', content: 'one\ntwo changed\nthree added\nfour\n' }); + const changes = [ + { originalRange: new vscode.Range(1, 0, 2, 0), modifiedRange: new vscode.Range(1, 0, 2, 0) }, + { originalRange: new vscode.Range(2, 0, 2, 0), modifiedRange: new vscode.Range(2, 0, 3, 0) }, + { originalRange: new vscode.Range(3, 0, 4, 0), modifiedRange: new vscode.Range(3, 0, 3, 0) }, + ]; + + assert.deepStrictEqual(lineRangesToGutterMarkers(document, changes), [ + { start: 4, endExclusive: 15, type: 'modified' }, + { start: 16, endExclusive: 27, type: 'added' }, + { start: 28, endExclusive: 28, type: 'deleted' }, + ]); + }); +}); diff --git a/package-lock.json b/package-lock.json index 6ab302055e3..7c4b3c2f112 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "^1.0.73", - "@github/copilot-sdk": "^1.0.8", + "@github/copilot": "^1.0.77", + "@github/copilot-sdk": "^1.0.9-preview.1", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -23,7 +23,7 @@ "@microsoft/mxc-sdk": "0.6.1", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-27", + "@vscode/codicons": "^0.0.46-28", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", @@ -1113,9 +1113,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.73.tgz", - "integrity": "sha512-8I2Ejg2CX/PQA3c2H8W1zuqhniCeR1q1/bD8CrV53/ZLw8GF7DAV0xQpwa8ELYvFgjXb6AADojafCKwdbVef+A==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.77.tgz", + "integrity": "sha512-nkTtDPKvsClAByPPqnD/57vK7YIBK1dgiv7aVc9uO3rxKCyqiqYaBqwi8pMzesvGP3yl+//+iMzaBXNWEcZVWQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -1124,20 +1124,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.73", - "@github/copilot-darwin-x64": "1.0.73", - "@github/copilot-linux-arm64": "1.0.73", - "@github/copilot-linux-x64": "1.0.73", - "@github/copilot-linuxmusl-arm64": "1.0.73", - "@github/copilot-linuxmusl-x64": "1.0.73", - "@github/copilot-win32-arm64": "1.0.73", - "@github/copilot-win32-x64": "1.0.73" + "@github/copilot-darwin-arm64": "1.0.77", + "@github/copilot-darwin-x64": "1.0.77", + "@github/copilot-linux-arm64": "1.0.77", + "@github/copilot-linux-x64": "1.0.77", + "@github/copilot-linuxmusl-arm64": "1.0.77", + "@github/copilot-linuxmusl-x64": "1.0.77", + "@github/copilot-win32-arm64": "1.0.77", + "@github/copilot-win32-x64": "1.0.77" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.73.tgz", - "integrity": "sha512-5jv7t2sw35/zI0cPze38hG6239NT5/q/Emjx6gLibYkolDqMDJjpm17Ps7tc8oafUEOiMQMb+ar7+qi6rSiGJA==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.77.tgz", + "integrity": "sha512-sCWSH5+Flm/OxFe7dzsBfyj7ADBkzkR54Sz5NGw7dtcVVEOnVUkZLjEtNmZ1t5QRD4Sf1+g/DiwgJEbsR9xR1w==", "cpu": [ "arm64" ], @@ -1151,9 +1151,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.73.tgz", - "integrity": "sha512-l794k6Ahb11AG2FQT/P4TEWxWblzM1h8aQQCzG8jBWp8dfwjhyYjJ+d+0CWQzM3Fc1ddNUZRjKXCUsfvFjiZhQ==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.77.tgz", + "integrity": "sha512-ReNlB+g+OBiqHwmY5leJBIyvHZQcjyWL/OY8aVimHyESn2ToPKP3eUNTzSUJvvbPM6+0LXwEpijLedkRd2Cn1g==", "cpu": [ "x64" ], @@ -1167,9 +1167,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.73.tgz", - "integrity": "sha512-Zu0W5nupJjNeem0brqU/pG+VY0IWr6EWr/FsC90g5SEDiaM4VhVNVWcz8t0E3DQCSYetV6IBaNMtjs/3uIIiDQ==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.77.tgz", + "integrity": "sha512-A8j/WBPFvV5WfLbgnIIQLUVuFRAR7kLyc5WgId6XLCu1ARbkRM7353zz9mEXXwjc6LqotHVg80ooANJjNtmSPg==", "cpu": [ "arm64" ], @@ -1186,9 +1186,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.73.tgz", - "integrity": "sha512-k33XIr6/PVp+K+5F/zv3No4PPaNImvHz73mcbIw63oxh5iiacXjgr0WqbBIS5s/rkhOWjNPIkbof/TTPZ7mQjA==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.77.tgz", + "integrity": "sha512-2eefKkdUnQ1Y8oxyRyexHBXVpuSmrfEM8XJauquVjPc0JqF5nab9axwpFPzrRSF1GB+25F9tUK2sDQRyp08wag==", "cpu": [ "x64" ], @@ -1205,9 +1205,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.73.tgz", - "integrity": "sha512-HJWzhfD3oaiIgfRAHkNWzp17fELtshqM9HVN5n+lFEmSO2EETCEh0P1lhJc4m+FYfXSJnL0raAqVuyaNMuPoPw==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.77.tgz", + "integrity": "sha512-YtltOZQp8plytSKSGTWWKbOx3QD8iZH04sLtKTrYs6nu5UalIgFPoMkwamy1gh7h5EBkeVxD2s3epVrlvP4X4w==", "cpu": [ "arm64" ], @@ -1224,9 +1224,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.73.tgz", - "integrity": "sha512-/BpOXSb16wHEu8I1SaKiLszQ4Kvu4+Z4uCn7W0bv4xI4fPZwTEG0u3zgaI2W9Ao3+aBl0XRpPmpWzE9ziYEq+w==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.77.tgz", + "integrity": "sha512-owINwPgHU/ZZBwFhVPgkgGjLkF6e4QbdofADvKMdKJWV2+7oWjXUIlPA4/PwraD2Gkuu583l7m0XLL27TN8oUA==", "cpu": [ "x64" ], @@ -1243,12 +1243,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.8.tgz", - "integrity": "sha512-dbahVsyt2aX8qqtOOtmYNe40MnvzSvOSHYFFgoFK7gHZSTNz9QgOht8b1sCCJlcXaFAn/w+5qNc7CwWoCjpQ0g==", + "version": "1.0.9-preview.1", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.9-preview.1.tgz", + "integrity": "sha512-/hUYdxpa4HL57uKmRCLmcg31wWjZPKGBbBfIhVqJdWXkp4/E6lbtHoiIbT5SLCPpf4c8Ka5zw356z+k4Nn/diA==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.73", + "@github/copilot": "^1.0.76-5", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" @@ -1267,9 +1267,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.73.tgz", - "integrity": "sha512-DbPeXiYzQjpOy9oboaBvuCzjRwfcL987c3bG09cK1crdCDrKfkTJ7NXpcp1KWRPIRFO1FQm1qToNE89J+L3uvg==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.77.tgz", + "integrity": "sha512-l5oQaMLCRup0nmmpbqOAYEAJ5YWgNlaoO0psNaKDzvTbdzEJRZqib2t7+p3bgoDpK7SB/m8m1uxFC4XT3hlprg==", "cpu": [ "arm64" ], @@ -1283,9 +1283,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.73.tgz", - "integrity": "sha512-8D3E1l5i+N5Eq8HIOQpx+Zbcb3MXdFxszksM2gqq175Z1S7Zna67oY4GoR3psxlbIpSyHKiLEBWYiaps6ayHWw==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.77.tgz", + "integrity": "sha512-8Mo9y3/8CVU2w35WqwSiRMTGH1kKHR3URPSJYF4J4OG8L7NOEy2fafXR9Tuq3H21Srg3OzFkl/A+Taunqz9KcA==", "cpu": [ "x64" ], @@ -4171,9 +4171,9 @@ } }, "node_modules/@vscode/codicons": { - "version": "0.0.46-27", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-27.tgz", - "integrity": "sha512-R6lEiJzbDrcrIT+pjM0aauVFjGXVHj4K9ClMzI4aOQWZH/fSswOJljypunppD81Mjwia1RzxA5LiBAlBJUI5PA==", + "version": "0.0.46-28", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-28.tgz", + "integrity": "sha512-Rj3yNS72a7N0FN/JeT/muXRCBzNNvnbQ99B++bCDyaOZkbHAfP/3DS7YoiAxa4z+ZiG5ZowJ5b9ncB40YGe1ig==", "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { diff --git a/package.json b/package.json index b8a8952ef4a..b2b925627ad 100644 --- a/package.json +++ b/package.json @@ -97,8 +97,8 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "^1.0.73", - "@github/copilot-sdk": "^1.0.8", + "@github/copilot": "^1.0.77", + "@github/copilot-sdk": "^1.0.9-preview.1", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -109,7 +109,7 @@ "@microsoft/mxc-sdk": "0.6.1", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-27", + "@vscode/codicons": "^0.0.46-28", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", diff --git a/remote/package-lock.json b/remote/package-lock.json index 840bc7a80b2..0367a56d604 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,8 +8,8 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@github/copilot": "^1.0.73", - "@github/copilot-sdk": "^1.0.8", + "@github/copilot": "^1.0.77", + "@github/copilot-sdk": "^1.0.9-preview.1", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.1", @@ -61,9 +61,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.73.tgz", - "integrity": "sha512-8I2Ejg2CX/PQA3c2H8W1zuqhniCeR1q1/bD8CrV53/ZLw8GF7DAV0xQpwa8ELYvFgjXb6AADojafCKwdbVef+A==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.77.tgz", + "integrity": "sha512-nkTtDPKvsClAByPPqnD/57vK7YIBK1dgiv7aVc9uO3rxKCyqiqYaBqwi8pMzesvGP3yl+//+iMzaBXNWEcZVWQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -72,20 +72,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.73", - "@github/copilot-darwin-x64": "1.0.73", - "@github/copilot-linux-arm64": "1.0.73", - "@github/copilot-linux-x64": "1.0.73", - "@github/copilot-linuxmusl-arm64": "1.0.73", - "@github/copilot-linuxmusl-x64": "1.0.73", - "@github/copilot-win32-arm64": "1.0.73", - "@github/copilot-win32-x64": "1.0.73" + "@github/copilot-darwin-arm64": "1.0.77", + "@github/copilot-darwin-x64": "1.0.77", + "@github/copilot-linux-arm64": "1.0.77", + "@github/copilot-linux-x64": "1.0.77", + "@github/copilot-linuxmusl-arm64": "1.0.77", + "@github/copilot-linuxmusl-x64": "1.0.77", + "@github/copilot-win32-arm64": "1.0.77", + "@github/copilot-win32-x64": "1.0.77" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.73.tgz", - "integrity": "sha512-5jv7t2sw35/zI0cPze38hG6239NT5/q/Emjx6gLibYkolDqMDJjpm17Ps7tc8oafUEOiMQMb+ar7+qi6rSiGJA==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.77.tgz", + "integrity": "sha512-sCWSH5+Flm/OxFe7dzsBfyj7ADBkzkR54Sz5NGw7dtcVVEOnVUkZLjEtNmZ1t5QRD4Sf1+g/DiwgJEbsR9xR1w==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.73.tgz", - "integrity": "sha512-l794k6Ahb11AG2FQT/P4TEWxWblzM1h8aQQCzG8jBWp8dfwjhyYjJ+d+0CWQzM3Fc1ddNUZRjKXCUsfvFjiZhQ==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.77.tgz", + "integrity": "sha512-ReNlB+g+OBiqHwmY5leJBIyvHZQcjyWL/OY8aVimHyESn2ToPKP3eUNTzSUJvvbPM6+0LXwEpijLedkRd2Cn1g==", "cpu": [ "x64" ], @@ -115,9 +115,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.73.tgz", - "integrity": "sha512-Zu0W5nupJjNeem0brqU/pG+VY0IWr6EWr/FsC90g5SEDiaM4VhVNVWcz8t0E3DQCSYetV6IBaNMtjs/3uIIiDQ==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.77.tgz", + "integrity": "sha512-A8j/WBPFvV5WfLbgnIIQLUVuFRAR7kLyc5WgId6XLCu1ARbkRM7353zz9mEXXwjc6LqotHVg80ooANJjNtmSPg==", "cpu": [ "arm64" ], @@ -134,9 +134,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.73.tgz", - "integrity": "sha512-k33XIr6/PVp+K+5F/zv3No4PPaNImvHz73mcbIw63oxh5iiacXjgr0WqbBIS5s/rkhOWjNPIkbof/TTPZ7mQjA==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.77.tgz", + "integrity": "sha512-2eefKkdUnQ1Y8oxyRyexHBXVpuSmrfEM8XJauquVjPc0JqF5nab9axwpFPzrRSF1GB+25F9tUK2sDQRyp08wag==", "cpu": [ "x64" ], @@ -153,9 +153,9 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.73.tgz", - "integrity": "sha512-HJWzhfD3oaiIgfRAHkNWzp17fELtshqM9HVN5n+lFEmSO2EETCEh0P1lhJc4m+FYfXSJnL0raAqVuyaNMuPoPw==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.77.tgz", + "integrity": "sha512-YtltOZQp8plytSKSGTWWKbOx3QD8iZH04sLtKTrYs6nu5UalIgFPoMkwamy1gh7h5EBkeVxD2s3epVrlvP4X4w==", "cpu": [ "arm64" ], @@ -172,9 +172,9 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.73.tgz", - "integrity": "sha512-/BpOXSb16wHEu8I1SaKiLszQ4Kvu4+Z4uCn7W0bv4xI4fPZwTEG0u3zgaI2W9Ao3+aBl0XRpPmpWzE9ziYEq+w==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.77.tgz", + "integrity": "sha512-owINwPgHU/ZZBwFhVPgkgGjLkF6e4QbdofADvKMdKJWV2+7oWjXUIlPA4/PwraD2Gkuu583l7m0XLL27TN8oUA==", "cpu": [ "x64" ], @@ -191,12 +191,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.8.tgz", - "integrity": "sha512-dbahVsyt2aX8qqtOOtmYNe40MnvzSvOSHYFFgoFK7gHZSTNz9QgOht8b1sCCJlcXaFAn/w+5qNc7CwWoCjpQ0g==", + "version": "1.0.9-preview.1", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.9-preview.1.tgz", + "integrity": "sha512-/hUYdxpa4HL57uKmRCLmcg31wWjZPKGBbBfIhVqJdWXkp4/E6lbtHoiIbT5SLCPpf4c8Ka5zw356z+k4Nn/diA==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.73", + "@github/copilot": "^1.0.76-5", "koffi": "^3.1.0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" @@ -215,9 +215,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.73.tgz", - "integrity": "sha512-DbPeXiYzQjpOy9oboaBvuCzjRwfcL987c3bG09cK1crdCDrKfkTJ7NXpcp1KWRPIRFO1FQm1qToNE89J+L3uvg==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.77.tgz", + "integrity": "sha512-l5oQaMLCRup0nmmpbqOAYEAJ5YWgNlaoO0psNaKDzvTbdzEJRZqib2t7+p3bgoDpK7SB/m8m1uxFC4XT3hlprg==", "cpu": [ "arm64" ], @@ -231,9 +231,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.73", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.73.tgz", - "integrity": "sha512-8D3E1l5i+N5Eq8HIOQpx+Zbcb3MXdFxszksM2gqq175Z1S7Zna67oY4GoR3psxlbIpSyHKiLEBWYiaps6ayHWw==", + "version": "1.0.77", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.77.tgz", + "integrity": "sha512-8Mo9y3/8CVU2w35WqwSiRMTGH1kKHR3URPSJYF4J4OG8L7NOEy2fafXR9Tuq3H21Srg3OzFkl/A+Taunqz9KcA==", "cpu": [ "x64" ], diff --git a/remote/package.json b/remote/package.json index 04951a553f7..3f1ea10856e 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,8 +3,8 @@ "version": "0.0.0", "private": true, "dependencies": { - "@github/copilot": "^1.0.73", - "@github/copilot-sdk": "^1.0.8", + "@github/copilot": "^1.0.77", + "@github/copilot-sdk": "^1.0.9-preview.1", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.1", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 619c300ac42..423c55ef873 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-27", + "@vscode/codicons": "^0.0.46-28", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.46-27", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-27.tgz", - "integrity": "sha512-R6lEiJzbDrcrIT+pjM0aauVFjGXVHj4K9ClMzI4aOQWZH/fSswOJljypunppD81Mjwia1RzxA5LiBAlBJUI5PA==", + "version": "0.0.46-28", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-28.tgz", + "integrity": "sha512-Rj3yNS72a7N0FN/JeT/muXRCBzNNvnbQ99B++bCDyaOZkbHAfP/3DS7YoiAxa4z+ZiG5ZowJ5b9ncB40YGe1ig==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index 06348620438..393d4dc09e5 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-27", + "@vscode/codicons": "^0.0.46-28", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", diff --git a/src/vs/base/browser/keyboardEvent.ts b/src/vs/base/browser/keyboardEvent.ts index 6b675d06535..b7c24be7290 100644 --- a/src/vs/base/browser/keyboardEvent.ts +++ b/src/vs/base/browser/keyboardEvent.ts @@ -150,9 +150,17 @@ export class StandardKeyboardEvent implements IKeyboardEvent { this.altKey = e.altKey; this.metaKey = e.metaKey; this.altGraphKey = e.getModifierState?.('AltGraph'); - this.keyCode = extractKeyCode(e); this.code = e.code; + // Browsers are inconsistent while an IME composition is in flight: most keystrokes arrive as + // `keyCode: 229` (which maps to `KEY_IN_COMPOSITION`), but some platform/IME combinations + // report the real key code for keys the IME owns - notably the Enter that commits a + // composition, but also Space, Escape and the arrows used to pick candidates. Normalize to + // `KEY_IN_COMPOSITION` so that "the IME owns this keystroke" has a single representation + // that `equals()`, direct `keyCode` readers and keybinding resolution all understand, + // instead of acting on a key the user never directed at the application. + this.keyCode = e.isComposing ? KeyCode.KEY_IN_COMPOSITION : extractKeyCode(e); + // console.info(e.type + ": keyCode: " + e.keyCode + ", which: " + e.which + ", charCode: " + e.charCode + ", detail: " + e.detail + " ====> " + this.keyCode + ' -- ' + KeyCode[this.keyCode]); this.ctrlKey = this.ctrlKey || this.keyCode === KeyCode.Ctrl; diff --git a/src/vs/base/browser/ui/inputbox/inputBox.ts b/src/vs/base/browser/ui/inputbox/inputBox.ts index d8c041c95ac..465101843c4 100644 --- a/src/vs/base/browser/ui/inputbox/inputBox.ts +++ b/src/vs/base/browser/ui/inputbox/inputBox.ts @@ -117,6 +117,7 @@ export class InputBox extends Widget { private maxHeight: number = Number.POSITIVE_INFINITY; private scrollableElement: ScrollableElement | undefined; private readonly hover: MutableDisposable = this._register(new MutableDisposable()); + private readonly messageResizeObserver: MutableDisposable = this._register(new MutableDisposable()); private _onDidChange = this._register(new Emitter()); public get onDidChange(): Event { return this._onDidChange.event; } @@ -527,10 +528,13 @@ export class InputBox extends Widget { }, onHide: () => { this.state = 'closed'; + this.messageResizeObserver.clear(); }, layout: layout }); + this.observeElementResize(); + // ARIA Support let alertText: string; if (this.message.type === MessageType.ERROR) { @@ -555,9 +559,27 @@ export class InputBox extends Widget { this.contextViewProvider.hideContextView(); } + this.messageResizeObserver.clear(); this.state = 'idle'; } + /** + * Keeps the validation message sized and anchored to the input while the + * message is showing and the input itself is resized, e.g. because the + * containing view was resized. + */ + private observeElementResize(): void { + const observer = new dom.DisposableResizeObserver('InputBox.validationMessage', () => { + // Ignore notifications for a hidden or detached input, laying out + // against a degenerate anchor would move the message to the corner. + if (this.element.isConnected && dom.getTotalWidth(this.element) > 0) { + this.layoutMessage(); + } + }, dom.getWindow(this.element)); + observer.observe(this.element); + this.messageResizeObserver.value = observer; + } + private layoutMessage(): void { if (this.state === 'open' && this.contextViewProvider) { this.contextViewProvider.layout(); diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index cbd904ba4ed..06e709dd15a 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -759,4 +759,5 @@ export const codiconsLibrary = { cloudUploadCompact: register('cloud-upload-compact', 0xece9), micCompact: register('mic-compact', 0xecea), arrowUpCompact: register('arrow-up-compact', 0xeceb), + xai: register('xai', 0xecec), } as const; diff --git a/src/vs/base/node/zip.ts b/src/vs/base/node/zip.ts index 8beee3266ca..7be49cbb75d 100644 --- a/src/vs/base/node/zip.ts +++ b/src/vs/base/node/zip.ts @@ -82,12 +82,13 @@ function extractEntry(stream: Readable, fileName: string, mode: number, targetPa let istream: WriteStream; - token.onCancellationRequested(() => { + const listener = token.onCancellationRequested(() => { istream?.destroy(); }); return Promise.resolve(promises.mkdir(targetDirName, { recursive: true })).then(() => new Promise((c, e) => { if (token.isCancellationRequested) { + c(); return; } @@ -100,7 +101,7 @@ function extractEntry(stream: Readable, fileName: string, mode: number, targetPa } catch (error) { e(error); } - })); + })).finally(() => listener.dispose()); } function extractZip(zipfile: ZipFile, targetPath: string, options: IOptions, token: CancellationToken): Promise { diff --git a/src/vs/base/test/browser/keyboardEvent.test.ts b/src/vs/base/test/browser/keyboardEvent.test.ts new file mode 100644 index 00000000000..50746c73910 --- /dev/null +++ b/src/vs/base/test/browser/keyboardEvent.test.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { StandardKeyboardEvent } from '../../browser/keyboardEvent.js'; +import { KeyCode, KeyMod } from '../../common/keyCodes.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../common/utils.js'; + +function keydown(init: KeyboardEventInit & { keyCode: number }): StandardKeyboardEvent { + // `keyCode` is legacy but is what `StandardKeyboardEvent` reads, so it has to be set explicitly. + const event = new KeyboardEvent('keydown', init); + Object.defineProperty(event, 'keyCode', { get: () => init.keyCode }); + return new StandardKeyboardEvent(event); +} + +suite('StandardKeyboardEvent', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reports the pressed key when no composition is in progress', () => { + const event = keydown({ keyCode: 13, isComposing: false }); + assert.deepStrictEqual( + [event.keyCode === KeyCode.Enter, event.equals(KeyCode.Enter)], + [true, true] + ); + }); + + test('normalizes the key code to KEY_IN_COMPOSITION while composing', () => { + // Some platform/IME combinations report the real key code (rather than 229) for the Enter + // that commits a composition. Normalizing means both `equals()` callers and the many + // handlers that compare `keyCode` directly stop seeing a key the user never aimed at them. + const event = keydown({ keyCode: 13, isComposing: true }); + assert.deepStrictEqual( + [event.keyCode === KeyCode.KEY_IN_COMPOSITION, event.equals(KeyCode.Enter)], + [true, false] + ); + }); + + test('normalizes modified keybindings while composing too', () => { + const event = keydown({ keyCode: 13, ctrlKey: true, isComposing: true }); + assert.strictEqual(event.equals(KeyMod.CtrlCmd | KeyCode.Enter), false); + }); + + test('keeps matching KEY_IN_COMPOSITION while composing', () => { + // Composition-aware callers ask about KEY_IN_COMPOSITION explicitly; the editor relies on + // this to detect IME input, so it has to keep working for both key code shapes. + for (const keyCode of [229, 13]) { + const event = keydown({ keyCode, isComposing: true }); + assert.deepStrictEqual( + [keyCode, event.equals(KeyCode.KEY_IN_COMPOSITION)], + [keyCode, true] + ); + } + }); + + test('resolves to a chord that matches no keybinding while composing', () => { + // Keybinding resolution goes through `toKeyCodeChord()` rather than `equals()`, so it needs + // the same normalization to avoid running commands mid-composition. + const event = keydown({ keyCode: 13, isComposing: true }); + assert.strictEqual(event.toKeyCodeChord().keyCode, KeyCode.KEY_IN_COMPOSITION); + }); +}); diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index c504148633d..7fb80ef3550 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -1074,7 +1074,7 @@ registerEditorCommand(new FindCommand({ handler: x => x.closeFindWidget(), kbOpts: { weight: KeybindingWeight.EditorContrib + 5, - kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, ContextKeyExpr.not('isComposing')), + kbExpr: EditorContextKeys.focus, primary: KeyCode.Escape, secondary: [KeyMod.Shift | KeyCode.Escape] } @@ -1167,7 +1167,7 @@ registerEditorCommand(new FindCommand({ handler: x => x.replace(), kbOpts: { weight: KeybindingWeight.EditorContrib + 5, - kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, CONTEXT_REPLACE_INPUT_FOCUSED, EditorContextKeys.isComposing.negate()), + kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, CONTEXT_REPLACE_INPUT_FOCUSED), primary: KeyCode.Enter } })); diff --git a/src/vs/editor/contrib/rename/browser/rename.ts b/src/vs/editor/contrib/rename/browser/rename.ts index cf675f54cf4..28b05b0d37e 100644 --- a/src/vs/editor/contrib/rename/browser/rename.ts +++ b/src/vs/editor/contrib/rename/browser/rename.ts @@ -423,7 +423,7 @@ registerEditorCommand(new RenameCommand({ handler: x => x.acceptRenameInput(false), kbOpts: { weight: KeybindingWeight.EditorContrib + 99, - kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, ContextKeyExpr.not('isComposing')), + kbExpr: EditorContextKeys.focus, primary: KeyCode.Enter } })); @@ -434,7 +434,7 @@ registerEditorCommand(new RenameCommand({ handler: x => x.acceptRenameInput(true), kbOpts: { weight: KeybindingWeight.EditorContrib + 99, - kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, ContextKeyExpr.not('isComposing')), + kbExpr: EditorContextKeys.focus, primary: KeyMod.CtrlCmd + KeyCode.Enter } })); diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index 0c35df41341..746a18d19b2 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -50,6 +50,7 @@ export const enum AccessibleViewProviderId { SessionsChanges = 'sessionsChanges', Survey = 'survey', Automations = 'automations', + BrowserElementCommenting = 'browserElementCommenting', } export const enum AccessibleViewType { diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index a5135121018..3f06fdc1eae 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -668,6 +668,7 @@ export class ActionListWidget extends Disposable { private readonly _collapsedSections = new Set(); private _filterText = ''; + private _imeSessionInProgress = false; private _suppressHover = false; private _hasLaidOut = false; private readonly _filterInput: HTMLInputElement | undefined; @@ -845,10 +846,34 @@ export class ActionListWidget extends Disposable { filterActionBar.push(filterActions, { icon: true, label: false }); } - this._register(dom.addDisposableListener(this._filterInput, 'input', () => { - this._filterText = this._filterInput!.value; + // While an IME composition is running the input holds intermediate text (e.g. pinyin) + // which must not drive the filter: re-filtering splices the list, re-highlights a row and + // re-layouts the popup, all of which disrupt the composition and the IME candidate window. + // Filter once the composition commits instead. + const onFilterValueChanged = () => { + const value = this._filterInput!.value; + // `compositionend` and the `input` event that follows it both land here (and browsers + // disagree on their order), so only filter when the text actually changed. + if (this._imeSessionInProgress || value === this._filterText) { + return; + } + this._filterText = value; this._applyOrUpdateFilter(); + }; + + this._register(dom.addDisposableListener(this._filterInput, 'compositionstart', () => { + this._imeSessionInProgress = true; + // A dynamic filter request issued for the previous value can still be in flight. + // Letting it resolve now would splice and re-layout the list underneath the IME + // candidate window - the very disruption this guard exists to prevent. The + // committed value starts a fresh request from `compositionend`. + this._filterCts.value?.cancel(); })); + this._register(dom.addDisposableListener(this._filterInput, 'compositionend', () => { + this._imeSessionInProgress = false; + onFilterValueChanged(); + })); + this._register(dom.addDisposableListener(this._filterInput, 'input', onFilterValueChanged)); } if (this._options?.secondaryHeading) { @@ -924,7 +949,7 @@ export class ActionListWidget extends Disposable { // ArrowRight opens submenu for the focused item and moves focus into it this._register(dom.addDisposableListener(this.domNode, 'keydown', (e: KeyboardEvent) => { - if (e.key === 'ArrowRight') { + if (e.key === 'ArrowRight' && !e.isComposing) { const focused = this._list.getFocus(); if (focused.length > 0) { const element = this._list.element(focused[0]); @@ -945,7 +970,7 @@ export class ActionListWidget extends Disposable { if (this._filterInput) { this._register(dom.addDisposableListener(this.domNode, 'keydown', (e: KeyboardEvent) => { if (this._filterInput && !dom.isActiveElement(this._filterInput) - && e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) { + && !e.isComposing && e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey && !e.altKey) { this._filterInput.focus(); this._filterInput.value = e.key; this._filterText = e.key; diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index 23fd5a60742..2af86401e5b 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -203,6 +203,43 @@ suite('ActionListWidget', () => { assert.ok(widget.domNode.textContent?.includes('ma-fresh-result')); }); + test('does not filter while an IME composition is in progress', () => { + const filters: string[] = []; + const widget = createActionListWidget(disposables, { + onFilter: async filter => { + filters.push(filter); + return [action(`result-${filter}`)]; + }, + }); + + assert.ok(widget.filterInput); + widget.filterInput.dispatchEvent(new Event('compositionstart')); + typeFilter(widget, 'd'); + typeFilter(widget, 'deepseek'); + widget.filterInput.value = 'DeepSeek'; + widget.filterInput.dispatchEvent(new Event('compositionend')); + // Chromium fires a trailing `input` for the committed text, which must not re-filter. + typeFilter(widget, 'DeepSeek'); + + assert.deepStrictEqual(filters, ['DeepSeek']); + }); + + test('cancels an in-flight dynamic filter when a composition starts', async () => { + const pending = new DeferredPromise[]>(); + const widget = createActionListWidget(disposables, { + onFilter: () => pending.p, + }); + + typeFilter(widget, 'd'); + assert.ok(widget.filterInput); + widget.filterInput.dispatchEvent(new Event('compositionstart')); + + // Resolving now must not splice/re-layout the list underneath the IME candidate window. + pending.complete([action('stale-result')]); + await timeout(0); + assert.ok(!widget.domNode.textContent?.includes('stale-result')); + }); + test('batches row width writes before reading layout', () => { const widget = createActionListWidget(disposables, { items: [ diff --git a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts index ab5ff348a98..e4c87d04312 100644 --- a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts +++ b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts @@ -23,6 +23,7 @@ import { localize } from '../../../nls.js'; import { IAccessibilityService } from '../../accessibility/common/accessibility.js'; import { ICommandAction, isICommandActionToggleInfo } from '../../action/common/action.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { ICommandService } from '../../commands/common/commands.js'; import { IContextKeyService } from '../../contextkey/common/contextkey.js'; import { IContextMenuService, IContextViewService } from '../../contextview/browser/contextView.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -425,6 +426,7 @@ export class SubmenuEntryActionViewItem extends DropdownMenuActionViewItem { export interface IDropdownWithDefaultActionViewItemOptions extends IDropdownMenuActionViewItemOptions { renderKeybindingWithDefaultActionLabel?: boolean; togglePrimaryAction?: boolean; + primaryActionIds?: readonly string[]; } export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { @@ -448,7 +450,8 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { @IContextMenuService protected _contextMenuService: IContextMenuService, @IMenuService protected _menuService: IMenuService, @IInstantiationService protected _instaService: IInstantiationService, - @IStorageService protected _storageService: IStorageService + @IStorageService protected _storageService: IStorageService, + @ICommandService protected _commandService: ICommandService, ) { super(null, submenuAction); this._options = options; @@ -458,10 +461,10 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { let defaultAction: IAction | undefined; const defaultActionId = options?.togglePrimaryAction ? _storageService.get(this._storageKey, StorageScope.WORKSPACE) : undefined; if (defaultActionId) { - defaultAction = submenuAction.actions.find(a => defaultActionId === a.id); + defaultAction = submenuAction.actions.find(a => defaultActionId === a.id && this._canBePrimaryAction(a)); } if (!defaultAction) { - defaultAction = submenuAction.actions[0]; + defaultAction = submenuAction.actions.find(action => this._canBePrimaryAction(action)) ?? submenuAction.actions[0]; } this._defaultAction = this._defaultActionDisposables.add(this._instaService.createInstance(MenuEntryActionViewItem, defaultAction, { keybinding: this._getDefaultActionKeybindingLabel(defaultAction), hoverDelegate: options?.hoverDelegate })); @@ -481,16 +484,31 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { } private registerTogglePrimaryActionListener(): void { - this._primaryActionListener.value = this._dropdown.actionRunner.onDidRun((e: IRunEvent) => { - if (e.action instanceof MenuItemAction) { - this.update(e.action); - } - }); + this._primaryActionListener.value = this._options?.primaryActionIds?.length + ? this._commandService.onDidExecuteCommand(event => { + const action = (this._action).actions.find(action => action.id === event.commandId); + if (action instanceof MenuItemAction && this._canBePrimaryAction(action)) { + this.update(action); + } + }) + : this._dropdown.actionRunner.onDidRun((e: IRunEvent) => { + if (e.action instanceof MenuItemAction) { + this.update(e.action); + } + }); } private update(lastAction: MenuItemAction): void { + if (!this._canBePrimaryAction(lastAction)) { + return; + } if (this._options?.togglePrimaryAction) { - this._storageService.store(this._storageKey, lastAction.id, StorageScope.WORKSPACE, StorageTarget.MACHINE); + if (this._storageService.get(this._storageKey, StorageScope.WORKSPACE) !== lastAction.id) { + this._storageService.store(this._storageKey, lastAction.id, StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + } + if (this._defaultAction.action.id === lastAction.id) { + return; } this._defaultActionDisposables.clear(); @@ -506,6 +524,10 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { } } + private _canBePrimaryAction(action: IAction): boolean { + return !this._options?.primaryActionIds?.length || this._options.primaryActionIds.includes(action.id); + } + private _getDefaultActionKeybindingLabel(defaultAction: IAction) { let defaultActionKeybinding: string | undefined; if (this._options?.renderKeybindingWithDefaultActionLabel) { @@ -527,15 +549,10 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { super.actionRunner = actionRunner; this._defaultAction.actionRunner = actionRunner; - // When togglePrimaryAction is enabled, keep the dropdown's private - // action runner so that the onDidRun listener only fires for actions - // originating from the dropdown, not from unrelated toolbar buttons. - if (!this._options?.togglePrimaryAction) { + // Without an allowlist, retain the private runner so only dropdown executions become primary. + if (!this._options?.togglePrimaryAction || this._options.primaryActionIds?.length) { this._dropdown.actionRunner = actionRunner; } - if (this._primaryActionListener.value) { - this.registerTogglePrimaryActionListener(); - } } override get actionRunner(): IActionRunner { @@ -635,6 +652,7 @@ export function createActionViewItem(instaService: IInstantiationService, action return instaService.createInstance(DropdownWithDefaultActionViewItem, action, { ...options, togglePrimaryAction: typeof action.item.isSplitButton !== 'boolean' ? action.item.isSplitButton.togglePrimaryAction : false, + primaryActionIds: typeof action.item.isSplitButton !== 'boolean' ? action.item.isSplitButton.primaryActionIds : undefined, }); } else { return instaService.createInstance(SubmenuEntryActionViewItem, action, options); diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index c775a60c48a..7d9168a21a8 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -51,6 +51,11 @@ export interface ISubmenuItem { * on the action that was last run. */ togglePrimaryAction: true; + /** + * Restricts which submenu commands can become the primary action. + * Running an eligible command outside the submenu also updates the primary action. + */ + primaryActionIds?: readonly string[]; }; } diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 0a7ca42c332..9cb21e3da04 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -18,7 +18,7 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; -import { AgentSession, AgentHostCodexAgentEnabledSettingId, AgentHostCopilotMultiRootEnabledSettingId, AgentHostClaudeMultiRootEnabledSettingId, AgentHostSystemProxyEnabledSettingId, IAgentConnection, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agentService.js'; +import { AgentSession, AgentHostCodexAgentEnabledSettingId, AgentHostCodexMultiRootEnabledSettingId, AgentHostCopilotMultiRootEnabledSettingId, AgentHostClaudeMultiRootEnabledSettingId, AgentHostSystemProxyEnabledSettingId, IAgentConnection, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agentService.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js'; import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; @@ -38,7 +38,7 @@ import { encodeBase64 } from '../../../base/common/buffer.js'; import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.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, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostClaudeMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostEditTelemetryEnabledConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, EDIT_TELEMETRY_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; +import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostCodexMultiRootEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostClaudeMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostEditTelemetryEnabledConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, EDIT_TELEMETRY_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js'; import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js'; import type { Implementation, InitializeResult } from '../common/state/protocol/common/commands.js'; @@ -400,6 +400,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } this._updateClaudeMultiRootEnabled(); } + if (e.affectsConfiguration(AgentHostCodexMultiRootEnabledSettingId)) { + if (this._state.kind !== AgentHostClientState.Connected) { + return; + } + this._updateCodexMultiRootEnabled(); + } if (e.affectsConfiguration(TERMINAL_AUTO_APPROVE_SETTING_ID) || e.affectsConfiguration(TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID)) { if (this._state.kind !== AgentHostClientState.Connected) { return; @@ -714,6 +720,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._updateSystemProxyEnabled(); this._updateCopilotMultiRootEnabled(); this._updateClaudeMultiRootEnabled(); + this._updateCodexMultiRootEnabled(); this._updateTerminalAutoApproveRules(); this._updateCodexEnabled(); this._updateDisableRepoInfoTelemetry(); @@ -1581,6 +1588,14 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC }, this._clientId, 0); } + private _updateCodexMultiRootEnabled(): void { + const enabled = this._configurationService.getValue(AgentHostCodexMultiRootEnabledSettingId) === true; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostCodexMultiRootEnabledConfigKey]: enabled }, + }, this._clientId, 0); + } + private _updateCodexEnabled(): void { // Always forwards the current value; the host only acts on enable, so a // forwarded `false` only takes effect on the next agent host restart diff --git a/src/vs/platform/agentHost/common/agentHostByokLm.ts b/src/vs/platform/agentHost/common/agentHostByokLm.ts index dec18d3be10..0323e351059 100644 --- a/src/vs/platform/agentHost/common/agentHostByokLm.ts +++ b/src/vs/platform/agentHost/common/agentHostByokLm.ts @@ -15,64 +15,109 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; * These shapes are deliberately wire-friendly (plain JSON, no `VSBuffer`, * `URI`, or `workbench/contrib/chat` types) so they survive both the local * utility-process IPC channel and the remote JSON-RPC transport without a - * translation step. The node side converts OpenAI Chat Completions wire - * payloads to/from these; the renderer side converts these to/from the VS Code + * translation step. The node side converts OpenAI Responses wire payloads + * to/from these; the renderer side converts these to/from the VS Code * LM API (`ILanguageModelsService`). */ -/** A single tool/function call requested by the assistant. */ -export interface IByokLmToolCall { - /** Stable id correlating the call with its later `tool` result message. */ - readonly id: string; - /** Tool/function name. */ +export interface IByokLmTextPart { + readonly type: 'text'; + readonly text: string; +} + +export interface IByokLmMessageItem { + readonly type: 'message'; + readonly role: 'system' | 'developer' | 'user' | 'assistant'; + readonly content: IByokLmTextPart[]; +} + +export interface IByokLmReasoningItem { + readonly type: 'reasoning'; + readonly id?: string; + readonly summary: string[]; + readonly encryptedContent?: string; + readonly metadata?: Record; +} + +export interface IByokLmFunctionCallItem { + readonly type: 'function_call'; + readonly callId: string; readonly name: string; - /** JSON-encoded tool input, which may be an object or a freeform string. */ readonly argumentsJson: string; } -/** A tool/function the model may call. */ -export interface IByokLmTool { +export interface IByokLmFunctionCallOutputItem { + readonly type: 'function_call_output'; + readonly callId: string; + readonly output: string; +} + +export interface IByokLmCustomToolCallItem { + readonly type: 'custom_tool_call'; + readonly callId: string; + readonly name: string; + readonly input: string; +} + +export interface IByokLmCustomToolCallOutputItem { + readonly type: 'custom_tool_call_output'; + readonly callId: string; + readonly output: string; +} + +export type IByokLmInputItem = + IByokLmMessageItem | + IByokLmReasoningItem | + IByokLmFunctionCallItem | + IByokLmFunctionCallOutputItem | + IByokLmCustomToolCallItem | + IByokLmCustomToolCallOutputItem; + +export interface IByokLmFunctionTool { + readonly type: 'function'; readonly name: string; readonly description?: string; - /** JSON schema for the tool parameters. */ readonly parametersSchema?: object; } -/** One chat message in a BYOK request. */ -export interface IByokLmChatMessage { - readonly role: 'system' | 'user' | 'assistant' | 'tool'; - /** Flattened text content. Empty string when the message carries only tool calls/results. */ - readonly content: string; - /** Present on `assistant` messages that requested tool calls. */ - readonly toolCalls?: IByokLmToolCall[]; - /** Present on `tool` messages: the {@link IByokLmToolCall.id} this result answers. */ - readonly toolCallId?: string; +export interface IByokLmCustomTool { + readonly type: 'custom'; + readonly name: string; + readonly description?: string; } -/** A chat request forwarded from the proxy to the renderer LM API. */ +export type IByokLmTool = IByokLmFunctionTool | IByokLmCustomTool; + export interface IByokLmChatRequest { - /** Provider/vendor name (the LM API vendor that registered the model). */ readonly vendor: string; - /** Provider-local model id (the wire id the runtime sent on the OpenAI request). */ readonly modelId: string; - readonly messages: IByokLmChatMessage[]; + readonly instructions?: string; + readonly input: IByokLmInputItem[]; readonly tools?: IByokLmTool[]; - /** Opaque per-request model options forwarded to the LM provider. */ + readonly previousResponseId?: string; + readonly reasoningEffort?: string; readonly modelOptions?: Record; } -/** The (buffered) completion produced by the renderer LM API. */ +export interface IByokLmOutputMessageItem { + readonly type: 'message'; + readonly content: IByokLmTextPart[]; +} + +export type IByokLmOutputItem = + IByokLmOutputMessageItem | + IByokLmReasoningItem | + IByokLmFunctionCallItem | + IByokLmCustomToolCallItem; + export interface IByokLmChatResult { - /** Concatenated assistant text. */ - readonly content: string; - /** Tool calls the assistant requested, if any. */ - readonly toolCalls?: IByokLmToolCall[]; - /** Best-effort token usage, when the provider reports it. */ + readonly output: IByokLmOutputItem[]; + readonly responseId?: string; readonly usage?: { - readonly promptTokens?: number; - readonly completionTokens?: number; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly reasoningTokens?: number; }; - /** Set when the LM call failed; `content` is then empty. */ readonly error?: string; } @@ -98,6 +143,10 @@ export interface IByokLmModelInfo { readonly maxContextWindowTokens?: number; /** Whether the model accepts image inputs, when known. */ readonly supportsVision?: boolean; + /** Reasoning effort values advertised by the renderer model, when known. */ + readonly supportedReasoningEfforts?: readonly string[]; + /** Default reasoning effort advertised by the renderer model, when known. */ + readonly defaultReasoningEffort?: string; } export const IAgentHostByokLmHandler = createDecorator('agentHostByokLmHandler'); @@ -119,7 +168,7 @@ export interface IAgentHostByokLmHandler { readonly onDidChangeModels?: Event; /** - * Run a BYOK chat completion against the extension-registered model that + * Run a BYOK Responses request against the extension-registered model that * matches `request.vendor` + `request.modelId`. Rejects (or resolves with * {@link IByokLmChatResult.error}) when no such model is available. */ diff --git a/src/vs/platform/agentHost/common/agentHostCheckpointService.ts b/src/vs/platform/agentHost/common/agentHostCheckpointService.ts index d166b446cf9..28689427968 100644 --- a/src/vs/platform/agentHost/common/agentHostCheckpointService.ts +++ b/src/vs/platform/agentHost/common/agentHostCheckpointService.ts @@ -8,12 +8,6 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IAgentHostCheckpointService = createDecorator('agentHostCheckpointService'); -/** - * `session_metadata` key under which the per-session baseline (turn/0) - * checkpoint ref is stored. - */ -export const META_CHECKPOINT_BASE_REF = 'checkpoint.baseRef'; - /** * Returns the canonical name for a per-turn checkpoint ref. * Distinct from the chat extension's `refs/sessions/...` so the two can @@ -42,24 +36,30 @@ export interface IAgentHostCheckpointService { readonly _serviceBrand: undefined; /** - * Captures the session's baseline (turn/0) checkpoint. Idempotent: if - * a baseline already exists for the session, returns the existing ref. - * Returns `undefined` when the working directory is not a git work tree - * (folder-isolation against a non-git folder) or when checkpoint - * capture fails. + * Captures the session's baseline (turn/0) checkpoint in each of + * `workingDirectories`. Idempotent per repository: a directory that + * already has a baseline ref is skipped, as is one that is not a git + * work tree (folder-isolation against a non-git folder). Best-effort — + * a failure for one repository does not stop the others. * * Called once per session, immediately after the session's working - * directory has been resolved and any worktree metadata has been + * directories have been resolved and any worktree metadata has been * persisted (e.g. `CopilotAgent._materializeProvisional`). + * + * The caller must pass the directories it just resolved rather than + * letting this service look them up: at that point the resolved set + * (which for an isolated session is the *worktree*, not the folder the + * user picked) has not necessarily reached the state manager yet, so a + * lookup can silently capture the baseline against the wrong repository. */ - captureBaseline(sessionUri: URI, workingDirectory: URI | undefined): Promise; + captureBaselineCheckpoint(sessionUri: URI, workingDirectories: readonly URI[] | undefined): Promise; /** - * Captures an end-of-turn checkpoint, chained to the previous turn's - * checkpoint (or the baseline for turn 1). Persists the ref against - * the turn via `ISessionDatabase.setTurnCheckpointRef`. Returns - * `undefined` when the session is not git-backed, the baseline is - * missing, or capture fails. + * Captures an end-of-turn checkpoint in each of `workingDirectories`, + * chained to the previous turn's checkpoint (or the baseline for turn 1). + * Persists the ref against the turn via `ISessionDatabase.setTurnCheckpointRef` + * once at least one repository captured successfully. A directory that is + * not git-backed, or has no baseline, is skipped. * * If the captured tree OID matches the parent's tree OID (no-op turn) * the parent ref is recorded against the turn rather than creating a @@ -68,15 +68,19 @@ export interface IAgentHostCheckpointService { * Called from `AgentSideEffects` when a `ChatTurnComplete` action * fires, BEFORE the changeset service's `onTurnComplete` hook so the * per-turn changeset compute can pick up the new refs. + * + * As with {@link captureBaselineCheckpoint}, the caller supplies the directories + * so that every checkpoint operation is explicit about the repositories + * it acts on rather than depending on live session state. */ - captureTurnCheckpoint(sessionUri: URI, turnId: string): Promise; + captureTurnCheckpoint(sessionUri: URI, turnId: string, workingDirectories: readonly URI[] | undefined): Promise; /** * Returns the `{ parent, current }` checkpoint refs for a turn, or * `undefined` when either is missing. Used by the changeset service * to decide whether to take the git-diff fast path for per-turn diffs. */ - getTurnCheckpointPair(sessionUri: URI, turnId: string): Promise<{ parent: string; current: string } | undefined>; + getTurnCheckpointPair(sessionUri: URI, turnId: string, workingDirectory?: URI): Promise<{ parent: string; current: string } | undefined>; /** * Returns the session's baseline checkpoint ref, or `undefined` when @@ -84,7 +88,7 @@ export interface IAgentHostCheckpointService { * failed). Used by the changeset service to resolve compare-turns * URIs whose `originalTurnId` is the `BASELINE_TURN_ID` sentinel. */ - getBaselineCheckpointRef(sessionUri: URI): Promise; + getBaselineCheckpoint(sessionUri: URI, workingDirectory?: URI): Promise; /** * Deletes every checkpoint ref this service created for the session @@ -93,22 +97,25 @@ export interface IAgentHostCheckpointService { * * Called from a subscriber to `ISessionDataService.onWillDeleteSessionData` * before the session's data directory is removed. + * + * `workingDirectories` identifies the repositories holding the refs. + * There is deliberately no fallback to the session's live state: by + * the time this runs the session has typically already been removed + * from the state manager, so omitting them is a silent no-op that + * leaks the refs. */ - disposeSessionData(sessionUri: URI): Promise; + deleteCheckpoints(sessionUri: URI, workingDirectories?: readonly string[]): Promise; } /** * A no-op implementation of {@link IAgentHostCheckpointService} used as a - * fallback in test fixtures that don't exercise checkpoint capture, and - * as the default value for the optional `_checkpointService` parameter - * on `AgentService` so existing test callsites keep compiling without - * forced fixture updates. + * fallback in test fixtures that don't exercise checkpoint capture. */ export const NULL_CHECKPOINT_SERVICE: IAgentHostCheckpointService = { _serviceBrand: undefined, - captureBaseline: async () => undefined, - captureTurnCheckpoint: async () => undefined, + captureBaselineCheckpoint: async () => { }, + captureTurnCheckpoint: async () => { }, getTurnCheckpointPair: async () => undefined, - getBaselineCheckpointRef: async () => undefined, - disposeSessionData: async () => { }, + getBaselineCheckpoint: async () => undefined, + deleteCheckpoints: async () => { }, }; diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index 3e0f459fc5c..705b1334633 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Sequencer } from '../../../base/common/async.js'; import { VSBuffer } from '../../../base/common/buffer.js'; +import { LRUCache } from '../../../base/common/map.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ISessionFileDiff, ISessionGitState } from './state/sessionState.js'; @@ -91,6 +93,54 @@ export interface IPullOptions { export const IAgentHostGitService = createDecorator('agentHostGitService'); +/** + * Resolves linked checkouts to their primary worktree and caches successful mappings for every worktree reported by Git. + * Resolution is serialized so concurrent requests across linked checkouts share one probe, while empty results remain retryable. + */ +class PrimaryWorktreeRootResolver { + private readonly _roots = new LRUCache(100); + private readonly _sequencer = new Sequencer(); + + constructor(private readonly _gitService: IAgentHostGitService) { } + + async resolve(checkoutRoot: URI): Promise { + const key = checkoutRoot.toString(); + const cached = this._roots.get(key); + if (cached) { + return cached; + } + return this._sequencer.queue(async () => { + const cached = this._roots.get(key); + if (cached) { + return cached; + } + const roots = await this._gitService.getWorktreeRoots(checkoutRoot); + const primaryRoot = roots[0]; + if (!primaryRoot) { + return undefined; + } + this._roots.set(key, primaryRoot); + for (const root of roots) { + this._roots.set(root.toString(), primaryRoot); + } + return primaryRoot; + }); + } +} + +/** Resolver lifetime follows the injected Git service; each resolver owns a bounded path cache. */ +const primaryWorktreeRootResolvers = new WeakMap(); + +/** Resolves the primary worktree root when Git reports a worktree listing. */ +export function tryResolvePrimaryWorktreeRoot(gitService: IAgentHostGitService, checkoutRoot: URI): Promise { + let resolver = primaryWorktreeRootResolvers.get(gitService); + if (!resolver) { + resolver = new PrimaryWorktreeRootResolver(gitService); + primaryWorktreeRootResolvers.set(gitService, resolver); + } + return resolver.resolve(checkoutRoot); +} + export interface IRefQuery { readonly count?: number; readonly pattern?: string | string[]; @@ -156,6 +206,7 @@ export interface IAgentHostGitService { getBranches(workingDirectory: URI, query?: IRefQuery): Promise; getBranch(workingDirectory: URI, name: string): Promise; getRepositoryRoot(workingDirectory: URI): Promise; + /** Returns worktree roots in Git's porcelain order, with the primary worktree first. */ getWorktreeRoots(workingDirectory: URI): Promise; /** * Creates a worktree for a new branch. `onProgress` receives every checkout diff --git a/src/vs/platform/agentHost/common/agentHostGitStateService.ts b/src/vs/platform/agentHost/common/agentHostGitStateService.ts index 034cb1aa08e..87a33c6eeb8 100644 --- a/src/vs/platform/agentHost/common/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitStateService.ts @@ -45,4 +45,13 @@ export interface IAgentHostGitStateService { * @param sessionKey The key of the session for which to check the GitHub pull request. */ attachSessionGitHubPullRequest(sessionKey: string): Promise; + + /** + * Detect GitHub issues referenced in a user message and add them to the + * session's GitHub state. Already-known issues are kept, so the session + * accumulates every issue referenced over its lifetime. + * @param sessionKey The key of the session the message was sent to. + * @param text The user message to scan for issue references. + */ + attachSessionGitHubIssues(sessionKey: string, text: string): Promise; } diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index b4cb79fb168..a6167a4ce61 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -451,6 +451,8 @@ export const GLOBAL_AUTO_APPROVE_SETTING_ID = 'chat.tools.global.autoApprove'; */ export const AgentHostAutoReplyEnabledConfigKey = 'autoReplyEnabled'; +export const AgentHostAutoReplyAnswer = 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.'; + /** * The VS Code setting ID for auto-reply. Defined here so renderer-side * agent-host clients can forward it without importing from `workbench/contrib/chat`. @@ -482,6 +484,9 @@ export const AgentHostCopilotMultiRootEnabledConfigKey = 'copilotMultiRootEnable */ export const AgentHostClaudeMultiRootEnabledConfigKey = 'claudeMultiRootEnabled'; +/** Root config key forwarded from the renderer that gates Codex multiple-working-directory support. */ +export const AgentHostCodexMultiRootEnabledConfigKey = 'codexMultiRootEnabled'; + /** * Root config key forwarded from the renderer when VS Code's * `chat.tools.terminal.autoApprove` setting changes. Holds the effective @@ -756,6 +761,12 @@ export const platformRootSchema = createSchema({ description: localize('agentHost.config.claudeMultiRootEnabled.description', "Whether the Claude provider advertises support for multiple working directories, letting a session span every folder of a multi-root workspace."), default: false, }), + [AgentHostCodexMultiRootEnabledConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.codexMultiRootEnabled.title', "Codex Multiple Working Directories"), + description: localize('agentHost.config.codexMultiRootEnabled.description', "Whether the Codex provider advertises support for multiple working directories, letting a session span every folder of a multi-root workspace."), + default: false, + }), [AgentHostTerminalAutoApproveRulesConfigKey]: schemaProperty({ type: 'object', title: localize('agentHost.config.terminalAutoApproveRules.title', "Terminal Auto Approve Rules"), diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 40118defc21..6a65e537ee0 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -16,6 +16,7 @@ import { AgentHostClaudeMultiRootEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, + AgentHostCodexMultiRootEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostCopilotMultiRootEnabledSettingId, @@ -112,6 +113,12 @@ configurationRegistry.registerConfiguration({ // `product.quality !== 'stable'`) to enable it for a build channel. included: false, }, + [AgentHostCodexMultiRootEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.codexAgent.multiRootEnabled', "When enabled, Codex agent-host sessions advertise support for multiple working directories, so a session created in a multi-root workspace can span every workspace folder. Experimental; newly created sessions pick up a change without restarting the agent host."), + default: false, + included: false, + }, [AgentHostClaudeAgentEnabledSettingId]: { type: 'boolean', description: nls.localize('chat.agentHost.claudeAgent.enabled', "When enabled, the agent host registers the Claude provider (subject to the Claude SDK being reachable). Independent of `#chat.agents.claude.preferAgentHost#` and `#chat.editor.claude.preferAgentHost#`, which choose which integration surfaces Claude. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect."), diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index ae912c4cfaa..2a1a0936af8 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -76,6 +76,13 @@ export const AgentHostCopilotMultiRootEnabledSettingId = 'chat.agentHost.copilot */ export const AgentHostClaudeMultiRootEnabledSettingId = 'chat.agentHost.claudeAgent.multiRootEnabled'; +/** + * Configuration key gating multiple-working-directory support for the Codex + * agent-host provider. Hidden from the Settings UI and off by default while the + * feature is dogfooded. + */ +export const AgentHostCodexMultiRootEnabledSettingId = 'chat.agentHost.codexAgent.multiRootEnabled'; + // The Copilot-CLI-specific setting IDs (`customTerminalTool`, `opus48Prompt`, // `reasoningEffortOverride`, `modelCapabilityOverrides`) live with their // root-config keys in `copilotCliConfig.ts`. diff --git a/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts b/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts index ffacb0b08ae..6cd0568f996 100644 --- a/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts +++ b/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts @@ -244,6 +244,37 @@ export class CloudSandboxAuthenticationRequiredError extends Error { } } +/** + * A Mission Control request that came back with a non-success status. Carries the {@link statusCode} + * so callers can tell a failure that may clear on its own from one that never will. + */ +export class CloudSandboxRequestError extends Error { + constructor(readonly statusCode: number | undefined, message: string) { + super(message); + this.name = 'CloudSandboxRequestError'; + } +} + +/** + * Whether re-issuing a request that failed with {@link error} could plausibly succeed later. + * + * Transport failures carry no status and are assumed transient, as are 5xx, 408 and 429. Every other + * 4xx describes a request Mission Control will reject identically however often it is repeated — a + * deleted environment, a revoked token — so repeating it only adds load. Callers that retry on a + * timer MUST consult this, or a single dead session becomes an unbounded stream of failed requests. + * + * Note that {@link CloudSandboxAuthenticationRequiredError} counts as retryable: it is raised before + * any request goes out, and covers the GitHub auth provider not having registered yet as well as a + * genuinely signed-out user. Callers still need their own ceiling on how long they keep trying. + */ +export function isRetryableCloudSandboxError(error: unknown): boolean { + if (!(error instanceof CloudSandboxRequestError) || error.statusCode === undefined) { + return true; + } + const status = error.statusCode; + return status === 408 || status === 429 || status < 400 || status >= 500; +} + export const ICloudSandboxAgentHostService = createDecorator('cloudSandboxAgentHostService'); /** Options for establishing a live AHP relay to a cloud sandbox environment. */ diff --git a/src/vs/platform/agentHost/common/githubIssueReferences.ts b/src/vs/platform/agentHost/common/githubIssueReferences.ts new file mode 100644 index 00000000000..c2980802738 --- /dev/null +++ b/src/vs/platform/agentHost/common/githubIssueReferences.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** A GitHub issue referenced from a user message. */ +export interface IGitHubIssueReference { + readonly owner: string; + readonly repo: string; + readonly number: number; +} + +/** + * Matches `https://github.com/{owner}/{repo}/issues/{number}`, optionally with a + * `www.` host, a trailing slash, a query string or a fragment (e.g. the + * `#issuecomment-123` anchor GitHub appends when copying a comment link). + */ +const ISSUE_URL_PATTERN = /\bhttps?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/issues\/(\d+)\b/gi; + +/** + * Matches the cross-repository shorthand `{owner}/{repo}#{number}`. The leading + * boundary check rejects references that are part of a longer path (e.g. the + * `microsoft/vscode#1` inside a URL, which the URL pattern already covers). + */ +const ISSUE_SHORTHAND_PATTERN = /(?(); + + const add = (owner: string, repo: string, rawNumber: string): void => { + const number = Number(rawNumber); + if (!Number.isSafeInteger(number) || number <= 0) { + return; + } + const url = toGitHubIssueUrl({ owner, repo, number }); + if (seen.has(url)) { + return; + } + seen.add(url); + references.push({ owner, repo, number }); + }; + + for (const match of text.matchAll(ISSUE_URL_PATTERN)) { + add(match[1], match[2], match[3]); + } + for (const match of text.matchAll(ISSUE_SHORTHAND_PATTERN)) { + add(match[1], match[2], match[3]); + } + + return references; +} + +/** Builds the canonical `github.com` URL for an issue reference. */ +export function toGitHubIssueUrl(reference: IGitHubIssueReference): string { + return `https://github.com/${reference.owner}/${reference.repo}/issues/${reference.number}`; +} + +/** Parses a canonical GitHub issue URL back into its parts, or `undefined`. */ +export function parseGitHubIssueUrl(url: string): IGitHubIssueReference | undefined { + return parseGitHubIssueReferences(url)[0]; +} diff --git a/src/vs/platform/agentHost/common/partialToolInput.ts b/src/vs/platform/agentHost/common/partialToolInput.ts new file mode 100644 index 00000000000..3be00e9f96c --- /dev/null +++ b/src/vs/platform/agentHost/common/partialToolInput.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { parse } from '../../../base/common/json.js'; + +const MAX_PARTIAL_TOOL_INPUT_PARSE_LENGTH = 4 * 1024; +let lastDisplayInput: string | undefined; +let lastDisplayValue: Record | undefined; + +export function parsePartialToolInput(raw: string, maxLength?: number): Record | undefined { + const parsed: unknown = parse(maxLength === undefined ? raw : raw.slice(0, maxLength)); + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) && Object.keys(parsed).length > 0 + ? { ...parsed as Record } + : undefined; +} + +export function parsePartialToolInputForDisplay(raw: string): Record | undefined { + const input = raw.slice(0, MAX_PARTIAL_TOOL_INPUT_PARSE_LENGTH); + if (input !== lastDisplayInput) { + lastDisplayInput = input; + lastDisplayValue = parsePartialToolInput(input); + } + return lastDisplayValue ? { ...lastDisplayValue } : undefined; +} diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 7f235cdd735..58fca5ce51f 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -391,8 +391,14 @@ export interface ISessionDataService { /** * Recursively deletes the data directory for a session, if it exists. + * + * `workingDirectories` is forwarded verbatim to + * {@link IWillDeleteSessionDataEvent.workingDirectories}. Callers that + * tear down live session state as part of disposal must resolve it + * *before* doing so, otherwise subscribers cannot locate the + * repositories they need to clean up. */ - deleteSessionData(session: URI): Promise; + deleteSessionData(session: URI, workingDirectories?: readonly string[]): Promise; /** * Fires immediately before a session's data directory (and the @@ -405,6 +411,10 @@ export interface ISessionDataService { * list of checkpoint refs from the (still-readable) database and * delete them before the directory is removed. * + * The repositories to clean up are identified by + * {@link IWillDeleteSessionDataEvent.workingDirectories}, which the + * caller resolves before tearing down live session state. + * * Subscribers must own their own error handling — exceptions * propagated out of `waitUntil` promises are logged and ignored; * deletion proceeds regardless. @@ -431,6 +441,21 @@ export interface ISessionDataService { */ export interface IWillDeleteSessionDataEvent { readonly session: URI; + /** + * The session's working directories (index 0 = primary), as resolved + * by the caller of {@link ISessionDataService.deleteSessionData} + * *before* any live session state was torn down. + * + * Subscribers that need to touch the session's repositories (deleting + * checkpoint or reviewed refs) must use this rather than querying + * session state themselves: by the time this event fires the session + * has typically already been removed from the state manager, so a + * live lookup returns `undefined` and the cleanup silently no-ops. + * + * `undefined` when the session had no working directories, or when + * the caller did not supply them. + */ + readonly workingDirectories: readonly string[] | undefined; /** * Register an asynchronous task that must settle before the session's * data directory is removed. diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 885d350651d..970c6c67057 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -c72272f8 +8e0a9bbf diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts index 3ddcc7d7ef2..ffd7a4eaec9 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts @@ -184,6 +184,16 @@ export interface ChatToolCallDeltaAction extends ToolCallActionBase { */ export interface ChatToolCallReadyAction extends ToolCallActionBase { type: ActionType.ChatToolCallReady; + /** + * Final contributor metadata. MUST NOT change execution ownership established + * at `chat/toolCallStart`; a client contributor must keep the same `clientId`. + */ + contributor?: ToolCallContributor; + /** + * Final human-readable description of what the tool invocation intends to do. + * When present, replaces the provisional intention from `chat/toolCallStart`. + */ + intention?: string; /** Message describing what the tool will do or what confirmation is needed */ invocationMessage: StringOrMarkdown; /** Raw tool input */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts index 231ecbfc5c5..b427cd42dfb 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts @@ -7,7 +7,7 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import { TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ToolCallContributorKind, ResponsePartKind, PendingMessageKind, type ChatState, type ToolCallState, type ResponsePart, type ToolCallResponsePart, type InputRequestResponsePart, type Turn, type PendingMessage, type ConfirmationOption } from './state.js'; +import { TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ToolCallContributorKind, ResponsePartKind, PendingMessageKind, type ChatState, type ToolCallState, type ResponsePart, type ToolCallResponsePart, type InputRequestResponsePart, type Turn, type PendingMessage, type ConfirmationOption, type ToolCallContributor } from './state.js'; import { SessionStatus } from '../channels-session/state.js'; import type { ChatAction } from '../action-origin.generated.js'; import { softAssertNever } from '../common/reducer-helpers.js'; @@ -33,6 +33,28 @@ function tcBaseWithMeta(tc: ToolCallState, meta: Record | undef }; } +function refineToolCallContributor( + current: ToolCallContributor | undefined, + next: ToolCallContributor | undefined, + log?: (msg: string) => void, +): ToolCallContributor | undefined { + if (!next) { + return current; + } + if (current?.kind === ToolCallContributorKind.Client) { + if (next.kind === ToolCallContributorKind.Client && next.clientId === current.clientId) { + return next; + } + log?.(`Ignoring contributor change for client tool call from '${current.clientId}'`); + return current; + } + if (next.kind === ToolCallContributorKind.Client) { + log?.(`Ignoring late client contributor '${next.clientId}' because client execution ownership must be established at tool call start`); + return current; + } + return next; +} + /** Resolves a selected option from the confirmation options array by ID. */ function resolveSelectedOption(options: ConfirmationOption[] | undefined, id: string | undefined): ConfirmationOption | undefined { if (!id || !options) { @@ -439,7 +461,11 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st ) { return tc; } - const base = tcBaseWithMeta(tc, action._meta); + const base = { + ...tcBaseWithMeta(tc, action._meta), + contributor: refineToolCallContributor(tc.contributor, action.contributor, log), + intention: action.intention ?? tc.intention, + }; if (action.confirmed) { return { status: ToolCallStatus.Running, diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index 1419fb15f42..6c8cd3b4cb0 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -793,21 +793,23 @@ export interface MessageAnnotationsAttachment extends MessageAttachmentBase { } /** - * An attachment that references a chat transcript through a completed turn. - * - * The referenced chat MAY belong to any session, not only the message's own. - * The model representation is a pointer — an `agent-host-session://` link, a - * short transcript excerpt, and a hint to call the `get_session_context` server - * tool — and both that link and that tool already resolve across sessions, so a - * cross-session reference carries the same weight as a same-session one. - * The host resolves the transcript from its first retained turn through - * `endTurn`, inclusive, when accepting the message. Later turns do not change - * the context represented by an already-sent attachment. When `endTurn` is - * omitted (e.g. a drag-and-drop client that cannot know turn ids), the host - * pins it to the referenced chat's latest completed turn as it accepts the - * message; when `endTurn` is provided it MUST reference a completed, retained + * An attachment that references a chat transcript through a fixed completed * turn. * + * The referenced chat MAY belong to a different session than the message's + * chat. The attachment's model representation identifies the chat in a way + * that hosts can resolve regardless of the session that owns it. + * + * When `endTurn` is omitted, the host MUST resolve and pin the referenced + * chat's latest completed turn when accepting the message. This lets clients + * attach a chat without knowing its turn identifiers. When provided, `endTurn` + * MUST reference a completed, retained turn. The host resolves the transcript + * from its first retained turn through the pinned turn, inclusive. Later turns + * do not change the context represented by an already-sent attachment. + * + * When the referenced chat has no completed retained turns, the resolved + * transcript is empty and hosts MUST NOT reject the attachment on that basis. + * * Hosts MUST NOT recursively expand chat attachments found inside the * referenced transcript. Clients SHOULD keep rendering `label` if the * referenced chat is later pruned, and treat opening `resource` as best-effort. @@ -821,8 +823,7 @@ export interface MessageChatAttachment extends MessageAttachmentBase { resource: URI; /** * Last completed turn included in the referenced transcript. When omitted, - * the host resolves the referenced chat's latest completed turn as it - * accepts the message. + * the host pins the latest completed turn when accepting the message. */ endTurn?: string; } diff --git a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts index 897f072ebe1..82764b3f711 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts @@ -48,13 +48,6 @@ export interface JsonRpcErrorResponse { }; } -/** A JSON-RPC parse error cannot identify the request that failed to parse. */ -export interface JsonRpcParseErrorResponse { - readonly jsonrpc: '2.0'; - readonly id: null; - readonly error: JsonRpcErrorResponse['error']; -} - /** * A typed JSON-RPC error response whose error object is a fully typed * {@link AhpError}. Useful when the caller knows the response is an AHP @@ -67,7 +60,7 @@ export interface AhpErrorResponse { } /** A JSON-RPC response (success or error). */ -export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse | JsonRpcParseErrorResponse; +export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; /** A JSON-RPC notification: has `method` but no `id`. */ export interface JsonRpcNotification { diff --git a/src/vs/platform/agentHost/common/state/sessionProtocol.ts b/src/vs/platform/agentHost/common/state/sessionProtocol.ts index 0241c9a3b50..1fb849eb68f 100644 --- a/src/vs/platform/agentHost/common/state/sessionProtocol.ts +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -16,12 +16,18 @@ export type { JsonRpcErrorResponse, JsonRpcNotification, - JsonRpcParseErrorResponse, JsonRpcRequest, JsonRpcResponse, JsonRpcSuccessResponse, } from './protocol/messages.js'; +/** A JSON-RPC parse error cannot identify the request that failed to parse. */ +export interface JsonRpcParseErrorResponse { + readonly jsonrpc: '2.0'; + readonly id: null; + readonly error: JsonRpcErrorResponse['error']; +} + // Typed message unions export type { AhpClientNotification, diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 97ca3739874..ed84dd9a735 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -111,7 +111,15 @@ export interface UsageInfoMeta { autoModeResolved?: IAutoModeResolvedInfo; /** Copilot-specific usage breakdown, including nano-AIU totals. */ copilotUsage?: { + /** This turn's nano-AIU cost. */ totalNanoAiu?: number; + /** + * The whole session's accumulated nano-AIU cost, as reported by the + * backend rather than summed from the turns. Clients SHOULD prefer this + * over adding up per-turn totals: it is authoritative, and it also + * covers work billed outside any turn (e.g. an out-of-turn compaction). + */ + sessionTotalNanoAiu?: number; [key: string]: unknown; }; /** @@ -210,6 +218,7 @@ export function readUsageInfoMeta(usage: UsageInfo | undefined): UsageInfoMeta { const rawUsage = copilotUsage as Record; const usage: Mutable> = {}; if (typeof rawUsage['totalNanoAiu'] === 'number') { usage.totalNanoAiu = rawUsage['totalNanoAiu']; } + if (typeof rawUsage['sessionTotalNanoAiu'] === 'number') { usage.sessionTotalNanoAiu = rawUsage['sessionTotalNanoAiu']; } result.copilotUsage = usage; } const quotaSnapshots = meta['quotaSnapshots']; @@ -248,6 +257,10 @@ export function hasReportedUsage(usage: UsageInfo | undefined): boolean { const meta = readUsageInfoMeta(usage); // Negative totals are treated as absent, matching how credits are read for display. return (typeof meta.copilotUsage?.totalNanoAiu === 'number' && meta.copilotUsage.totalNanoAiu >= 0) + // A report can carry only the session total — a compaction billed while no turn + // was active advances it without any per-event billing payload — and that is + // still consumption worth showing. + || (typeof meta.copilotUsage?.sessionTotalNanoAiu === 'number' && meta.copilotUsage.sessionTotalNanoAiu >= 0) || (typeof meta.cost === 'number' && meta.cost >= 0); } @@ -1153,6 +1166,11 @@ export interface ISessionGitHubState { readonly repo?: string; /** The URL of the GitHub pull request. */ readonly pullRequestUrl?: string; + /** + * URLs of the GitHub issues referenced by the session's user messages, in + * order of first appearance. + */ + readonly issueUrls?: readonly string[]; } /** @@ -1231,11 +1249,13 @@ export function readSessionGitHubState(meta: SessionSummaryMeta | undefined): IS owner?: string; repo?: string; pullRequestUrl?: string; + issueUrls?: readonly string[]; } = {}; if (typeof raw['owner'] === 'string') { result.owner = raw['owner']; } if (typeof raw['repo'] === 'string') { result.repo = raw['repo']; } if (typeof raw['pullRequestUrl'] === 'string') { result.pullRequestUrl = raw['pullRequestUrl']; } + if (Array.isArray(raw['issueUrls'])) { result.issueUrls = raw['issueUrls'].filter((url): url is string => typeof url === 'string'); } return result; } diff --git a/src/vs/platform/agentHost/common/streamingToolCallDisplay.ts b/src/vs/platform/agentHost/common/streamingToolCallDisplay.ts new file mode 100644 index 00000000000..68bf3dce447 --- /dev/null +++ b/src/vs/platform/agentHost/common/streamingToolCallDisplay.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { escapeMarkdownLinkLabel } from '../../../base/common/htmlContent.js'; +import { basename } from '../../../base/common/resources.js'; +import { splitLines } from '../../../base/common/strings.js'; +import { URI } from '../../../base/common/uri.js'; +import { localize } from '../../../nls.js'; +import type { StringOrMarkdown } from './state/protocol/state.js'; + +export type ToolPathResolver = (path: string) => string; + +const identityPathResolver: ToolPathResolver = path => path; + +/** + * Minimum interval between streamed tool-call display updates + */ +export const STREAMING_TOOL_DISPLAY_INTERVAL_MS = 100; + +/** Flattens a display message so equal updates can be suppressed. */ +export function streamingToolDisplayText(message: StringOrMarkdown): string { + return typeof message === 'string' ? message : message.markdown; +} + +export function formatGenericToolInput(input: Record | undefined, rawFallback?: string): string | undefined { + if (!input) { + return rawFallback; + } + try { + return JSON.stringify(input, null, 2); + } catch { + return rawFallback; + } +} + +function markdown(value: string): StringOrMarkdown { + return { markdown: value }; +} + +function formatPath(path: unknown, resolvePath: ToolPathResolver): string | undefined { + if (typeof path !== 'string' || !path) { + return undefined; + } + const uri = URI.file(resolvePath(path)); + return `[${escapeMarkdownLinkLabel(basename(uri))}](${uri})`; +} + +export function streamingToolTextLineCount(value: unknown): number | undefined { + return typeof value === 'string' ? splitLines(value).length : undefined; +} + +export function getStreamingEditMessage( + path: unknown, + lineCount: number | undefined, + resolvePath: ToolPathResolver = identityPathResolver, +): StringOrMarkdown { + const file = formatPath(path, resolvePath); + if (lineCount !== undefined) { + if (file) { + return lineCount === 1 + ? markdown(localize('toolStream.editOneLineInFile', "Editing 1 line in {0}", file)) + : markdown(localize('toolStream.editLinesInFile', "Editing {0} lines in {1}", lineCount, file)); + } + return lineCount === 1 + ? localize('toolStream.editOneLine', "Editing 1 line") + : localize('toolStream.editLines', "Editing {0} lines", lineCount); + } + return file + ? markdown(localize('toolStream.editFile', "Editing {0}", file)) + : localize('toolStream.edit', "Editing file"); +} + +export function getStreamingReplaceMessage( + path: unknown, + oldLineCount: number | undefined, + newLineCount: number | undefined, + resolvePath: ToolPathResolver = identityPathResolver, +): StringOrMarkdown { + const file = formatPath(path, resolvePath); + if (oldLineCount !== undefined && newLineCount !== undefined) { + if (file) { + if (oldLineCount === 1 && newLineCount === 1) { + return markdown(localize('toolStream.replaceOneLineWithOneLineInFile', "Replacing 1 line with 1 line in {0}", file)); + } + if (oldLineCount === 1) { + return markdown(localize('toolStream.replaceOneLineWithLinesInFile', "Replacing 1 line with {0} lines in {1}", newLineCount, file)); + } + if (newLineCount === 1) { + return markdown(localize('toolStream.replaceLinesWithOneLineInFile', "Replacing {0} lines with 1 line in {1}", oldLineCount, file)); + } + return markdown(localize('toolStream.replaceLinesWithLinesInFile', "Replacing {0} lines with {1} lines in {2}", oldLineCount, newLineCount, file)); + } + if (oldLineCount === 1 && newLineCount === 1) { + return localize('toolStream.replaceOneLineWithOneLine', "Replacing 1 line with 1 line"); + } + if (oldLineCount === 1) { + return localize('toolStream.replaceOneLineWithLines', "Replacing 1 line with {0} lines", newLineCount); + } + if (newLineCount === 1) { + return localize('toolStream.replaceLinesWithOneLine', "Replacing {0} lines with 1 line", oldLineCount); + } + return localize('toolStream.replaceLinesWithLines', "Replacing {0} lines with {1} lines", oldLineCount, newLineCount); + } + if (oldLineCount !== undefined) { + if (file) { + return oldLineCount === 1 + ? markdown(localize('toolStream.replaceOneLineInFile', "Replacing 1 line in {0}", file)) + : markdown(localize('toolStream.replaceLinesInFile', "Replacing {0} lines in {1}", oldLineCount, file)); + } + return oldLineCount === 1 + ? localize('toolStream.replaceOneLine', "Replacing 1 line") + : localize('toolStream.replaceLines', "Replacing {0} lines", oldLineCount); + } + return getStreamingEditMessage(path, undefined, resolvePath); +} + +export function getStreamingCreateMessage( + path: unknown, + lineCount: number | undefined, + resolvePath: ToolPathResolver = identityPathResolver, +): StringOrMarkdown { + const file = formatPath(path, resolvePath); + if (lineCount !== undefined) { + if (file) { + return lineCount === 1 + ? markdown(localize('toolStream.createOneLineInFile', "Creating {0} (1 line)", file)) + : markdown(localize('toolStream.createLinesInFile', "Creating {0} ({1} lines)", file, lineCount)); + } + return lineCount === 1 + ? localize('toolStream.createOneLine', "Creating file (1 line)") + : localize('toolStream.createLines', "Creating file ({0} lines)", lineCount); + } + return file + ? markdown(localize('toolStream.createFile', "Creating {0}", file)) + : localize('toolStream.create', "Creating file"); +} + +export function getStreamingInsertMessage( + path: unknown, + lineCount: number | undefined, + resolvePath: ToolPathResolver = identityPathResolver, +): StringOrMarkdown { + const file = formatPath(path, resolvePath); + if (lineCount !== undefined) { + if (file) { + return lineCount === 1 + ? markdown(localize('toolStream.insertOneLineInFile', "Inserting 1 line in {0}", file)) + : markdown(localize('toolStream.insertLinesInFile', "Inserting {0} lines in {1}", lineCount, file)); + } + return lineCount === 1 + ? localize('toolStream.insertOneLine', "Inserting 1 line") + : localize('toolStream.insertLines', "Inserting {0} lines", lineCount); + } + return file + ? markdown(localize('toolStream.insertInFile', "Inserting text in {0}", file)) + : localize('toolStream.insert', "Inserting text"); +} + +export function getStreamingPatchMessage( + paths: readonly string[], + lineCount: number | undefined, + resolvePath: ToolPathResolver = identityPathResolver, +): StringOrMarkdown { + const fileList = paths.map(path => formatPath(path, resolvePath)).filter(path => path !== undefined).join(', ') || undefined; + if (lineCount !== undefined) { + if (fileList) { + return lineCount === 1 + ? markdown(localize('toolStream.patchOneLineInFiles', "Generating patch (1 line) in {0}", fileList)) + : markdown(localize('toolStream.patchLinesInFiles', "Generating patch ({0} lines) in {1}", lineCount, fileList)); + } + return lineCount === 1 + ? localize('toolStream.patchOneLine', "Generating patch (1 line)") + : localize('toolStream.patchLines', "Generating patch ({0} lines)", lineCount); + } + return fileList + ? markdown(localize('toolStream.patchFiles', "Generating patch in {0}", fileList)) + : localize('toolStream.patch', "Generating patch"); +} diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index b66a493809b..e7aa0a3ab9e 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -37,7 +37,6 @@ import { IAgentHostGitService, META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; import { NodeWorkerDiffComputeService } from './diffComputeService.js'; import { computeSessionDiffs, computeTurnDiffs, computeUnionedDiffs, type IIncrementalDiffOptions, type ISessionDiffSource } from './sessionDiffAggregator.js'; -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'; @@ -475,7 +474,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC this._publishChangesetDiffs(session, compareUri, []); return compareUri; } - const workingDir = await this._resolveWorkingDirectory(ref.object); + const workingDir = await this._resolveWorkingDirectory(session); if (!workingDir) { this._stateManager.dispatchServerAction(compareUri, { type: ActionType.ChangesetStatusChanged, @@ -588,7 +587,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC private async _computeTurnDiffsPreferCheckpoint(session: ProtocolURI, db: ISessionDatabase, turnId: string): Promise { const pair = await this._checkpointService.getTurnCheckpointPair(URI.parse(session), turnId); if (pair && pair.parent !== pair.current) { - const workingDir = await this._resolveWorkingDirectory(db); + const workingDir = await this._resolveWorkingDirectory(session); if (workingDir) { const fromRefDiffs = await this._gitService.computeFileDiffsBetweenRefs(workingDir, { sessionUri: session, @@ -609,13 +608,14 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC return computeTurnDiffs(session, db, this._diffComputeService, turnId); } - private async _resolveWorkingDirectory(db: ISessionDatabase): Promise { - // Checkpoint baseline writes `checkpoint.workingDir` alongside - // `checkpoint.baseRef`. We use that as the canonical working - // directory for checkpoint diff computation; reading it here keeps - // the changeset service out of agent-specific metadata keys. - const raw = await db.getMetadata(META_CHECKPOINT_WORKING_DIR); - return raw ? URI.parse(raw) : undefined; + private async _resolveWorkingDirectory(session: ProtocolURI): Promise { + // For the time being we default to the first working directory in the list, if any. + // In the future we may want to support multiple working directories per session, + // but for now we only support one. + const workingDirectories = this._configurationService.getEffectiveWorkingDirectories(session); + return workingDirectories && workingDirectories.length > 0 + ? URI.parse(workingDirectories[0]) + : undefined; } // ---- Lifecycle hooks invoked by AgentSideEffects ----------------------- @@ -1027,7 +1027,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC const sessionUri = URI.parse(session); const [baseline, pair] = await Promise.all([ - this._checkpointService.getBaselineCheckpointRef(sessionUri), + this._checkpointService.getBaselineCheckpoint(sessionUri), this._checkpointService.getTurnCheckpointPair(sessionUri, latestTurnId), ]); if (!baseline || !pair) { diff --git a/src/vs/platform/agentHost/node/agentHostCheckpointService.ts b/src/vs/platform/agentHost/node/agentHostCheckpointService.ts index 22459af8929..222407d2d51 100644 --- a/src/vs/platform/agentHost/node/agentHostCheckpointService.ts +++ b/src/vs/platform/agentHost/node/agentHostCheckpointService.ts @@ -7,19 +7,11 @@ import { SequencerByKey } from '../../../base/common/async.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; -import { IAgentHostCheckpointService, META_CHECKPOINT_BASE_REF, buildCheckpointRefName } from '../common/agentHostCheckpointService.js'; +import { IAgentHostCheckpointService, buildCheckpointRefName } from '../common/agentHostCheckpointService.js'; import { AgentSession } from '../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; - -/** - * `session_metadata` key under which the working directory used for - * checkpoint capture is persisted (set when the baseline is created). - * Stored as `URI.toString()`. Read by `captureTurnCheckpoint` / - * `disposeSessionData` so they can resolve the repo without per-call - * working-directory plumbing. - */ -export const META_CHECKPOINT_WORKING_DIR = 'checkpoint.workingDir'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; export class AgentHostCheckpointService extends Disposable implements IAgentHostCheckpointService { declare readonly _serviceBrand: undefined; @@ -34,6 +26,7 @@ export class AgentHostCheckpointService extends Disposable implements IAgentHost constructor( @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @IAgentConfigurationService private readonly _agentConfigService: IAgentConfigurationService, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @ILogService private readonly _logService: ILogService, ) { @@ -42,185 +35,222 @@ export class AgentHostCheckpointService extends Disposable implements IAgentHost // deleted, enumerate and delete every checkpoint ref we created // for that session BEFORE the database file disappears. The // `waitUntil` API blocks `deleteSessionData` until our promise - // settles, so the deletion can't race the ref read. + // settles, so the deletion can't race the ref read. The working + // directories come from the event because the session has already + // been removed from the state manager by this point. this._register(this._sessionDataService.onWillDeleteSessionData(e => { - e.waitUntil(this.disposeSessionData(e.session)); + e.waitUntil(this.deleteCheckpoints(e.session, e.workingDirectories)); })); } - captureBaseline(sessionUri: URI, workingDirectory: URI | undefined): Promise { - return this._sequencer.queue(sessionUri.toString(), () => this._captureBaseline(sessionUri, workingDirectory)); + captureBaselineCheckpoint(sessionUri: URI, workingDirectories: readonly URI[] | undefined): Promise { + return this._sequencer.queue(sessionUri.toString(), () => this._captureBaseline(sessionUri, workingDirectories)); } - private async _captureBaseline(sessionUri: URI, workingDirectory: URI | undefined): Promise { - if (!workingDirectory) { - return undefined; + private async _captureBaseline(sessionUri: URI, workingDirectories: readonly URI[] | undefined): Promise { + if (!workingDirectories || workingDirectories.length === 0) { + this._logService.trace(`[AgentHostCheckpoint] Skipping baseline capture for ${sessionUri.toString()} as no working directories are found`); + return; } - const ref = this._sessionDataService.openDatabase(sessionUri); - try { - const existing = await ref.object.getMetadata(META_CHECKPOINT_BASE_REF); - if (existing) { - return existing; + + const sanitized = this._sanitizedSessionId(sessionUri); + const baselineRefName = buildCheckpointRefName(sanitized, 0); + + for (const workingDirectoryUri of workingDirectories) { + try { + // Check that the working directory has a git repository + const repositoryRootUri = await this._gitService.getRepositoryRoot(workingDirectoryUri); + if (!repositoryRootUri) { + continue; + } + + // Check if the baseline ref already exists + const baselineCheckpointRef = await this.getBaselineCheckpoint(sessionUri, repositoryRootUri); + if (baselineCheckpointRef) { + continue; + } + + // Create checkpoint commit + const commit = await this._writeCheckpointCommit(repositoryRootUri, undefined, `Agent host session ${sanitized} - baseline checkpoint`); + if (!commit) { + continue; + } + + // Update the baseline ref to point to the new commit + await this._gitService.updateRef(repositoryRootUri, baselineRefName, commit); + this._logService.trace(`[AgentHostCheckpoint] Captured baseline for ${sessionUri.toString()} at ${baselineRefName} in working directory ${workingDirectoryUri.toString()}`); + } catch (err) { + this._logService.warn(`[AgentHostCheckpoint] Failed to capture baseline for ${sessionUri.toString()} in working directory ${workingDirectoryUri.toString()}`, err); } - const sanitized = this._sanitizedSessionId(sessionUri); - const refName = buildCheckpointRefName(sanitized, 0); - const commit = await this._writeCheckpointCommit(workingDirectory, undefined, `Agent host session ${sanitized} - baseline checkpoint`); - if (!commit) { - return undefined; - } - const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory); - if (!repoRoot) { - return undefined; - } - await this._gitService.updateRef(repoRoot, refName, commit.commitOid); - await ref.object.setMetadata(META_CHECKPOINT_BASE_REF, refName); - await ref.object.setMetadata(META_CHECKPOINT_WORKING_DIR, workingDirectory.toString()); - this._logService.trace(`[AgentHostCheckpoint] Captured baseline for ${sessionUri.toString()} at ${refName}`); - return refName; - } catch (err) { - this._logService.warn(`[AgentHostCheckpoint] Failed to capture baseline for ${sessionUri.toString()}`, err); - return undefined; - } finally { - ref.dispose(); } } - captureTurnCheckpoint(sessionUri: URI, turnId: string): Promise { - return this._sequencer.queue(sessionUri.toString(), () => this._captureTurnCheckpoint(sessionUri, turnId)); + captureTurnCheckpoint(sessionUri: URI, turnId: string, workingDirectories: readonly URI[] | undefined): Promise { + return this._sequencer.queue(sessionUri.toString(), () => this._captureTurnCheckpoint(sessionUri, turnId, workingDirectories)); } - private async _captureTurnCheckpoint(sessionUri: URI, turnId: string): Promise { + private async _captureTurnCheckpoint(sessionUri: URI, turnId: string, workingDirectories: readonly URI[] | undefined): Promise { + if (!workingDirectories || workingDirectories.length === 0) { + this._logService.trace(`[AgentHostCheckpoint] Skipping turn checkpoint capture for ${sessionUri.toString()} as no working directories are found`); + return; + } + const ref = this._sessionDataService.openDatabase(sessionUri); + try { - const [baseRef, workingDirRaw, existing, prevTurnRef] = await Promise.all([ - ref.object.getMetadata(META_CHECKPOINT_BASE_REF), - ref.object.getMetadata(META_CHECKPOINT_WORKING_DIR), - ref.object.getTurnCheckpointRef(turnId), - ref.object.getPreviousCheckpointRef(turnId), - ]); - if (existing) { - return existing; - } - if (!baseRef || !workingDirRaw) { - // Baseline never captured — session is not git-backed or - // baseline failed. Nothing to chain from. - return undefined; - } - const workingDirectory = URI.parse(workingDirRaw); - const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory); - if (!repoRoot) { - return undefined; - } - const parentRef = prevTurnRef ?? baseRef; - const parentCommitOid = await this._gitService.revParse(repoRoot, parentRef); - if (!parentCommitOid) { - this._logService.warn(`[AgentHostCheckpoint] Parent ref ${parentRef} missing for session ${sessionUri.toString()}`); - return undefined; - } - - const tree = await this._gitService.captureWorkingTreeAsTree(workingDirectory); - if (!tree) { - return undefined; - } - - // No-op turn: if the tree is identical to the parent's tree, - // don't create a redundant commit/ref — point the turn at the - // parent ref so per-turn diffs against it are empty by - // construction. - const parentTree = await this._gitService.revParse(repoRoot, `${parentCommitOid}^{tree}`); - if (parentTree && parentTree === tree) { - await ref.object.setTurnCheckpointRef(turnId, parentRef); - this._logService.trace(`[AgentHostCheckpoint] No-op turn ${turnId} for ${sessionUri.toString()}; reusing ${parentRef}`); - return parentRef; - } - const sanitized = this._sanitizedSessionId(sessionUri); const turnNumber = await this._nextTurnNumber(ref.object); const refName = buildCheckpointRefName(sanitized, turnNumber); - const commitOid = await this._gitService.commitTree(repoRoot, tree, parentCommitOid, `Agent host session ${sanitized} - turn ${turnNumber}`); - if (!commitOid) { - return undefined; - } - await this._gitService.updateRef(repoRoot, refName, commitOid); - await ref.object.setTurnCheckpointRef(turnId, refName); - this._logService.trace(`[AgentHostCheckpoint] Captured turn ${turnNumber} for ${sessionUri.toString()} at ${refName}`); - return refName; - } catch (err) { - this._logService.warn(`[AgentHostCheckpoint] Failed to capture turn checkpoint for ${sessionUri.toString()}/${turnId}`, err); - return undefined; - } finally { - ref.dispose(); - } - } - async getTurnCheckpointPair(sessionUri: URI, turnId: string): Promise<{ parent: string; current: string } | undefined> { - const ref = this._sessionDataService.openDatabase(sessionUri); - try { - const [current, prev, baseRef] = await Promise.all([ + const [checkpointRef, prevTurnCheckpointRef] = await Promise.all([ ref.object.getTurnCheckpointRef(turnId), ref.object.getPreviousCheckpointRef(turnId), - ref.object.getMetadata(META_CHECKPOINT_BASE_REF), ]); - if (!current) { - return undefined; + + if (checkpointRef) { + // Already captured for this + // turn, return the existing ref. + return; } - const parent = prev ?? baseRef; - if (!parent) { - return undefined; + + let capturedCheckpointRef = false; + for (const workingDirectoryUri of workingDirectories) { + try { + // Check that the working directory has a git repository + const repositoryRootUri = await this._gitService.getRepositoryRoot(workingDirectoryUri); + if (!repositoryRootUri) { + continue; + } + + // Check if the baseline ref exists for this repository. If it + // doesn't exist, we cannot capture a turn checkpoint for this repository. + const baselineCheckpointRef = await this.getBaselineCheckpoint(sessionUri, repositoryRootUri); + if (!baselineCheckpointRef) { + continue; + } + + const parentRef = prevTurnCheckpointRef ?? baselineCheckpointRef; + const parentCommitOid = await this._gitService.revParse(repositoryRootUri, parentRef); + if (!parentCommitOid) { + this._logService.warn(`[AgentHostCheckpoint] Parent ref ${parentRef} missing for session ${sessionUri.toString()} in working directory ${workingDirectoryUri.toString()}`); + continue; + } + + const tree = await this._gitService.captureWorkingTreeAsTree(repositoryRootUri); + if (!tree) { + continue; + } + + const commitOid = await this._gitService.commitTree(repositoryRootUri, tree, parentCommitOid, `Agent host session ${sanitized} - turn ${turnNumber}`); + if (!commitOid) { + continue; + } + + await this._gitService.updateRef(repositoryRootUri, refName, commitOid); + capturedCheckpointRef = true; + + this._logService.trace(`[AgentHostCheckpoint] Captured turn ${turnNumber} for ${sessionUri.toString()} in working directory ${workingDirectoryUri.toString()} at ${refName}`); + } catch (err) { + this._logService.warn(`[AgentHostCheckpoint] Failed to capture turn checkpoint for ${sessionUri.toString()} in working directory ${workingDirectoryUri.toString()}`, err); + } } - return { parent, current }; + + if (capturedCheckpointRef) { + await ref.object.setTurnCheckpointRef(turnId, refName); + } + } catch (err) { + this._logService.warn(`[AgentHostCheckpoint] Failed to capture turn checkpoint for ${sessionUri.toString()}/${turnId}`, err); } finally { ref.dispose(); } } - async getBaselineCheckpointRef(sessionUri: URI): Promise { + async getTurnCheckpointPair( + sessionUri: URI, + turnId: string, + workingDirectory?: URI + ): Promise<{ parent: string; current: string } | undefined> { const ref = this._sessionDataService.openDatabase(sessionUri); try { - return await ref.object.getMetadata(META_CHECKPOINT_BASE_REF); + const [currentCheckpointRef, previousCheckpointRef, baselineCheckpointRef] = await Promise.all([ + ref.object.getTurnCheckpointRef(turnId), + ref.object.getPreviousCheckpointRef(turnId), + this.getBaselineCheckpoint(sessionUri, workingDirectory) + ]); + if (!currentCheckpointRef || !baselineCheckpointRef) { + return undefined; + } + + return { + current: currentCheckpointRef, + parent: previousCheckpointRef ?? baselineCheckpointRef + }; } finally { ref.dispose(); } } - async disposeSessionData(sessionUri: URI): Promise { - await this._sequencer.queue(sessionUri.toString(), () => this._disposeSessionData(sessionUri)); + async getBaselineCheckpoint(sessionUri: URI, workingDirectory?: URI): Promise { + if (!workingDirectory) { + const workingDirectories = this._agentConfigService.getEffectiveWorkingDirectories(sessionUri.toString()); + if (!workingDirectories || workingDirectories.length === 0) { + return undefined; + } + + workingDirectory = URI.parse(workingDirectories[0]); + } + + const sanitized = this._sanitizedSessionId(sessionUri); + const baselineRefName = buildCheckpointRefName(sanitized, 0); + + const baselineRef = await this._gitService.revParse(workingDirectory, baselineRefName); + return baselineRef ? baselineRefName : undefined; } - private async _disposeSessionData(sessionUri: URI): Promise { + async deleteCheckpoints(sessionUri: URI, workingDirectories?: readonly string[]): Promise { + await this._sequencer.queue(sessionUri.toString(), () => this._deleteCheckpoints(sessionUri, workingDirectories)); + } + + private async _deleteCheckpoints(sessionUri: URI, workingDirectories?: readonly string[]): Promise { + if (!workingDirectories || workingDirectories.length === 0) { + return; + } + const refHandle = await this._sessionDataService.tryOpenDatabase(sessionUri); if (!refHandle) { return; } + try { - const [workingDirRaw, baseRef, turnRefs] = await Promise.all([ - refHandle.object.getMetadata(META_CHECKPOINT_WORKING_DIR), - refHandle.object.getMetadata(META_CHECKPOINT_BASE_REF), - refHandle.object.getAllCheckpointRefs(), - ]); - if (!workingDirRaw) { + const turnRefs = await refHandle.object.getAllCheckpointRefs(); + if (turnRefs.length === 0) { return; } - const workingDirectory = URI.parse(workingDirRaw); - const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory); - if (!repoRoot) { - return; + + for (const workingDirectory of workingDirectories) { + try { + const workingDirectoryUri = URI.parse(workingDirectory); + + const repositoryRootUri = await this._gitService.getRepositoryRoot(workingDirectoryUri); + if (!repositoryRootUri) { + continue; + } + + const baselineCheckpointRef = await this.getBaselineCheckpoint(sessionUri, repositoryRootUri); + if (!baselineCheckpointRef) { + continue; + } + + // Dedup baseRef and turnRefs (a no-op turn may reuse its + // parent's ref). Deleting the same ref twice is harmless but + // noisy, and the batch API takes a list. + const checkpointRefs = new Set([baselineCheckpointRef, ...turnRefs]); + await this._gitService.deleteRefs(repositoryRootUri, [...checkpointRefs]); + this._logService.trace(`[AgentHostCheckpoint] Deleted ${checkpointRefs.size} checkpoint refs for ${sessionUri.toString()} in working directory ${workingDirectory}`); + } catch (err) { + this._logService.warn(`[AgentHostCheckpoint] Failed to delete checkpoint refs for ${sessionUri.toString()} in working directory ${workingDirectory}`, err); + } } - // Dedup baseRef and turnRefs (a no-op turn may reuse its - // parent's ref). Deleting the same ref twice is harmless but - // noisy, and the batch API takes a list. - const all = new Set(); - if (baseRef) { - all.add(baseRef); - } - for (const r of turnRefs) { - all.add(r); - } - if (all.size === 0) { - return; - } - await this._gitService.deleteRefs(repoRoot, [...all]); - this._logService.trace(`[AgentHostCheckpoint] Deleted ${all.size} checkpoint refs for ${sessionUri.toString()}`); } catch (err) { this._logService.warn(`[AgentHostCheckpoint] Failed to dispose checkpoint refs for ${sessionUri.toString()}`, err); } finally { @@ -229,23 +259,21 @@ export class AgentHostCheckpointService extends Disposable implements IAgentHost } private async _writeCheckpointCommit( - workingDirectory: URI, + repositoryRootUri: URI, parentOid: string | undefined, message: string, - ): Promise<{ commitOid: string } | undefined> { - const tree = await this._gitService.captureWorkingTreeAsTree(workingDirectory); + ): Promise { + const tree = await this._gitService.captureWorkingTreeAsTree(repositoryRootUri); if (!tree) { return undefined; } - const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory); - if (!repoRoot) { - return undefined; - } - const commitOid = await this._gitService.commitTree(repoRoot, tree, parentOid, message); + + const commitOid = await this._gitService.commitTree(repositoryRootUri, tree, parentOid, message); if (!commitOid) { return undefined; } - return { commitOid }; + + return commitOid; } /** diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index 0c8e47a2154..6619f3b2a7a 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -9,6 +9,7 @@ import { Emitter } from '../../../base/common/event.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE } from '../common/agentHostGitStateService.js'; import { ISessionGitHubState, readSessionGitHubState, readSessionGitState, SessionLifecycle, withSessionGitHubState, withSessionGitState, type ISessionGitState } from '../common/state/sessionState.js'; +import { MAX_SESSION_ISSUE_REFERENCES, parseGitHubIssueReferences, toGitHubIssueUrl } from '../common/githubIssueReferences.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; import { ISessionDataService } from '../common/sessionDataService.js'; @@ -93,6 +94,36 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi } } + /** + * Scans a user message for GitHub issue references and merges them into the + * session's GitHub state. References already recorded are preserved and keep + * their position, so the list reflects the order in which the session first + * mentioned each issue. + */ + async attachSessionGitHubIssues(sessionKey: string, text: string): Promise { + const references = parseGitHubIssueReferences(text); + if (references.length === 0) { + return; + } + + const currentUrls = readSessionGitHubState(this._stateManager.getSessionState(sessionKey)?._meta)?.issueUrls ?? []; + const nextUrls = [...currentUrls]; + for (const reference of references) { + const url = toGitHubIssueUrl(reference); + if (!nextUrls.includes(url)) { + nextUrls.push(url); + } + } + + if (nextUrls.length === currentUrls.length) { + return; + } + + await this.setSessionGitHubState(sessionKey, { + issueUrls: nextUrls.slice(0, MAX_SESSION_ISSUE_REFERENCES) + } satisfies ISessionGitHubState); + } + async refreshSessionGitState(sessionKey: string, workingDirectory: URI | undefined): Promise { const sessionState = this._stateManager.getSessionState(sessionKey); if (sessionState?.lifecycle === SessionLifecycle.CreationFailed) { diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 07df867e2f3..af8d32ac0bd 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -86,7 +86,6 @@ import { IAgentPluginManager } from '../common/agentPluginManager.js'; import { AgentPluginManager } from './agentPluginManager.js'; import { AgentHostGitService } from './agentHostGitService.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; -import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { registerPendingEditContentProvider } from './copilot/pendingEditContentStore.js'; @@ -185,12 +184,6 @@ async function startAgentHost(): Promise { diServices.set(ISandboxHelperService, new SandboxHelperService()); const gitService = instantiationService.createInstance(AgentHostGitService); diServices.set(IAgentHostGitService, gitService); - // Checkpoint service depends on session data + git services, so - // construct it AFTER both are registered. Consumed by CopilotAgent - // (baseline capture) and AgentService's inner DI (changeset - // pipeline / end-of-turn capture). - const checkpointService = disposables.add(instantiationService.createInstance(AgentHostCheckpointService)); - diServices.set(IAgentHostCheckpointService, checkpointService); // Register the agent SDK downloader BEFORE any service that injects it // (ClaudeAgentSdkService and CodexAgent below). The downloader resolves // dev-override env var → on-disk cache → product.agentSdks download. @@ -210,7 +203,7 @@ async function startAgentHost(): Promise { diServices.set(IByokLmProxyService, byokLmProxyService); const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService, fetchFn)); diServices.set(IAgentHostOTelService, agentHostOTelService); - agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)]); + agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)]); const networkDiagnosticsService = instantiationService.createInstance(NetworkDiagnosticsService); diServices.set(INetworkDiagnosticsService, networkDiagnosticsService); agentService.setNetworkDiagnosticsService(networkDiagnosticsService); @@ -231,6 +224,7 @@ async function startAgentHost(): Promise { diServices.set(IEditArcReporterService, editArcReporterService); diServices.set(IAgentHostGitHubEndpointService, agentService.gitHubEndpointService); diServices.set(IAgentHostCompletions, agentService.completionsService); + diServices.set(IAgentHostCheckpointService, agentService.checkpointService); // CopilotApiService and the proxies that consume it are created AFTER the // GitHub endpoint service is re-exported (above) so CAPI endpoint discovery diff --git a/src/vs/platform/agentHost/node/agentHostReviewService.ts b/src/vs/platform/agentHost/node/agentHostReviewService.ts index 73b0c63766a..6da7c46519e 100644 --- a/src/vs/platform/agentHost/node/agentHostReviewService.ts +++ b/src/vs/platform/agentHost/node/agentHostReviewService.ts @@ -51,10 +51,11 @@ export class AgentHostReviewService extends Disposable implements IAgentHostRevi // 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. + // resolve the repository root is supplied by the event (resolved + // before the session's live state was torn down) so we don't + // persist our own copy. this._register(this._sessionDataService.onWillDeleteSessionData(e => { - e.waitUntil(this.disposeSessionData(e.session.toString())); + e.waitUntil(this.disposeSessionData(e.session.toString(), e.workingDirectories)); })); } @@ -229,30 +230,31 @@ export class AgentHostReviewService extends Disposable implements IAgentHostRevi return { repoRoot, baselineTree, reviewedRef, reviewedCommit, reviewedTree }; } - async disposeSessionData(session: ProtocolURI): Promise { - await this._sequencer.queue(session, () => this._disposeSessionData(session)); + async disposeSessionData(session: ProtocolURI, workingDirectories?: readonly string[]): Promise { + await this._sequencer.queue(session, () => this._disposeSessionData(session, workingDirectories)); } - private async _disposeSessionData(session: ProtocolURI): Promise { - const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; - if (!workingDirectory) { - // No working directory means we can't resolve the repository root - // (session was never git-backed, or its working directory is gone). + private async _disposeSessionData(session: ProtocolURI, workingDirectories?: readonly string[]): Promise { + if (!workingDirectories || workingDirectories.length === 0) { return; } - const repoRoot = await this._gitService.getRepositoryRoot(URI.parse(workingDirectory)); - if (!repoRoot) { - return; - } + const sanitizedSessionId = this._sanitizedSessionId(session); + const reviewedRef = buildReviewedRefName(sanitizedSessionId); - try { - const reviewedRef = buildReviewedRefName(this._sanitizedSessionId(session)); - await this._gitService.deleteRefs(repoRoot, [reviewedRef]); + for (const workingDirectory of workingDirectories) { + try { + const workingDirectoryUri = URI.parse(workingDirectory); + const repositoryRootUri = await this._gitService.getRepositoryRoot(workingDirectoryUri); + if (!repositoryRootUri) { + continue; + } - 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); + await this._gitService.deleteRefs(repositoryRootUri, [reviewedRef]); + this._logService.trace(`[AgentHostReview][_disposeSessionData] Deleted reviewed ref for ${session} in working directory ${workingDirectory}`); + } catch (err) { + this._logService.warn(`[AgentHostReview][_disposeSessionData] Failed to dispose reviewed ref for ${session} in working directory ${workingDirectory}`, err); + } } } diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 50123ea0cc0..3f86955bed6 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -83,7 +83,6 @@ import { IAgentPluginManager } from '../common/agentPluginManager.js'; import { registerPendingEditContentProvider } from './copilot/pendingEditContentStore.js'; import { AgentHostGitService } from './agentHostGitService.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; -import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { createAgentHostTelemetryService } from './agentHostTelemetryService.js'; @@ -254,11 +253,9 @@ async function main(): Promise { diServices.set(ISandboxHelperService, new SandboxHelperService()); const gitService = instantiationService.createInstance(AgentHostGitService); diServices.set(IAgentHostGitService, gitService); - const checkpointService = disposables.add(instantiationService.createInstance(AgentHostCheckpointService)); - 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, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)]); + const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)]); disposables.add(agentService); diServices.set(IAgentService, agentService); diServices.set(IAgentHostStateManager, agentService.stateManager); @@ -283,6 +280,7 @@ async function main(): Promise { diServices.set(IEditArcReporterService, editArcReporterService); diServices.set(IAgentHostGitHubEndpointService, agentService.gitHubEndpointService); diServices.set(IAgentHostCompletions, agentService.completionsService); + diServices.set(IAgentHostCheckpointService, agentService.checkpointService); diServices.set(IAgentHostGitService, gitService); // Register `ICopilotApiService` BEFORE `IClaudeProxyService` — // the proxy service constructor requires it. diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index 33688a362ad..a878a9d8a08 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -132,7 +132,7 @@ export interface IAgentHostToolInvokedReport { toolId: string; toolSourceKind: string; result: ToolInvokedResult; - invocationTimeMs: number; + invocationTimeMs?: number; } type AgentHostToolCallResponseType = 'success' | 'cancelled' | 'failed'; diff --git a/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts index 2d674bdad17..76b30e5f104 100644 --- a/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts @@ -55,13 +55,22 @@ export function toolSourceKindFromContributor(contributor: ToolCallContributor | } } +function canRefineContributor(current: ToolCallContributor | undefined, next: ToolCallContributor): boolean { + if (current?.kind === ToolCallContributorKind.Client) { + return next.kind === ToolCallContributorKind.Client && next.clientId === current.clientId; + } + return next.kind !== ToolCallContributorKind.Client; +} + /** Per-tool-call timing state, keyed by `session:toolCallId`. */ interface IToolCallTiming { - readonly stopWatch: StopWatch; + readonly lifecycleStopWatch: StopWatch; + invocationStopWatch?: StopWatch; readonly provider: string; readonly session: string; readonly toolId: string; - readonly toolSourceKind: string; + contributor: ToolCallContributor | undefined; + toolSourceKind: string; } interface IStalledToolCall { @@ -95,14 +104,33 @@ export class AgentHostToolCallTracker extends Disposable { toolCallStarted(provider: string, session: string, toolCallId: string, toolName: string, contributor: ToolCallContributor | undefined): void { this._toolCalls.set(this._key(session, toolCallId), { - stopWatch: StopWatch.create(true), + lifecycleStopWatch: StopWatch.create(true), provider, session, toolId: toolName, + contributor, toolSourceKind: toolSourceKindFromContributor(contributor), }); } + toolCallMetadataUpdated(session: string, toolCallId: string, contributor: ToolCallContributor | undefined): void { + const timing = this._toolCalls.get(this._key(session, toolCallId)); + if (!timing) { + return; + } + if (contributor && canRefineContributor(timing.contributor, contributor)) { + timing.contributor = contributor; + timing.toolSourceKind = toolSourceKindFromContributor(contributor); + } + } + + toolCallExecutionStarted(session: string, toolCallId: string): void { + const timing = this._toolCalls.get(this._key(session, toolCallId)); + if (timing && !timing.invocationStopWatch) { + timing.invocationStopWatch = StopWatch.create(true); + } + } + toolCallCompleted(session: string, toolCallId: string, result: ToolCallResult): void { const key = this._key(session, toolCallId); const timing = this._toolCalls.get(key); @@ -114,7 +142,7 @@ export class AgentHostToolCallTracker extends Disposable { } this._toolCalls.delete(key); const resultBucket = deriveToolInvokedResult(result); - const totalTimeMs = timing.stopWatch.elapsed(); + const totalTimeMs = timing.lifecycleStopWatch.elapsed(); this._reporter.toolInvoked({ provider: timing.provider, @@ -122,7 +150,7 @@ export class AgentHostToolCallTracker extends Disposable { toolId: timing.toolId, toolSourceKind: timing.toolSourceKind, result: resultBucket, - invocationTimeMs: totalTimeMs, + invocationTimeMs: timing.invocationStopWatch?.elapsed(), }); const stalled = this._stalledToolCalls.get(key); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index e851c6b0ed8..2a0a0f3a1ce 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -9,7 +9,7 @@ import { DeferredPromise, disposableTimeout, ResourceQueue } from '../../../base import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../base/common/map.js'; +import { LRUCache, ResourceMap } from '../../../base/common/map.js'; import { getExtensionForMimeType, getMediaMime } from '../../../base/common/mime.js'; import { Schemas } from '../../../base/common/network.js'; import { IObservable, observableValue } from '../../../base/common/observable.js'; @@ -23,7 +23,7 @@ import { InstantiationService } from '../../instantiation/common/instantiationSe import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, AgentSignal, AgentHostSessionReleaseGraceMsEnvVar, IAgent, IAgentChatDataChange, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentHostAuthTokenRequest, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkEndpoint, IAgentHostNetworkFetchResult, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../common/agentService.js'; -import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; +import { type ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js'; @@ -43,7 +43,7 @@ import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHost import { ISessionDbUriFields, parseSessionDbUri } from '../common/sessionDbUri.js'; import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js'; import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; -import { IAgentHostGitService } from '../common/agentHostGitService.js'; +import { IAgentHostGitService, tryResolvePrimaryWorktreeRoot } from '../common/agentHostGitService.js'; import { AgentSideEffects } from './agentSideEffects.js'; import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; @@ -53,7 +53,7 @@ import { type IChatContextSnapshot, type ISessionServerToolAccessor } from './sh import { WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT, worktreeProjectFromRepositoryRoot } from './shared/worktreeIsolation.js'; import { AgentHostChangesetService } from './agentHostChangesetService.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; -import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../common/agentHostCheckpointService.js'; +import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; import { IAgentHostReviewService } from '../common/agentHostReviewService.js'; import { AgentHostChangesetCoordinator } from './agentHostChangesetCoordinator.js'; import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js'; @@ -86,6 +86,7 @@ import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscard import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js'; import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js'; import { AgentHostReviewService } from './agentHostReviewService.js'; +import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -235,6 +236,9 @@ export class AgentService extends Disposable implements IAgentService { /** Exposes the GitHub endpoint service so agent providers share GitHub (Enterprise) resource resolution. */ get gitHubEndpointService(): IAgentHostGitHubEndpointService { return this._gitHubEndpointService; } + /** Exposes the checkpoint service so agent providers can capture session baselines. */ + get checkpointService(): IAgentHostCheckpointService { return this._checkpointService; } + /** Registered providers keyed by their {@link AgentProvider} id. */ private readonly _providers = new Map(); /** Maps each active session URI (toString) to its owning provider. */ @@ -286,6 +290,8 @@ export class AgentService extends Disposable implements IAgentService { /** Server-side host for the agent host's server tools. */ private readonly _serverToolHost: AgentServerToolHost; private readonly _configurationService: AgentConfigurationService; + /** Captures baseline / per-turn git checkpoints backing the changeset pipeline. */ + private readonly _checkpointService: IAgentHostCheckpointService; /** * Host-owned worktree isolation controller. Set post-construction via * {@link setWorktreeIsolation} because it depends on the branch-name @@ -295,6 +301,8 @@ export class AgentService extends Disposable implements IAgentService { * agents stay unaware of the folder-vs-worktree distinction. */ private _worktree: WorktreeIsolation | undefined; + /** Successful list-time repository-root resolutions; eviction only causes safe re-resolution. */ + private readonly _normalizedWorktreeRepositoryRoots = new LRUCache(100); /** Single source of truth for GitHub (Enterprise) endpoints and protected resources. */ private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService; /** Pluggable completion item providers (e.g. workspace file completions, agent-specific @-mentions). */ @@ -382,7 +390,6 @@ export class AgentService extends Disposable implements IAgentService { private readonly _sessionDataService: ISessionDataService, private readonly _productService: IProductService, private readonly _gitService: IAgentHostGitService, - private readonly _checkpointService: IAgentHostCheckpointService = NULL_CHECKPOINT_SERVICE, private readonly _rootConfigResource?: URI, private readonly _telemetryService: ITelemetryService = NullTelemetryService, _fileMonitorService?: IAgentHostFileMonitorService, @@ -449,10 +456,7 @@ export class AgentService extends Disposable implements IAgentService { this._gitStateService = this._register(instantiationService.createInstance(AgentHostGitStateService)); services.set(IAgentHostGitStateService, this._gitStateService); - // The checkpoint service is constructed in the outer agent-host - // DI scope and passed via {@link _checkpointService}; register it - // in the inner service collection so the changeset service / - // side effects can resolve it via DI. + this._checkpointService = this._register(instantiationService.createInstance(AgentHostCheckpointService)); services.set(IAgentHostCheckpointService, this._checkpointService); // The subscription service manages the lifecycle of changeset subscriptions. The service @@ -535,6 +539,10 @@ export class AgentService extends Disposable implements IAgentService { // Check for a GitHub pull request associated with the session's branch. void this._gitStateService.attachSessionGitHubPullRequest(session.toString()); }, + onUserMessage: (session, text) => { + // Record the GitHub issues the message references on the session. + void this._gitStateService.attachSessionGitHubIssues(session.toString(), text); + }, })); // Server-side tools, executed in-process against each session's own @@ -825,6 +833,41 @@ export class AgentService extends Disposable implements IAgentService { }; } + /** + * Repairs repository roots written by older builds that treated a parent linked checkout as the repository. + * Listing performs this migration because archived sessions may never resume through WorktreeIsolation's metadata reader. + */ + private async _normalizeListedWorktreeRepositoryRoot(session: IAgentSessionMetadata, database: ISessionDatabase, repositoryRootRaw: string): Promise { + const storedRepositoryRootRaw = repositoryRootRaw; + const persistedRoot = URI.parse(repositoryRootRaw); + const sessionStr = session.session.toString(); + let primaryRoot = this._normalizedWorktreeRepositoryRoots.get(sessionStr); + if (!primaryRoot) { + const workingDirectory = session.workingDirectories?.[0]; + const checkoutRoot = workingDirectory && await this._fileExistsSafe(workingDirectory) ? workingDirectory : persistedRoot; + try { + primaryRoot = await tryResolvePrimaryWorktreeRoot(this._gitService, checkoutRoot) + ?? (checkoutRoot.toString() !== persistedRoot.toString() ? await tryResolvePrimaryWorktreeRoot(this._gitService, persistedRoot) : undefined); + if (primaryRoot) { + this._normalizedWorktreeRepositoryRoots.set(sessionStr, primaryRoot); + } + } catch (error) { + this._logService.warn(`[AgentService][listSessions] Failed to resolve primary worktree for ${session.session}`, error); + } + } + if (primaryRoot) { + repositoryRootRaw = primaryRoot.toString(); + } + if (repositoryRootRaw !== storedRepositoryRootRaw) { + try { + await database.setMetadata(WORKTREE_META_REPOSITORY_ROOT, repositoryRootRaw); + } catch (error) { + this._logService.warn(`[AgentService][listSessions] Failed to normalize worktree repository metadata for ${session.session}`, error); + } + } + return repositoryRootRaw; + } + async listSessions(): Promise { this._logService.trace('[AgentService] listSessions called'); const results = await Promise.all( @@ -893,12 +936,11 @@ export class AgentService extends Disposable implements IAgentService { updated = { ...updated, _meta: withSessionWorkspaceless(updated._meta, m[AH_META_WORKSPACELESS_DB_KEY] === 'true') }; } - // Worktree-isolated sessions run out of `.worktrees/` but - // must group under the repository in the sessions UI. Merge the repo - // project persisted alongside the worktree metadata so a list refresh - // doesn't revert the workspace name to the worktree directory. No-op - // for folder sessions (key absent). - const worktreeProject = worktreeProjectFromRepositoryRoot(m[WORKTREE_META_REPOSITORY_ROOT]); + let repositoryRootRaw = m[WORKTREE_META_REPOSITORY_ROOT]; + if (repositoryRootRaw) { + repositoryRootRaw = await this._normalizeListedWorktreeRepositoryRoot(updated, ref.object, repositoryRootRaw); + } + const worktreeProject = worktreeProjectFromRepositoryRoot(repositoryRootRaw); if (worktreeProject) { updated = { ...updated, project: worktreeProject }; } @@ -1946,12 +1988,27 @@ export class AgentService extends Disposable implements IAgentService { async disposeSession(session: URI): Promise { this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`); + // Resolve the working directories up front and pass them explicitly: + // the checkpoint and review services need them to locate the + // repositories holding this session's refs, and reading them from + // session state would silently break the moment `deleteSession` below + // is reordered ahead of the data deletion. + const workingDirectories = this._configurationService.getEffectiveWorkingDirectories(session.toString()); const provider = this._findProviderForSession(session); if (provider) { await this._disposeSession(provider, session); this._sessionToProvider.delete(session.toString()); this._clearDownloadProgressInterest(session.toString()); } + // Remove the VS Code per-session data directory (metadata DB + checkpoints) to mirror the SDK-side cleanup + // performed by the provider above. No-op when the directory does not exist. + // + // Runs before the worktree is removed: subscribers of the will-delete + // event drop this session's git refs, and for a worktree-isolated + // session the working directory *is* the worktree, so once it is gone + // the repository can no longer be resolved and the refs would leak + // into the main repository (`refs/agents/*` is shared, not per-worktree). + await this._sessionDataService.deleteSessionData(session, workingDirectories); // Remove any worktree this process created for the session (host-owned; // agents stay unaware). await this._worktree?.removeCreatedWorktree(AgentSession.id(session)); @@ -1963,9 +2020,6 @@ export class AgentService extends Disposable implements IAgentService { // Remove all subagent sessions for this parent this._sideEffects.removeSubagentSessions(session.toString()); this._stateManager.deleteSession(session.toString()); - // Remove the VS Code per-session data directory (metadata DB + checkpoints) to mirror the SDK-side cleanup - // performed by the provider above. No-op when the directory does not exist. - await this._sessionDataService.deleteSessionData(session); } // ---- Protocol methods --------------------------------------------------- diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 055213b020e..d2e745f5afd 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -16,6 +16,7 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { ILogService } from '../../log/common/log.js'; import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { readAgentModelByokIdentifier } from '../common/agentModelByokMeta.js'; import { AgentSession, AgentSignal, IAgent, IAgentToolPendingConfirmationSignal } from '../common/agentService.js'; @@ -108,6 +109,12 @@ export interface IAgentSideEffectsOptions { * excluded — only the parent session URI is passed. */ readonly onTurnComplete: (session: ProtocolURI) => void; + /** + * Called with the text of every user message that is forwarded to an agent, + * so the host can derive session state from what the user wrote (e.g. the + * GitHub issues the message references). + */ + readonly onUserMessage?: (session: ProtocolURI, text: string) => void; } interface IQueuedMessageSender { @@ -184,6 +191,7 @@ export class AgentSideEffects extends Disposable { @IAgentHostChangesetService private readonly _changesets: IAgentHostChangesetService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService, + @IAgentConfigurationService private readonly _agentConfigService: IAgentConfigurationService, ) { super(); this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); @@ -709,10 +717,14 @@ export class AgentSideEffects extends Disposable { if (action.type === ActionType.ChatToolCallStart && agent) { this._toolCallAgents.set(`${sessionKey}:${action.toolCallId}`, agent.id); // Stamp the tool call start for `languageModelToolInvoked` telemetry. - // Only the start action carries the tool name and contributor, so the - // source kind must be captured here rather than on completion. The - // provider comes from the agent that emitted the signal. + // Ready may refine the contributor once the complete tool metadata is + // available, so the tracker updates the source kind below when needed. this._toolCallTracker.toolCallStarted(agent.id, sessionKey, action.toolCallId, action.toolName, action.contributor); + } else if (action.type === ActionType.ChatToolCallReady) { + this._toolCallTracker.toolCallMetadataUpdated(sessionKey, action.toolCallId, action.contributor); + if (action.confirmed) { + this._toolCallTracker.toolCallExecutionStarted(sessionKey, action.toolCallId); + } } const sessionUri = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey; @@ -816,7 +828,15 @@ export class AgentSideEffects extends Disposable { // completion since those have always been fire-and-forget; the // ordering guarantee we care about is checkpoint-then-changeset. if (turnId !== undefined) { - this._checkpointService.captureTurnCheckpoint(URI.parse(sessionUri), turnId).then(() => { + // Resolved here rather than inside the checkpoint service so the + // repositories a checkpoint acts on are always explicit at the + // call site. Note the changeset service below deliberately keeps + // its own resolution: `onTurnComplete` only schedules deferred + // recomputes that are shared with subscription, truncation and + // mid-turn-debounce entry points, so it has no single point at + // which a caller-supplied set would apply. + const workingDirectories = this._agentConfigService.getEffectiveWorkingDirectories(sessionUri)?.map(w => URI.parse(w)); + this._checkpointService.captureTurnCheckpoint(URI.parse(sessionUri), turnId, workingDirectories).then(() => { this._changesets.onTurnComplete(sessionUri, turnId); }, err => { this._logService.warn(`[AgentSideEffects] Turn checkpoint capture failed for ${sessionUri}/${turnId}: ${err instanceof Error ? err.message : String(err)}`); @@ -1169,10 +1189,12 @@ export class AgentSideEffects extends Disposable { // Mark confirmations where a persistent allow rule can suppress the next equivalent prompt. effective = { ...effective, state: { ...effective.state, _meta: { ...toolCall?._meta, ...effective.state._meta, ...toToolCallMeta({ autoApproveRuleResolvable: true }) } } }; } - this._stateManager.dispatchServerAction( - sessionKey, - this._permissionManager.createToolReadyAction(effective, sessionKey, turnId) - ); + const readyAction = this._permissionManager.createToolReadyAction(effective, sessionKey, turnId); + this._toolCallTracker.toolCallMetadataUpdated(sessionKey, readyAction.toolCallId, readyAction.contributor); + if (readyAction.confirmed) { + this._toolCallTracker.toolCallExecutionStarted(sessionKey, readyAction.toolCallId); + } + this._stateManager.dispatchServerAction(sessionKey, readyAction); } handleAction(channel: ProtocolURI, action: StateAction, clientId?: string, clientType = AgentHostClientType.Unknown): void { @@ -1203,6 +1225,7 @@ export class AgentSideEffects extends Disposable { this._logService.info(`[AgentSideEffects] Turn started for session not in state manager: ${channel}, turnId=${action.turnId} - status/summary updates may be dropped unless the session is restored`); } this._titleController.seedTitleFromFirstMessage(sessionChannel, action.message.text, chatChannel); + this._options.onUserMessage?.(sessionChannel, action.message.text); const agent = this._options.getAgent(sessionChannel); if (!agent) { @@ -1236,6 +1259,9 @@ export class AgentSideEffects extends Disposable { throw new Error(`ChatToolCallConfirmed must be handled on an AHP chat channel: ${channel}`); } const toolCallKey = `${channel}:${action.toolCallId}`; + if (action.approved) { + this._toolCallTracker.toolCallExecutionStarted(channel, action.toolCallId); + } const managedApprovalRequired = this._managedApprovalToolCalls.delete(toolCallKey); const agentId = this._toolCallAgents.get(toolCallKey); if (agentId) { diff --git a/src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts b/src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts index 3729e98674b..41d7903a439 100644 --- a/src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts +++ b/src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts @@ -162,10 +162,21 @@ function modelsEqual(a: readonly IByokLmModelInfo[], b: readonly IByokLmModelInf } return a.every((m, i) => { const n = b[i]; - return m.vendor === n.vendor && m.id === n.id && m.name === n.name && m.modelIdentifier === n.modelIdentifier && m.maxContextWindowTokens === n.maxContextWindowTokens && m.supportsVision === n.supportsVision; + return m.vendor === n.vendor + && m.id === n.id + && m.name === n.name + && m.modelIdentifier === n.modelIdentifier + && m.maxContextWindowTokens === n.maxContextWindowTokens + && m.supportsVision === n.supportsVision + && m.defaultReasoningEffort === n.defaultReasoningEffort + && arraysEqual(m.supportedReasoningEfforts, n.supportedReasoningEfforts); }); } +function arraysEqual(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean { + return a === b || (a !== undefined && b !== undefined && a.length === b.length && a.every((value, index) => value === b[index])); +} + /** * No-op {@link IByokLmBridgeRegistry} for agent host entrypoints that do not * support BYOK — e.g. the remote agent host, where no extension host runs diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index 472f46a51ab..05e5babd86f 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -7,7 +7,7 @@ import type { McpSdkServerConfigWithInstance, OnElicitation, Options, Permission import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { INativeEnvironmentService } from '../../../environment/common/environment.js'; @@ -36,12 +36,11 @@ import { SessionClientToolsDiff } from './clientTools/claudeSessionClientToolsMo import { SessionClientCustomizationsDiff } from './customizations/claudeSessionClientCustomizationsModel.js'; import { ClaudeCustomizationWatcher, buildDiscoveredCustomizations, resolveClaudeAgentName } from './customizations/claudeSessionCustomizationDiscovery.js'; import { applyMcpServerEnablement, findMcpChildId, findMcpServerName, getEffectiveMcpServerCustomizations } from '../shared/mcpCustomizationController.js'; -import { scanClaudeDiskCustomizations } from './customizations/scan/claudeAgentSkillScan.js'; import { scanClaudeHooks } from './customizations/scan/claudeHookScan.js'; import { scanClaudeMcpServers } from './customizations/scan/claudeMcpScan.js'; -import { scanClaudeNativePlugins } from './customizations/scan/claudeNativePluginScan.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostStateManager.js'; import { scanClaudeRules } from './customizations/scan/claudeRuleScan.js'; +import { discoverClaudeMultiRootCustomizations } from './customizations/claudeMultiRootCustomizationDiscovery.js'; import { resolvePromptToContentBlocks } from './claudePromptResolver.js'; import type { ClaudeTransport } from './claudeProxyService.js'; import { ClaudeSdkPipeline, IRematerializer, type ISdkResolvedCustomizations } from './claudeSdkPipeline.js'; @@ -97,6 +96,16 @@ function resolveCurrentPermissionMode( return readClaudePermissionMode(configurationService, sessionUri) ?? permissionModeFallback; } +function sameWorkingDirectories(a: readonly URI[] | undefined, b: readonly URI[] | undefined): boolean { + if (!a || !b) { + return a === b; + } + if (a.length !== b.length) { + return false; + } + return a.every((directory, index) => isEqual(directory, b[index])); +} + /** * Per-session coordinator. Owns: * • Per-session identity (sessionId / sessionUri / workspace / @@ -177,7 +186,7 @@ export class ClaudeAgentSession extends Disposable { const primary = this.workingDirectory; return primary ? [primary, ...this._additionalDirectories] : undefined; } - private readonly _customizationWatcher = this._register(new DisposableStore()); + private readonly _customizationWatcher = this._register(new MutableDisposable()); /** Exposed for the materializer's MCP-server build closure. */ get pendingClientToolCalls(): PendingRequestRegistry { return this._pendingClientToolCalls; } @@ -385,18 +394,19 @@ export class ClaudeAgentSession extends Disposable { this.toolDiff = this._register(toolDiff); this._register(this.clientCustomizationsDiff.onDidChange(() => this._onDidCustomizationsChange.fire())); - this._watchCustomizations(this.workspace); + this._watchCustomizations(this.workingDirectories); } - private _watchCustomizations(directory: URI | undefined): void { - this._customizationWatcher.clear(); - const watcher = this._customizationWatcher.add(new ClaudeCustomizationWatcher( - directory, + private _watchCustomizations(directories: readonly URI[] | undefined): void { + const store = new DisposableStore(); + const watcher = store.add(new ClaudeCustomizationWatcher( + directories, this._environmentService.userHome, this._fileService, this._logService, )); - this._customizationWatcher.add(watcher.onDidChange(() => this._onDidCustomizationsChange.fire())); + store.add(watcher.onDidChange(() => this._onDidCustomizationsChange.fire())); + this._customizationWatcher.value = store; } /** @@ -470,14 +480,18 @@ export class ClaudeAgentSession extends Disposable { // roots) takes precedence and also refreshes the additional-directory // tail; the singular `workingDirectory` stays supported for single-root // callers that only resolve the primary. + const previousWorkingDirectories = this.workingDirectories; const resolvedPrimary = ctx.workingDirectories?.[0] ?? ctx.workingDirectory; if (resolvedPrimary && !isEqual(resolvedPrimary, this.workingDirectory)) { this._workingDirectory = resolvedPrimary; - this._watchCustomizations(resolvedPrimary); } if (ctx.workingDirectories && ctx.workingDirectories.length > 0) { this._additionalDirectories = ctx.workingDirectories.slice(1); } + const currentWorkingDirectories = this.workingDirectories; + if (!sameWorkingDirectories(previousWorkingDirectories, currentWorkingDirectories)) { + this._watchCustomizations(currentWorkingDirectories); + } if (!this.workingDirectory) { throw new Error(`Cannot materialize Claude session ${this.sessionId}: workingDirectory is required`); } @@ -1057,12 +1071,11 @@ export class ClaudeAgentSession extends Disposable { async getSessionCustomizations(): Promise { const { synced } = this.clientCustomizationsDiff.model.state.get(); const userHome = this._environmentService.userHome; - const [discovered, rules, mcpServers, hooks, nativePlugins] = await Promise.all([ - scanClaudeDiskCustomizations(this.workingDirectory, userHome, this._fileService), + const [multiRoot, rules, mcpServers, hooks] = await Promise.all([ + discoverClaudeMultiRootCustomizations(this.workingDirectories, userHome, this._fileService, this._logService), scanClaudeRules(this.workingDirectory, userHome, this._fileService), scanClaudeMcpServers(this.workingDirectory, userHome, this._fileService), scanClaudeHooks(this.workingDirectory, userHome, this._fileService), - scanClaudeNativePlugins(this.workingDirectory, userHome, this._fileService, this._logService), ]); // Post-materialize, the live SDK snapshot filters the disk set down to @@ -1082,7 +1095,7 @@ export class ClaudeAgentSession extends Disposable { // `buildDiscoveredCustomizations` also folds in the read-only "Built-in" // surfacing (curated pre-materialize, SDK-derived post-materialize) for // both agents and skills, so the SDK-vs-curated decision lives in one place. - const discoveredCustomizations = buildDiscoveredCustomizations([...discovered, ...rules], mcpServers, hooks, nativePlugins, this.workingDirectory, userHome, sdk); + const discoveredCustomizations = buildDiscoveredCustomizations([...multiRoot.discovered, ...rules], mcpServers, hooks, multiRoot.nativePlugins, multiRoot.workingDirectories, userHome, sdk); // Final projection: the client-pushed tier first, then the discovered // tier, with session MCP enablement applied to both. diff --git a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts index f8fa0250179..cd296d52c9b 100644 --- a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts @@ -13,7 +13,7 @@ import { extractForwardedErrorInfo } from '../shared/forwardedChatError.js'; import { buildTopLevelSubagentReadyAction, emitInnerAssistantSignals, mapSubagentSystemMessage, SUBAGENT_SPAWNING_TOOL_NAMES, tagWithParent } from './claudeSubagentSignals.js'; import type { SubagentRegistry } from './claudeSubagentRegistry.js'; import { stripClientToolNamePrefix, hasClientToolNamePrefix } from './clientTools/claudeClientToolMcpServer.js'; -import { buildClaudeToolMeta, getClaudePastTenseMessage, getClaudeToolDisplayName } from './claudeToolDisplay.js'; +import { buildClaudeToolMeta, getClaudePastTenseMessage, getClaudeToolDisplayName, isClaudeFileEditTool } from './claudeToolDisplay.js'; import { claudeToolDenialCode } from './claudeToolDenial.js'; import { ClaudeToolCallRegistry } from './claudeToolCallRegistry.js'; import { ToolCallConfirmationReason, ToolCallContributorKind, type StringOrMarkdown } from '../../common/state/protocol/state.js'; @@ -47,7 +47,7 @@ import { ToolCallConfirmationReason, ToolCallContributorKind, type StringOrMarkd * lifecycle invariants live behind named methods. */ export class ClaudeMapperState { - private readonly _activeToolBlocks = new Map(); + private readonly _activeToolBlocks = new Map(); /** * Phase 8.5 — cross-message tool-call attribution + input * accumulation + computed start-info, encapsulated as its own @@ -88,12 +88,12 @@ export class ClaudeMapperState { * scopes; the per-message map gets drained on `content_block_stop`, * the cross-message maps survive until the matching `tool_result`. */ - startToolBlock(index: number, toolUseId: string, toolName: string, turnId: string): void { - this._activeToolBlocks.set(index, { toolUseId, toolName }); - this.toolCalls.begin(toolUseId, toolName, turnId); + startToolBlock(index: number, toolUseId: string, toolName: string, turnId: string, isClientTool = false): void { + this._activeToolBlocks.set(index, { toolUseId, toolName, isClientTool }); + this.toolCalls.begin(toolUseId, toolName, turnId, isClientTool); } - getActiveToolBlock(index: number): { toolUseId: string; toolName: string } | undefined { + getActiveToolBlock(index: number): { toolUseId: string; toolName: string; isClientTool: boolean } | undefined { return this._activeToolBlocks.get(index); } @@ -133,9 +133,9 @@ export class ClaudeMapperState { * `undefined` if the `tool_use_id` is unknown (defense-in-depth * against transport drift / replay). */ - lookupToolCall(toolUseId: string): { turnId: string; toolName: string } | undefined { + lookupToolCall(toolUseId: string): { turnId: string; toolName: string; isClientTool: boolean } | undefined { const entry = this.toolCalls.lookup(toolUseId); - return entry ? { turnId: entry.turnId, toolName: entry.toolName } : undefined; + return entry ? { turnId: entry.turnId, toolName: entry.toolName, isClientTool: entry.isClientTool } : undefined; } /** Drain cross-message tracking once a `tool_result` is delivered. */ @@ -183,6 +183,20 @@ export class ClaudeMapperState { } } +function fileEditToolDelta(chat: URI, turnId: string, toolCallId: string, invocationMessage: StringOrMarkdown): AgentSignal { + return { + kind: 'action', + resource: chat, + action: { + type: ActionType.ChatToolCallDelta, + turnId, + toolCallId, + content: '', + invocationMessage, + }, + }; +} + /** * Map one SDK message to zero or more agent signals. * @@ -243,7 +257,7 @@ export function mapSDKMessageToAgentSignals( return mapResult(message, chat, turnId, turnDuration, state, logService, registry); case 'assistant': return tagWithParent( - mapAssistantCanonical(message, chat, turnId, state, message.parent_tool_use_id, registry), + mapAssistantCanonical(message, chat, turnId, state, message.parent_tool_use_id, registry, clientToolOwner), chat, message.parent_tool_use_id, registry, @@ -291,6 +305,7 @@ function mapAssistantCanonical( state: ClaudeMapperState, parentToolUseId: string | null, registry: SubagentRegistry, + clientToolOwner?: (toolName: string) => string | undefined, ): AgentSignal[] { if (parentToolUseId === null) { const top: AgentSignal[] = []; @@ -302,7 +317,7 @@ function mapAssistantCanonical( } return top; } - return emitInnerAssistantSignals(message, chat, turnId, state, parentToolUseId, registry); + return emitInnerAssistantSignals(message, chat, turnId, state, parentToolUseId, registry, clientToolOwner); } /** @@ -350,8 +365,12 @@ function mapUserMessage( .map(c => c.text) .join('\n'); const pastTenseMessage: StringOrMarkdown = info - ? getClaudePastTenseMessage(info.toolName, info.displayName, info.parsedInput, !isError, resultText) - : `${getClaudeToolDisplayName(tracked.toolName)} finished`; + ? info.isClientTool + ? info.displayName + : getClaudePastTenseMessage(info.toolName, info.displayName, info.parsedInput, !isError, resultText) + : tracked.isClientTool + ? tracked.toolName + : `${getClaudeToolDisplayName(tracked.toolName)} finished`; // A denied/cancelled tool surfaces as an `is_error` result whose content // is the deny `message` we returned from `canUseTool`; classify it so the // telemetry reports `userCancelled` rather than a generic error. @@ -570,7 +589,7 @@ function mapStreamEvent( // they don't carry the prefix. const toolName = stripClientToolNamePrefix(block.name); const isClientTool = hasClientToolNamePrefix(block.name); - state.startToolBlock(event.index, block.id, toolName, turnId); + state.startToolBlock(event.index, block.id, toolName, turnId, isClientTool); // Phase 12 — subagent correlation bookkeeping. Either this // tool_use is at the top level and (if Task/Agent) spawns a // new subagent, or it is inner and we record its edge to the @@ -593,7 +612,7 @@ function mapStreamEvent( // state transitions (D6). Subagent meta from Phase 12 is now // produced by `buildClaudeToolMeta` because // `getClaudeToolKind('Task') === 'subagent'`. - const meta = buildClaudeToolMeta(toolName); + const meta = isClientTool ? undefined : buildClaudeToolMeta(toolName); const toolClientId = isClientTool ? clientToolOwner?.(toolName) : undefined; return [{ kind: 'action', @@ -603,7 +622,7 @@ function mapStreamEvent( turnId, toolCallId: block.id, toolName, - displayName: getClaudeToolDisplayName(toolName), + displayName: isClientTool ? toolName : getClaudeToolDisplayName(toolName), ...(toolClientId ? { contributor: { kind: ToolCallContributorKind.Client, clientId: toolClientId } } : {}), ...(meta ? { _meta: meta } : {}), }, @@ -644,6 +663,13 @@ function mapStreamEvent( return []; } state.appendToolBlockInputDelta(event.index, event.delta.partial_json); + if (!tracked.isClientTool && isClaudeFileEditTool(tracked.toolName)) { + const update = state.toolCalls.streamingInputUpdate(tracked.toolUseId); + if (!update) { + return []; + } + return [fileEditToolDelta(chat, turnId, tracked.toolUseId, update.invocationMessage)]; + } return [{ kind: 'action', resource: chat, @@ -660,6 +686,9 @@ function mapStreamEvent( case 'content_block_stop': { const tracked = state.getActiveToolBlock(event.index); + const finalStreamingUpdate = tracked && !tracked.isClientTool && isClaudeFileEditTool(tracked.toolName) + ? state.toolCalls.streamingInputUpdate(tracked.toolUseId, true) + : undefined; state.finalizeToolBlock(event.index); state.endToolBlock(event.index); if (!tracked) { @@ -670,8 +699,12 @@ function mapStreamEvent( if (!info) { return []; } - const meta = buildClaudeToolMeta(tracked.toolName); - return [{ + const meta = tracked.isClientTool ? undefined : buildClaudeToolMeta(tracked.toolName); + const signals: AgentSignal[] = []; + if (finalStreamingUpdate) { + signals.push(fileEditToolDelta(chat, turnId, tracked.toolUseId, finalStreamingUpdate.invocationMessage)); + } + signals.push({ kind: 'action', resource: chat, action: { @@ -683,7 +716,8 @@ function mapStreamEvent( confirmed: ToolCallConfirmationReason.NotNeeded, ...(meta ? { _meta: meta } : {}), }, - }]; + }); + return signals; } case 'message_delta': diff --git a/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts b/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts index c82f9d8d543..5522564e786 100644 --- a/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts +++ b/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts @@ -24,8 +24,9 @@ import { } from '../../common/state/protocol/state.js'; import { buildSubagentSessionUri } from '../../common/state/sessionState.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; +import { formatGenericToolInput } from '../../common/streamingToolCallDisplay.js'; import { buildClaudeToolMeta, getClaudeInvocationMessage, getClaudePastTenseMessage, getClaudeToolDisplayName, getClaudeToolInputString } from './claudeToolDisplay.js'; -import { stripClientToolNamePrefix } from './clientTools/claudeClientToolMcpServer.js'; +import { hasClientToolNamePrefix, stripClientToolNamePrefix } from './clientTools/claudeClientToolMcpServer.js'; /** * Phase 13 — replay mapper. Reduces a flat `SessionMessage[]` (the SDK's @@ -266,7 +267,7 @@ class ReplayBuilder { * pattern but simpler (replay has the full input synchronously on * the `tool_use` block). */ - private readonly _toolUses = new Map | undefined }>(); + private readonly _toolUses = new Map | undefined; readonly isClientTool: boolean }>(); /** Turns opened from a leading assistant envelope because the prompt was missing. Reported once by {@link finish}. */ private _recoveredPromptlessTurns = 0; @@ -376,7 +377,7 @@ class ReplayBuilder { // the workbench-registered tool by its unprefixed name (matches the // live stream mapper). Without this, replayed client-tool calls // fall back to the generic "Run MCP tool" rendering. - this._openToolUse(block.id, stripClientToolNamePrefix(block.name), block.input); + this._openToolUse(block.id, stripClientToolNamePrefix(block.name), block.input, hasClientToolNamePrefix(block.name)); } // Other block types (server_tool_use, etc.) are dropped silently per M7. } @@ -385,21 +386,23 @@ class ReplayBuilder { } } - private _openToolUse(toolUseId: string, toolName: string, input: unknown): void { + private _openToolUse(toolUseId: string, toolName: string, input: unknown, isClientTool: boolean): void { if (this._active === undefined) { return; } - const displayName = getClaudeToolDisplayName(toolName); + const displayName = isClientTool ? toolName : getClaudeToolDisplayName(toolName); const parsedInput = input !== null && typeof input === 'object' ? input as Record : undefined; - const meta = buildClaudeToolMeta(toolName); + const meta = isClientTool ? undefined : buildClaudeToolMeta(toolName); // Build a placeholder Cancelled state by default; replaced with Completed when the tool_result lands. const placeholder: ToolCallCancelledState = { status: ToolCallStatus.Cancelled, toolCallId: toolUseId, toolName, displayName, - invocationMessage: getClaudeInvocationMessage(toolName, displayName, parsedInput), - toolInput: parsedInput !== undefined ? getClaudeToolInputString(toolName, parsedInput) : (typeof input === 'string' ? input : input !== undefined ? safeStringify(input) : undefined), + invocationMessage: isClientTool ? displayName : getClaudeInvocationMessage(toolName, displayName, parsedInput), + toolInput: parsedInput !== undefined + ? isClientTool ? formatGenericToolInput(parsedInput) : getClaudeToolInputString(toolName, parsedInput) + : (typeof input === 'string' ? input : input !== undefined ? safeStringify(input) : undefined), reason: ToolCallCancellationReason.Skipped, ...(meta ? { _meta: meta } : {}), }; @@ -410,7 +413,7 @@ class ReplayBuilder { this._active.responseParts.push(part); this._active.toolCallParts.set(toolUseId, part); this._active.pendingToolUseIds.add(toolUseId); - this._toolUses.set(toolUseId, { turnId: this._active.id, parsedInput }); + this._toolUses.set(toolUseId, { turnId: this._active.id, parsedInput, isClientTool }); } private _attachToolResult(block: UserToolResultBlock): string | undefined { @@ -449,7 +452,9 @@ class ReplayBuilder { toolInput: previousState.status === ToolCallStatus.Streaming ? undefined : previousState.toolInput, confirmed: ToolCallConfirmationReason.NotNeeded, success: !isError, - pastTenseMessage: getClaudePastTenseMessage(previousState.toolName, previousState.displayName, entry.parsedInput, !isError, resultText), + pastTenseMessage: entry.isClientTool + ? previousState.displayName + : getClaudePastTenseMessage(previousState.toolName, previousState.displayName, entry.parsedInput, !isError, resultText), content: content.length > 0 ? content : undefined, ...(previousState._meta ? { _meta: previousState._meta } : {}), }; diff --git a/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts b/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts index a10365af689..a90f19aa930 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts @@ -9,11 +9,11 @@ import type { Mutable } from '../../../../base/common/types.js'; import { toToolCallMeta, type IToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import type { AgentSignal, IAgentSubagentStartedSignal } from '../../common/agentService.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, ToolCallConfirmationReason } from '../../common/state/sessionState.js'; +import { ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind } from '../../common/state/sessionState.js'; import type { ClaudeMapperState } from './claudeMapSessionEvents.js'; import { SUBAGENT_TOOL_NAMES, type SubagentRegistry } from './claudeSubagentRegistry.js'; import { buildClaudeToolCallMeta, buildClaudeToolMeta, getClaudeInvocationMessage, getClaudeToolDisplayName, getClaudeToolInputString } from './claudeToolDisplay.js'; -import { stripClientToolNamePrefix } from './clientTools/claudeClientToolMcpServer.js'; +import { hasClientToolNamePrefix, stripClientToolNamePrefix } from './clientTools/claudeClientToolMcpServer.js'; /** * Phase 12 — SDK tool names that spawn subagent sessions. Re-exported @@ -215,6 +215,7 @@ export function emitInnerAssistantSignals( state: ClaudeMapperState, parentToolUseId: string, registry: SubagentRegistry, + clientToolOwner?: (toolName: string) => string | undefined, ): AgentSignal[] { const messageId = message.message.id; const signals: AgentSignal[] = []; @@ -257,7 +258,9 @@ export function emitInnerAssistantSignals( // calls render with their real name (matches the top-level stream // mapper). SDK-owned tools and Task/Agent passes through unchanged. const toolName = stripClientToolNamePrefix(block.name); - state.startToolBlock(index, block.id, toolName, turnId); + const isClientTool = hasClientToolNamePrefix(block.name); + const clientId = isClientTool ? clientToolOwner?.(toolName) : undefined; + state.startToolBlock(index, block.id, toolName, turnId, isClientTool); // Inner tool input arrives pre-parsed on the synthesized // `assistant` message (not via `input_json_delta` chunks), so // seed the registry directly. Without this the live @@ -266,9 +269,10 @@ export function emitInnerAssistantSignals( // always computes rich text) drifts from live — violating D6. state.toolCalls.seedParsedInput(block.id, block.input); registry.noteInnerTool(block.id, parentToolUseId); - const displayName = getClaudeToolDisplayName(toolName); - const meta = buildClaudeToolMeta(toolName); - const toolInputStr = getClaudeToolInputString(toolName, block.input); + const displayName = isClientTool ? toolName : getClaudeToolDisplayName(toolName); + const meta = isClientTool ? undefined : buildClaudeToolMeta(toolName); + const info = state.toolCalls.lookup(block.id)?.info; + const toolInputStr = info?.toolInput ?? getClaudeToolInputString(toolName, block.input); signals.push({ kind: 'action', resource: chat, @@ -278,6 +282,7 @@ export function emitInnerAssistantSignals( toolCallId: block.id, toolName, displayName, + ...(clientId ? { contributor: { kind: ToolCallContributorKind.Client, clientId } } : {}), ...(meta ? { _meta: meta } : {}), }, }); @@ -288,7 +293,7 @@ export function emitInnerAssistantSignals( type: ActionType.ChatToolCallReady, turnId, toolCallId: block.id, - invocationMessage: getClaudeInvocationMessage(toolName, displayName, block.input), + invocationMessage: isClientTool ? displayName : getClaudeInvocationMessage(toolName, displayName, block.input), ...(toolInputStr !== undefined ? { toolInput: toolInputStr } : {}), confirmed: ToolCallConfirmationReason.NotNeeded, }, diff --git a/src/vs/platform/agentHost/node/claude/claudeToolCallRegistry.ts b/src/vs/platform/agentHost/node/claude/claudeToolCallRegistry.ts index 83abf44a0f3..0d5579a5218 100644 --- a/src/vs/platform/agentHost/node/claude/claudeToolCallRegistry.ts +++ b/src/vs/platform/agentHost/node/claude/claudeToolCallRegistry.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import type { ILogService } from '../../../log/common/log.js'; +import { parsePartialToolInput } from '../../common/partialToolInput.js'; +import { formatGenericToolInput, STREAMING_TOOL_DISPLAY_INTERVAL_MS, streamingToolDisplayText } from '../../common/streamingToolCallDisplay.js'; import type { StringOrMarkdown } from '../../common/state/protocol/state.js'; -import { getClaudeInvocationMessage, getClaudeToolDisplayName, getClaudeToolInputString } from './claudeToolDisplay.js'; +import { getClaudeInvocationMessage, getClaudeStreamingInvocationMessage, getClaudeToolDisplayName, getClaudeToolInputString } from './claudeToolDisplay.js'; /** * Phase 8.5 — per-tool-call info computed at `content_block_stop` and @@ -21,15 +23,24 @@ export interface IClaudeToolStartInfo { readonly parsedInput: Record | undefined; readonly invocationMessage: StringOrMarkdown; readonly toolInput: string | undefined; + readonly isClientTool: boolean; } interface IRegistryEntry { readonly toolName: string; readonly turnId: string; + readonly isClientTool: boolean; inputBuffer: string; + displayedInputLength: number; + displayedAt: number | undefined; + displayedMessage: string | undefined; info: IClaudeToolStartInfo | undefined; } +export interface IClaudeStreamingToolInputUpdate { + readonly invocationMessage: StringOrMarkdown; +} + /** * Phase 8.5 — per-session, cross-message tool-call tracking for the * live mapper. Owns: @@ -68,11 +79,15 @@ export class ClaudeToolCallRegistry { * for a `tool_use` block. Allocates the delta buffer; the * computed info bag is filled in by {@link finalize}. */ - begin(toolUseId: string, toolName: string, turnId: string): void { + begin(toolUseId: string, toolName: string, turnId: string, isClientTool = false): void { this._entries.set(toolUseId, { toolName, turnId, + isClientTool, inputBuffer: '', + displayedInputLength: 0, + displayedAt: undefined, + displayedMessage: undefined, info: undefined, }); } @@ -90,6 +105,37 @@ export class ClaudeToolCallRegistry { entry.inputBuffer += partialJson; } + /** + * Renders the next streaming display message for a file-edit tool, or + * `undefined` when nothing new should be shown. Throttled on elapsed time + * only: the SDK streams argument text token by token, so a size-based rule + * would make updates rarer as the edit grows. `force` bypasses the interval + * for the final flush at `content_block_stop`. Identical messages are + * suppressed so a steady tick does not re-send an unchanged row. + */ + streamingInputUpdate(toolUseId: string, force = false): IClaudeStreamingToolInputUpdate | undefined { + const entry = this._entries.get(toolUseId); + if (!entry || entry.displayedInputLength === entry.inputBuffer.length) { + return undefined; + } + const now = performance.now(); + if (!force && entry.displayedAt !== undefined && now - entry.displayedAt < STREAMING_TOOL_DISPLAY_INTERVAL_MS) { + return undefined; + } + const invocationMessage = getClaudeStreamingInvocationMessage(entry.toolName, parsePartialToolInput(entry.inputBuffer)); + if (!invocationMessage) { + return undefined; + } + entry.displayedInputLength = entry.inputBuffer.length; + entry.displayedAt = now; + const message = streamingToolDisplayText(invocationMessage); + if (message === entry.displayedMessage) { + return undefined; + } + entry.displayedMessage = message; + return { invocationMessage }; + } + /** * Parse the accumulated buffer and stash the computed * {@link IClaudeToolStartInfo}. Called from `content_block_stop`. @@ -143,13 +189,14 @@ export class ClaudeToolCallRegistry { } private _writeInfo(entry: IRegistryEntry, parsedInput: Record | undefined, rawFallback?: string): void { - const displayName = getClaudeToolDisplayName(entry.toolName); + const displayName = entry.isClientTool ? entry.toolName : getClaudeToolDisplayName(entry.toolName); entry.info = { toolName: entry.toolName, displayName, parsedInput, - invocationMessage: getClaudeInvocationMessage(entry.toolName, displayName, parsedInput), - toolInput: getClaudeToolInputString(entry.toolName, parsedInput) ?? rawFallback, + invocationMessage: entry.isClientTool ? displayName : getClaudeInvocationMessage(entry.toolName, displayName, parsedInput), + toolInput: entry.isClientTool ? formatGenericToolInput(parsedInput, rawFallback) : getClaudeToolInputString(entry.toolName, parsedInput) ?? rawFallback, + isClientTool: entry.isClientTool, }; } @@ -159,12 +206,12 @@ export class ClaudeToolCallRegistry { * drift / replay). The `info` field may be `undefined` if the * tool block never reached `content_block_stop`. */ - lookup(toolUseId: string): { readonly turnId: string; readonly toolName: string; readonly info: IClaudeToolStartInfo | undefined } | undefined { + lookup(toolUseId: string): { readonly turnId: string; readonly toolName: string; readonly isClientTool: boolean; readonly info: IClaudeToolStartInfo | undefined } | undefined { const entry = this._entries.get(toolUseId); if (!entry) { return undefined; } - return { turnId: entry.turnId, toolName: entry.toolName, info: entry.info }; + return { turnId: entry.turnId, toolName: entry.toolName, isClientTool: entry.isClientTool, info: entry.info }; } /** diff --git a/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts b/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts index 2a913fe5611..5fb53566b50 100644 --- a/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts +++ b/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts @@ -8,6 +8,7 @@ import { appendEscapedMarkdownInlineCode, escapeMarkdownLinkLabel } from '../../ import { basename } from '../../../../base/common/resources.js'; import { truncate } from '../../../../base/common/strings.js'; import { URI } from '../../../../base/common/uri.js'; +import { getStreamingCreateMessage, getStreamingEditMessage, getStreamingReplaceMessage, streamingToolTextLineCount } from '../../common/streamingToolCallDisplay.js'; import { toToolCallMeta, type IToolCallMeta, type ToolKind } from '../../common/meta/agentToolCallMeta.js'; import type { StringOrMarkdown } from '../../common/state/protocol/state.js'; import { getServerToolDisplay } from '../shared/serverToolGroups.js'; @@ -470,6 +471,42 @@ export function getClaudeInvocationMessage( } } +export function getClaudeStreamingInvocationMessage(toolName: string, input: Record | undefined): StringOrMarkdown | undefined { + switch (toolName) { + case 'Write': + return getStreamingCreateMessage(input?.['file_path'], streamingToolTextLineCount(input?.['content'])); + case 'Edit': + return getStreamingReplaceMessage( + input?.['file_path'], + streamingToolTextLineCount(input?.['old_string']), + streamingToolTextLineCount(input?.['new_string']), + ); + case 'MultiEdit': { + const edits = Array.isArray(input?.['edits']) ? input['edits'] : []; + let oldLineCount: number | undefined; + let newLineCount: number | undefined; + for (const edit of edits) { + if (!edit || typeof edit !== 'object' || Array.isArray(edit)) { + continue; + } + const oldLines = streamingToolTextLineCount((edit as Record)['old_string']); + const newLines = streamingToolTextLineCount((edit as Record)['new_string']); + if (oldLines !== undefined) { + oldLineCount = (oldLineCount ?? 0) + oldLines; + } + if (newLines !== undefined) { + newLineCount = (newLineCount ?? 0) + newLines; + } + } + return getStreamingReplaceMessage(input?.['file_path'], oldLineCount, newLineCount); + } + case 'NotebookEdit': + return getStreamingEditMessage(input?.['notebook_path'], streamingToolTextLineCount(input?.['new_source'])); + default: + return undefined; + } +} + /** * Phase 8.5 — success-aware rich past-tense message. Mirror of * [`copilotToolDisplay.getPastTenseMessage`](../copilot/copilotToolDisplay.ts#L572). diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeCustomizationPolicy.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeCustomizationPolicy.ts new file mode 100644 index 00000000000..2f4e3b1bf96 --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeCustomizationPolicy.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqualOrParent } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; + +export function selectFirstClaudeCustomizationByKey(groups: readonly (readonly T[])[], keyOf: (item: T) => string): readonly T[] { + const selected = new Map(); + for (const group of groups) { + for (const item of group) { + const key = keyOf(item); + if (!selected.has(key)) { + selected.set(key, item); + } + } + } + return [...selected.values()]; +} + +export function selectEnabledClaudePluginIds(groups: readonly ReadonlyMap[]): readonly string[] { + const selected = new Map(); + for (const group of groups) { + for (const [id, enabled] of group) { + if (!selected.has(id)) { + selected.set(id, enabled); + } + } + } + return [...selected].filter(([, enabled]) => enabled).map(([id]) => id); +} + +export function findMostSpecificClaudeWorkspaceRoot(resource: URI, workingDirectories: readonly URI[]): URI | undefined { + let result: URI | undefined; + for (const directory of workingDirectories) { + if (resource.scheme === directory.scheme && isEqualOrParent(resource, directory) && (!result || directory.path.length > result.path.length)) { + result = directory; + } + } + return result; +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeMultiRootCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeMultiRootCustomizationDiscovery.ts new file mode 100644 index 00000000000..8210cd1124a --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeMultiRootCustomizationDiscovery.ts @@ -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 { ResourceSet } from '../../../../../base/common/map.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IFileService } from '../../../../files/common/files.js'; +import { ILogService } from '../../../../log/common/log.js'; +import { CustomizationType } from '../../../common/state/protocol/channels-session/state.js'; +import type { IParsedAgent, IParsedSkill } from '../../../../agentPlugins/common/pluginParsers.js'; +import { scanClaudeCustomizationScope, scanClaudeDiskCustomizations } from './scan/claudeAgentSkillScan.js'; +import { scanClaudeNativePlugins, scanClaudeNativePluginsForRoots, type IResolvedNativePlugin } from './scan/claudeNativePluginScan.js'; +import { selectFirstClaudeCustomizationByKey } from './claudeCustomizationPolicy.js'; + +export interface IClaudeMultiRootCustomizations { + readonly workingDirectories: readonly URI[]; + readonly discovered: readonly (IParsedAgent | IParsedSkill)[]; + readonly nativePlugins: readonly IResolvedNativePlugin[]; +} + +export function distinctClaudeWorkingDirectories(workingDirectories: readonly URI[] | undefined): readonly URI[] { + const seen = new ResourceSet(); + const result: URI[] = []; + for (const directory of workingDirectories ?? []) { + if (!seen.has(directory)) { + seen.add(directory); + result.push(directory); + } + } + return result; +} + +function isParsedAgent(item: IParsedAgent | IParsedSkill): item is IParsedAgent { + return item.customization.type === CustomizationType.Agent; +} + +function isParsedSkill(item: IParsedAgent | IParsedSkill): item is IParsedSkill { + return item.customization.type === CustomizationType.Skill; +} + +export async function discoverClaudeMultiRootCustomizations( + workingDirectories: readonly URI[] | undefined, + userHome: URI, + fileService: IFileService, + logService: ILogService, +): Promise { + const roots = distinctClaudeWorkingDirectories(workingDirectories); + if (roots.length <= 1) { + const [discovered, nativePlugins] = await Promise.all([ + scanClaudeDiskCustomizations(roots[0], userHome, fileService), + scanClaudeNativePlugins(roots[0], userHome, fileService, logService), + ]); + return { workingDirectories: roots, discovered, nativePlugins }; + } + const [scopes, nativePlugins] = await Promise.all([ + Promise.all([ + ...roots.map((root, index) => scanClaudeCustomizationScope(root, fileService, index === 0)), + scanClaudeCustomizationScope(userHome, fileService), + ]), + scanClaudeNativePluginsForRoots(roots, userHome, fileService, logService), + ]); + const discovered = [ + ...selectFirstClaudeCustomizationByKey(scopes.map(items => items.filter(isParsedAgent)), item => item.name), + ...selectFirstClaudeCustomizationByKey(scopes.map(items => items.filter(isParsedSkill)), item => item.name), + ]; + return { + workingDirectories: roots, + discovered, + nativePlugins, + }; +} diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts index 53e92dc5ff2..061026de091 100644 --- a/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts @@ -18,6 +18,8 @@ import { deriveMcpState } from './scan/claudeMcpScan.js'; import { claudeMemoryFiles } from './scan/claudeRuleScan.js'; import type { IResolvedNativePlugin } from './scan/claudeNativePluginScan.js'; import { CLAUDE_BUILTIN_AGENTS, buildClaudeBuiltinSkillsContainer, buildSdkBuiltinSkillsContainer } from './claudeBuiltinCommands.js'; +import { distinctClaudeWorkingDirectories } from './claudeMultiRootCustomizationDiscovery.js'; +import { findMostSpecificClaudeWorkspaceRoot } from './claudeCustomizationPolicy.js'; /** * The Claude SDK's built-in default agent. Hidden from the picker: @@ -87,24 +89,27 @@ function makePlugin(plugin: IResolvedNativePlugin): PluginCustomization { } /** - * The scope a discovered customization belongs to, derived from which - * `.claude/` tree contains its source file. + * A URI-backed scope bucket. The base URI distinguishes workspace A, + * workspace B, and user scope without a separate scope enum. */ -const enum ClaudeCustomizationScope { - Workspace = 'workspace', - User = 'user', +interface ICustomizationBucket { + readonly base: URI; + readonly agents: AgentCustomization[]; + readonly skills: SkillCustomization[]; + readonly rules: RuleCustomization[]; + readonly hooks: HookCustomization[]; } -/** - * Attributes a discovered file to the scope whose `.claude/` directory - * contains it. SDK-only (`claude-internal:`) and any out-of-tree URIs fall - * back to the user scope. Drives per-scope grouping so the workbench can - * label containers "Workspace" vs "User". - */ -function scopeOf(uri: URI, workingDirectory: URI | undefined): ClaudeCustomizationScope { - return workingDirectory && uri.scheme === workingDirectory.scheme && isEqualOrParent(uri, workingDirectory) - ? ClaudeCustomizationScope.Workspace - : ClaudeCustomizationScope.User; +function createBucket(base: URI): ICustomizationBucket { + return { base, agents: [], skills: [], rules: [], hooks: [] }; +} + +function findCustomizationBucket(uri: URI, workspaceBuckets: readonly ICustomizationBucket[], userBucket: ICustomizationBucket): ICustomizationBucket { + const root = findMostSpecificClaudeWorkspaceRoot(uri, workspaceBuckets.map(bucket => bucket.base)); + if (workspaceBuckets.length > 1 && uri.scheme === userBucket.base.scheme && isEqualOrParent(uri, userBucket.base) && (!root || userBucket.base.path.length > root.path.length)) { + return userBucket; + } + return workspaceBuckets.find(bucket => bucket.base === root) ?? userBucket; } /** @@ -122,15 +127,14 @@ export function mapDiscoveredCustomizations( mcpServers: readonly McpServerCustomization[], hooks: readonly HookCustomization[], nativePlugins: readonly IResolvedNativePlugin[], - workingDirectory: URI | undefined, + workingDirectories: readonly URI[] | URI | undefined, userHome: URI, ): readonly Customization[] { - const buckets = new Map([ - [ClaudeCustomizationScope.Workspace, { agents: [], skills: [], rules: [], hooks: [] }], - [ClaudeCustomizationScope.User, { agents: [], skills: [], rules: [], hooks: [] }], - ]); + const roots = distinctClaudeWorkingDirectories(Array.isArray(workingDirectories) ? workingDirectories : workingDirectories ? [workingDirectories] : []); + const workspaceBuckets = roots.map(createBucket); + const userBucket = createBucket(userHome); for (const d of discovered) { - const bucket = buckets.get(scopeOf(d.uri, workingDirectory))!; + const bucket = findCustomizationBucket(d.uri, workspaceBuckets, userBucket); if (d.customization.type === CustomizationType.Agent) { bucket.agents.push(d.customization); } else if (d.customization.type === CustomizationType.Skill) { @@ -143,32 +147,22 @@ export function mapDiscoveredCustomizations( // carry no `IParsed*` wrapper, so attribute them to scope via their source // settings-file uri. for (const hook of hooks) { - buckets.get(scopeOf(URI.parse(hook.uri), workingDirectory))!.hooks.push(hook); + findCustomizationBucket(URI.parse(hook.uri), workspaceBuckets, userBucket).hooks.push(hook); } const result: Customization[] = []; - // Workspace containers first (precedence), then user. `base` is the scope - // root the container `.claude/` uri is built from. - const orderedScopes: readonly (readonly [ClaudeCustomizationScope, URI | undefined])[] = [ - [ClaudeCustomizationScope.Workspace, workingDirectory], - [ClaudeCustomizationScope.User, userHome], - ]; - for (const [scope, base] of orderedScopes) { - if (!base) { - continue; - } - const bucket = buckets.get(scope)!; + for (const bucket of [...workspaceBuckets, userBucket]) { if (bucket.agents.length > 0) { - result.push(makeDirectory(base, 'agents', CustomizationType.Agent, bucket.agents)); + result.push(makeDirectory(bucket.base, 'agents', CustomizationType.Agent, bucket.agents)); } if (bucket.skills.length > 0) { - result.push(makeDirectory(base, 'skills', CustomizationType.Skill, bucket.skills)); + result.push(makeDirectory(bucket.base, 'skills', CustomizationType.Skill, bucket.skills)); } if (bucket.rules.length > 0) { - result.push(makeDirectory(base, 'rules', CustomizationType.Rule, bucket.rules)); + result.push(makeDirectory(bucket.base, 'rules', CustomizationType.Rule, bucket.rules)); } if (bucket.hooks.length > 0) { - result.push(makeDirectory(base, 'hooks', CustomizationType.Hook, bucket.hooks)); + result.push(makeDirectory(bucket.base, 'hooks', CustomizationType.Hook, bucket.hooks)); } } @@ -278,7 +272,7 @@ export function buildDiscoveredCustomizations( mcpServers: readonly McpServerCustomization[], hooks: readonly HookCustomization[], nativePlugins: readonly IResolvedNativePlugin[], - workingDirectory: URI | undefined, + workingDirectories: readonly URI[] | URI | undefined, userHome: URI, sdk: ISdkResolvedCustomizations | undefined, ): readonly Customization[] { @@ -350,7 +344,7 @@ export function buildDiscoveredCustomizations( const builtinAgents = CLAUDE_BUILTIN_AGENTS .filter(a => a.name !== CLAUDE_SDK_DEFAULT_AGENT_NAME && !diskAgentNames.has(a.name)) .map(a => toParsedAgent({ uri: nonEditableUri('agent', a.name), name: a.name, description: a.description() })); - return withBuiltinSkills(mapDiscoveredCustomizations([...discovered, ...builtinAgents], mcpServers, hooks, nativePlugins, workingDirectory, userHome)); + return withBuiltinSkills(mapDiscoveredCustomizations([...discovered, ...builtinAgents], mcpServers, hooks, nativePlugins, workingDirectories, userHome)); } const agentNames = new Set(sdk.agents.map(a => a.name)); @@ -430,7 +424,7 @@ export function buildDiscoveredCustomizations( // Native plugins were matched to the live SDK set at the top of this // function (`visiblePlugins`); surface them as top-level containers. - return withBuiltinSkills(mapDiscoveredCustomizations(entries, servers, hooks, visiblePlugins, workingDirectory, userHome)); + return withBuiltinSkills(mapDiscoveredCustomizations(entries, servers, hooks, visiblePlugins, workingDirectories, userHome)); } /** @@ -479,7 +473,7 @@ export class ClaudeCustomizationWatcher extends Disposable { readonly onDidChange: Event; constructor( - workingDirectory: URI | undefined, + workingDirectories: readonly URI[] | URI | undefined, userHome: URI, fileService: IFileService, logService: ILogService, @@ -487,9 +481,16 @@ export class ClaudeCustomizationWatcher extends Disposable { ) { super(); + const roots = distinctClaudeWorkingDirectories(Array.isArray(workingDirectories) ? workingDirectories : workingDirectories ? [workingDirectories] : []); // URIs whose subtree (or exact file, for `.mcp.json`) signals a re-scan. const triggers: URI[] = []; + const watched = new Set(); const watch = (uri: URI, recursive: boolean) => { + const key = `${recursive}:${uri.toString()}`; + if (watched.has(key)) { + return; + } + watched.add(key); try { this._register(fileService.watch(uri, { recursive, excludes: [] })); } catch (err) { @@ -506,12 +507,23 @@ export class ClaudeCustomizationWatcher extends Disposable { } }; - if (workingDirectory) { - const projectClaude = URI.joinPath(workingDirectory, '.claude'); + const primary = roots[0]; + if (primary) { + const projectClaude = URI.joinPath(primary, '.claude'); watch(projectClaude, true); addClaudeTriggers(projectClaude); - watch(workingDirectory, false); - triggers.push(URI.joinPath(workingDirectory, '.mcp.json')); + watch(primary, false); + triggers.push(URI.joinPath(primary, '.mcp.json')); + } + for (const additional of roots.slice(1)) { + const projectClaude = URI.joinPath(additional, '.claude'); + watch(projectClaude, true); + triggers.push( + URI.joinPath(projectClaude, 'agents'), + URI.joinPath(projectClaude, 'skills'), + URI.joinPath(projectClaude, 'settings.json'), + URI.joinPath(projectClaude, 'settings.local.json'), + ); } const userClaude = URI.joinPath(userHome, '.claude'); watch(userClaude, true); @@ -521,7 +533,7 @@ export class ClaudeCustomizationWatcher extends Disposable { // canonical list so the watcher never drifts from what it actually // reads. Entries already under a recursively-watched `.claude` root // (e.g. `.claude/CLAUDE.md`) are harmless duplicate triggers. - triggers.push(...claudeMemoryFiles(workingDirectory, userHome)); + triggers.push(...claudeMemoryFiles(primary, userHome)); // Collapse the raw file-change stream into a single debounced signal. // The `DisposableStore` argument is required because `onDidChange` is a diff --git a/src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts index ffa9c863836..bb748df4172 100644 --- a/src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts +++ b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts @@ -7,6 +7,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { dirname } from '../../../../../../base/common/resources.js'; import { IFileService } from '../../../../../files/common/files.js'; import { detectPluginFormat, readAgentComponents, readSkills, toParsedAgent, toParsedSkill, type INamedPluginResource, type IParsedAgent, type IParsedSkill } from '../../../../../agentPlugins/common/pluginParsers.js'; +import { CustomizationType } from '../../../../common/state/protocol/channels-session/state.js'; /** * The `.claude/` directories one scope (project or user) contributes @@ -54,6 +55,26 @@ async function excludeNativePluginSkills(skills: readonly INamedPluginResource[] return skills.filter((_, i) => !isPluginDir[i]); } +export async function scanClaudeCustomizationScope( + scope: URI, + fileService: IFileService, + includeCommands: boolean = true, +): Promise { + const { agents: agentsDir, skills: skillsDir, commands: commandsDir } = scopeRoots(scope); + const [agentResources, skillResources, commandResources] = await Promise.all([ + readAgentComponents([agentsDir], fileService), + readSkills(skillsDir, [skillsDir], fileService), + includeCommands ? readAgentComponents([commandsDir], fileService) : [], + ]); + const agents = new Map(); + const skills = new Map(); + collectByName(agents, agentResources.map(toParsedAgent)); + const standaloneSkills = await excludeNativePluginSkills(skillResources, fileService); + collectByName(skills, standaloneSkills.map(toParsedSkill)); + collectByName(skills, commandResources.map(toParsedSkill)); + return [...agents.values(), ...skills.values()]; +} + /** * Scans a Claude session's `.claude/{agents,skills,commands}` directories * (project + user scope) and returns the discovered customizations with @@ -84,21 +105,9 @@ export async function scanClaudeDiskCustomizations( const skills = new Map(); for (const scope of scopes) { - const { agents: agentsDir, skills: skillsDir, commands: commandsDir } = scopeRoots(scope); - const [agentRes, skillRes, commandRes] = await Promise.all([ - readAgentComponents([agentsDir], fileService), - // pluginRoot = the skills dir itself, so the readSkills fallback - // targets `/SKILL.md` (a legit single-skill dir), never - // an unrelated `/SKILL.md`. - readSkills(skillsDir, [skillsDir], fileService), - readAgentComponents([commandsDir], fileService), - ]); - collectByName(agents, agentRes.map(toParsedAgent)); - // Skills before commands so a same-named skill wins (spec section 3). - // Drop `@skills-dir` plugin dirs first — they surface as plugins (PB-8). - const standaloneSkills = await excludeNativePluginSkills(skillRes, fileService); - collectByName(skills, standaloneSkills.map(toParsedSkill)); - collectByName(skills, commandRes.map(toParsedSkill)); + const discovered = await scanClaudeCustomizationScope(scope, fileService); + collectByName(agents, discovered.filter((item): item is IParsedAgent => item.customization.type === CustomizationType.Agent)); + collectByName(skills, discovered.filter((item): item is IParsedSkill => item.customization.type === CustomizationType.Skill)); } return [...agents.values(), ...skills.values()]; diff --git a/src/vs/platform/agentHost/node/claude/customizations/scan/claudeNativePluginScan.ts b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeNativePluginScan.ts index 6ca52c8c1d8..70d0608192a 100644 --- a/src/vs/platform/agentHost/node/claude/customizations/scan/claudeNativePluginScan.ts +++ b/src/vs/platform/agentHost/node/claude/customizations/scan/claudeNativePluginScan.ts @@ -8,6 +8,7 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; import { IFileService } from '../../../../../files/common/files.js'; import { ILogService } from '../../../../../log/common/log.js'; import { detectPluginFormat, parsePlugin, readJsonFile, type IParsedPlugin } from '../../../../../agentPlugins/common/pluginParsers.js'; +import { findMostSpecificClaudeWorkspaceRoot, selectEnabledClaudePluginIds } from '../claudeCustomizationPolicy.js'; /** * A Claude-native plugin enabled via `enabledPlugins` and resolved to its @@ -46,6 +47,22 @@ function claudeSettingsFilesByPrecedence(workingDirectory: URI | undefined, user return files; } +async function readEnabledPlugins(uri: URI, fileService: IFileService): Promise> { + const result = new Map(); + const raw = await readJsonFile(uri, fileService); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return result; + } + const enabledPlugins = (raw as Record)['enabledPlugins']; + if (!enabledPlugins || typeof enabledPlugins !== 'object' || Array.isArray(enabledPlugins)) { + return result; + } + for (const [id, value] of Object.entries(enabledPlugins as Record)) { + result.set(id, value !== false); + } + return result; +} + /** * Computes the effective set of enabled plugin ids across the settings * scopes. A plugin's value may be `true`, a `string[]` (version @@ -57,8 +74,6 @@ async function resolveEnabledPluginIds(workingDirectory: URI | undefined, userHo const seenFiles = new ResourceSet(); for (const uri of claudeSettingsFilesByPrecedence(workingDirectory, userHome)) { if (seenFiles.has(uri)) { - // The same settings file can be reached from two scopes (cwd === - // userHome) — read it once. Mirrors the per-scanner dedupe. continue; } seenFiles.add(uri); @@ -114,9 +129,9 @@ async function hasManifest(dir: URI, fileService: IFileService): Promise { +async function resolveSkillsDirRoot(plugin: string, workingDirectories: readonly URI[], userHome: URI, fileService: IFileService): Promise { const candidates: URI[] = []; - if (workingDirectory) { + for (const workingDirectory of workingDirectories) { candidates.push(URI.joinPath(workingDirectory, '.claude', 'skills', plugin)); } candidates.push(URI.joinPath(userHome, '.claude', 'skills', plugin)); @@ -189,6 +204,34 @@ export async function scanClaudeNativePlugins( logService: ILogService, ): Promise { const ids = await resolveEnabledPluginIds(workingDirectory, userHome, fileService); + return resolveNativePlugins(ids, workingDirectory ? [workingDirectory] : [], userHome, fileService, logService); +} + +export async function scanClaudeNativePluginsForRoots( + workingDirectories: readonly URI[], + userHome: URI, + fileService: IFileService, + logService: ILogService, +): Promise { + const settingsFiles: URI[] = []; + for (const workingDirectory of workingDirectories) { + settingsFiles.push( + URI.joinPath(workingDirectory, '.claude', 'settings.local.json'), + URI.joinPath(workingDirectory, '.claude', 'settings.json'), + ); + } + settingsFiles.push(URI.joinPath(userHome, '.claude', 'settings.json')); + const ids = selectEnabledClaudePluginIds(await Promise.all(settingsFiles.map(uri => readEnabledPlugins(uri, fileService)))); + return resolveNativePlugins(ids, workingDirectories, userHome, fileService, logService); +} + +async function resolveNativePlugins( + ids: readonly string[], + workingDirectories: readonly URI[], + userHome: URI, + fileService: IFileService, + logService: ILogService, +): Promise { const result: IResolvedNativePlugin[] = []; const seenRoots = new ResourceSet(); for (const id of ids) { @@ -198,7 +241,7 @@ export async function scanClaudeNativePlugins( continue; } const root = parts.marketplace === SKILLS_DIR_MARKETPLACE - ? await resolveSkillsDirRoot(parts.plugin, workingDirectory, userHome, fileService) + ? await resolveSkillsDirRoot(parts.plugin, workingDirectories, userHome, fileService) : await resolveMarketplaceCacheRoot(parts.plugin, parts.marketplace, userHome, fileService); if (!root) { logService.warn(`[claudeNativePluginScan] could not resolve an on-disk root for enabled plugin '${id}'`); @@ -209,7 +252,10 @@ export async function scanClaudeNativePlugins( } seenRoots.add(root); try { - const parsed = await parsePlugin(root, fileService, workingDirectory, userHome, root); + const workspaceRoot = parts.marketplace === SKILLS_DIR_MARKETPLACE + ? findMostSpecificClaudeWorkspaceRoot(root, workingDirectories) + : undefined; + const parsed = await parsePlugin(root, fileService, workspaceRoot ?? workingDirectories[0], userHome, root); result.push({ id, root, parsed }); } catch (err) { logService.warn(`[claudeNativePluginScan] failed to parse plugin '${id}' at '${root.toString()}': ${err instanceof Error ? err.message : String(err)}`); diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index c87803f88bc..a57baa3e6e3 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -12,8 +12,8 @@ import { fetchResourceMetadata } from '../../../../base/common/oauth.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { type IObservable, observableValue } from '../../../../base/common/observable.js'; -import { basename, dirname, isAbsolute, join, resolve, sep } from '../../../../base/common/path.js'; -import { isEqual } from '../../../../base/common/resources.js'; +import { basename, dirname, isAbsolute, join, normalize, resolve, sep } from '../../../../base/common/path.js'; +import { extUriBiasedIgnorePathCase, isEqual } from '../../../../base/common/resources.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; @@ -21,7 +21,7 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { localize } from '../../../../nls.js'; import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; -import { createSchema, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostMcpServersConfigKey, type ISchemaProperty, type SessionMode } from '../../common/agentHostSchema.js'; +import { createSchema, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostCodexMultiRootEnabledConfigKey, AgentHostMcpServersConfigKey, type ISchemaProperty, type SessionMode } from '../../common/agentHostSchema.js'; import { createPricingMetaFromBilling, normalizeCAPIBilling } from '../../common/agentModelPricing.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, type CodexUsageSource } from '../../common/agentHostCustomizationConfig.js'; import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js'; @@ -92,6 +92,8 @@ import type { Thread } from './protocol/generated/v2/Thread.js'; import type { ThreadListResponse } from './protocol/generated/v2/ThreadListResponse.js'; import type { ThreadReadResponse } from './protocol/generated/v2/ThreadReadResponse.js'; import type { ThreadForkResponse } from './protocol/generated/v2/ThreadForkResponse.js'; +import type { ThreadStartResponse } from './protocol/generated/v2/ThreadStartResponse.js'; +import type { ThreadResumeResponse } from './protocol/generated/v2/ThreadResumeResponse.js'; import type { TurnCompletedNotification } from './protocol/generated/v2/TurnCompletedNotification.js'; import type { TurnStartedNotification } from './protocol/generated/v2/TurnStartedNotification.js'; import type { ItemStartedNotification } from './protocol/generated/v2/ItemStartedNotification.js'; @@ -356,6 +358,45 @@ const codexSessionConfigDefaults: ICodexSessionConfigDefaults = { [CodexSessionConfigKey.ReasoningSummary]: 'auto', }; +function distinctAbsolutePaths(paths: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const path of paths) { + const normalized = normalize(path); + const key = filesystemPathComparisonKey(normalized); + if (key && !seen.has(key)) { + seen.add(key); + result.push(normalized); + } + } + return result; +} + +function distinctWorkingDirectories(directories: readonly URI[] | undefined): readonly URI[] | undefined { + if (!directories) { + return undefined; + } + const seen = new Set(); + const result: URI[] = []; + for (const directory of directories) { + const path = normalize(directory.fsPath); + const key = filesystemPathComparisonKey(path); + if (key && !seen.has(key)) { + seen.add(key); + result.push(directory); + } + } + return result.length > 0 ? result : undefined; +} + +function filesystemPathComparisonKey(path: string): string | undefined { + if (!isAbsolute(path)) { + return undefined; + } + const resource = extUriBiasedIgnorePathCase.removeTrailingPathSeparator(URI.file(path)); + return extUriBiasedIgnorePathCase.getComparisonKey(resource); +} + const CodexPrewarmTtlMs = 60_000; /** @@ -404,6 +445,7 @@ interface ICodexSession { * `workingDirectory`). */ workingDirectories?: readonly URI[]; + readonly multiRootEnabled: boolean; /** * Set to the temp folder created for this session when no working * directory was supplied, so {@link CodexAgent.disposeSession} can remove @@ -531,6 +573,10 @@ interface ICodexSession { readonly clientCustomizations: CodexClientCustomizationStore; } +type ICodexSessionRead = ThreadReadResponse & { + readonly persistedWorkingDirectories?: readonly URI[]; +}; + /** * A live Codex collab-agent (subagent) child thread. Codex runs each spawned * subagent as its OWN app-server thread that emits a full item/turn event @@ -1162,10 +1208,16 @@ export class CodexAgent extends Disposable implements IAgent { if (mode === 'read-only') { return { type: 'readOnly', networkAccess: false }; } - const writableRoots = [ - ...(session.workingDirectory ? [session.workingDirectory.fsPath] : []), - ...(narrowAdditionalDirectories(config[CodexSessionConfigKey.AdditionalDirectories]) ?? []), - ]; + const additionalDirectories = narrowAdditionalDirectories(config[CodexSessionConfigKey.AdditionalDirectories]) ?? []; + const writableRoots = this._isMultiRootActive(session) + ? distinctAbsolutePaths([ + ...this._runtimeWorkspaceRoots(session), + ...additionalDirectories, + ]) + : [ + ...(session.workingDirectory ? [session.workingDirectory.fsPath] : []), + ...additionalDirectories, + ]; return { type: 'workspaceWrite', writableRoots, @@ -1179,7 +1231,9 @@ export class CodexAgent extends Disposable implements IAgent { const config = this._readSessionConfig(session); const { approvalPolicy, sandboxMode, approvalsReviewer } = this._resolveSessionPermissions(session); const sandboxPolicy = this._sandboxPolicy(session, config, sandboxMode); - const runtimeWorkspaceRoots = sandboxPolicy.type === 'workspaceWrite' ? sandboxPolicy.writableRoots : undefined; + const runtimeWorkspaceRoots = this._isMultiRootActive(session) + ? this._runtimeWorkspaceRoots(session) + : (sandboxPolicy.type === 'workspaceWrite' ? sandboxPolicy.writableRoots : undefined); const effort = this._getReasoningEffort(session); const personality = narrowPersonality(config[CodexSessionConfigKey.Personality]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.Personality]; const summary = narrowReasoningSummary(config[CodexSessionConfigKey.ReasoningSummary]) ?? codexSessionConfigDefaults[CodexSessionConfigKey.ReasoningSummary]; @@ -1205,6 +1259,16 @@ export class CodexAgent extends Disposable implements IAgent { }; } + private _runtimeWorkspaceRoots(session: ICodexSession): string[] { + const workingDirectories = session.workingDirectories + ?? (session.workingDirectory ? [session.workingDirectory] : []); + return distinctAbsolutePaths(workingDirectories.map(directory => directory.fsPath)); + } + + private _isMultiRootActive(session: ICodexSession): boolean { + return session.multiRootEnabled && (session.workingDirectories?.length ?? 0) > 1; + } + private async _refreshModels(): Promise { const usageSource = this._usageSource; if (usageSource === 'openai') { @@ -2197,6 +2261,8 @@ export class CodexAgent extends Disposable implements IAgent { threadId: childThreadId, sessionUri: parent.sessionUri, workingDirectory: parent.workingDirectory, + workingDirectories: parent.workingDirectories, + multiRootEnabled: parent.multiRootEnabled, managedWorkingDirectory: undefined, mapState: createCodexSessionMapState(new Set(this._serverToolHost?.toolNames ?? []), clientToolSet), pendingCommandApprovals: new PendingRequestRegistry(), @@ -2625,9 +2691,14 @@ export class CodexAgent extends Disposable implements IAgent { description: this._usageSource === 'openai' ? localize('codexAgent.description.openai', "Codex agent using your OpenAI account") : localize('codexAgent.description.copilot', "Codex agent using GitHub Copilot"), + ...(this._isMultiRootEnabled() ? { capabilities: { multipleWorkingDirectories: { immutablePrimary: true } } } : {}), }; } + private _isMultiRootEnabled(): boolean { + return this._configurationService.getRootValue(platformRootSchema, AgentHostCodexMultiRootEnabledConfigKey) === true; + } + private _sessionUriFromChat(chat: URI): URI { const parsed = parseChatUri(chat); return parsed ? URI.parse(parsed.session) : chat; @@ -2708,6 +2779,10 @@ export class CodexAgent extends Disposable implements IAgent { const effectiveModel = this._supportedModelOrUndefined(config.model); const sessionId = config.session ? AgentSession.id(config.session) : generateUuid(); const sessionUri = config.session ?? AgentSession.uri(this.id, sessionId); + const multiRootEnabled = this._isMultiRootEnabled(); + const workingDirectories = multiRootEnabled && (config.workingDirectories?.length ?? 0) > 1 + ? distinctWorkingDirectories(config.workingDirectories) + : undefined; // If the workbench is rebinding this URI (createSession arriving // after a previous dispose for the same id), reuse the existing @@ -2729,6 +2804,8 @@ export class CodexAgent extends Disposable implements IAgent { threadId: undefined, sessionUri, workingDirectory: config.workingDirectories?.[0], + workingDirectories, + multiRootEnabled, managedWorkingDirectory: undefined, mapState: createCodexSessionMapState(new Set(this._serverToolHost?.toolNames ?? []), clientToolSet), pendingCommandApprovals: new PendingRequestRegistry(), @@ -2775,13 +2852,16 @@ export class CodexAgent extends Disposable implements IAgent { * `thread/resume` (`needsResume: true`) — so the prewarm/first-turn flags * are pre-set to their post-materialization values. */ - private _createResumedSessionEntry(sessionId: string, threadId: string, sessionUri: URI, workingDirectory: URI | undefined, model: ModelSelection | undefined): ICodexSession { + private _createResumedSessionEntry(sessionId: string, threadId: string, sessionUri: URI, workingDirectory: URI | undefined, model: ModelSelection | undefined, workingDirectories?: readonly URI[], multiRootEnabled?: boolean): ICodexSession { const clientToolSet = new ActiveClientToolSet(); + const effectiveWorkingDirectories = distinctWorkingDirectories(workingDirectories); return { sessionId, threadId, sessionUri, workingDirectory, + workingDirectories: effectiveWorkingDirectories, + multiRootEnabled: multiRootEnabled ?? (effectiveWorkingDirectories?.length ?? 0) > 1, managedWorkingDirectory: undefined, mapState: createCodexSessionMapState(new Set(this._serverToolHost?.toolNames ?? []), clientToolSet), pendingCommandApprovals: new PendingRequestRegistry(), @@ -2833,12 +2913,21 @@ export class CodexAgent extends Disposable implements IAgent { } const sourceThreadId = sourceRead.thread.id; const sourceTurns = sourceRead.thread.turns ?? []; + const sourceSession = this._sessions.get(AgentSession.id(fork.session)); + const sourcePrimary = sourceRead.thread.cwd ? URI.file(sourceRead.thread.cwd) : config.workingDirectories?.[0]; + const sourceStoredWorkingDirectories = sourceSession?.workingDirectories ?? sourceRead.persistedWorkingDirectories; + const inheritedWorkingDirectories = sourcePrimary + ? distinctWorkingDirectories([sourcePrimary, ...(sourceStoredWorkingDirectories?.slice(1) ?? [])]) + : undefined; + const multiRootEnabled = sourceSession?.multiRootEnabled ?? (inheritedWorkingDirectories?.length ?? 0) > 1; + const runtimeWorkspaceRoots = multiRootEnabled && inheritedWorkingDirectories && inheritedWorkingDirectories.length > 1 + ? distinctAbsolutePaths(inheritedWorkingDirectories.map(directory => directory.fsPath)) + : undefined; // Resolve how many trailing turns to drop so the fork keeps turns up to // and including `fork.turnId`. A live source maps host turn ids to codex // turn ids; a restored source already uses codex ids. Fall back to the // caller-supplied `turnIndex` when the id can't be resolved. - const sourceSession = this._sessions.get(AgentSession.id(fork.session)); const codexTurnId = sourceSession?.codexTurnIdByHostTurnId.get(fork.turnId) ?? fork.turnId; // Reject an unresolvable fork boundary rather than silently keeping the // full history: if neither the mapped codex turn id nor the caller's @@ -2867,6 +2956,10 @@ export class CodexAgent extends Disposable implements IAgent { ); const forkResult = await conn.client.request<'thread/fork', ThreadForkResponse>('thread/fork', { threadId: sourceThreadId, + ...(runtimeWorkspaceRoots?.length ? { + cwd: runtimeWorkspaceRoots[0], + runtimeWorkspaceRoots, + } : {}), ...(model ? { model: model.id } : {}), approvalPolicy, sandbox: sandboxMode, @@ -2900,8 +2993,15 @@ export class CodexAgent extends Disposable implements IAgent { const workingDirectory = forkResult.cwd ? URI.file(forkResult.cwd) : (sourceRead.thread.cwd ? URI.file(sourceRead.thread.cwd) : config.workingDirectories?.[0]); + const forkWorkingDirectories = multiRootEnabled + ? distinctWorkingDirectories( + forkResult.runtimeWorkspaceRoots?.length + ? forkResult.runtimeWorkspaceRoots.map(path => URI.file(path)) + : inheritedWorkingDirectories, + ) + : undefined; - const session = this._createResumedSessionEntry(newThreadId, newThreadId, newSessionUri, workingDirectory, model); + const session = this._createResumedSessionEntry(newThreadId, newThreadId, newSessionUri, workingDirectory, model, forkWorkingDirectories, multiRootEnabled); this._sessions.set(newThreadId, session); this._sessionIdByThreadId.set(newThreadId, newThreadId); // Forked threads skip materialization (the thread already exists), so @@ -3008,8 +3108,11 @@ export class CodexAgent extends Disposable implements IAgent { threadConfig.mcp_servers = mcpServers as JsonValue; this._logService.info(`[Codex] thread/start for session=${session.sessionUri.toString()} with ${mcpServerNames.length} MCP server(s): ${mcpServerNames.join(', ')}`); } - const startResult = await conn.client.request<'thread/start', { thread: { id: string } }>('thread/start', { + const multiRootActive = this._isMultiRootActive(session); + const runtimeWorkspaceRoots = multiRootActive ? this._runtimeWorkspaceRoots(session) : undefined; + const startResult = await conn.client.request<'thread/start', ThreadStartResponse>('thread/start', { cwd: session.workingDirectory.fsPath, + ...(runtimeWorkspaceRoots?.length ? { runtimeWorkspaceRoots } : {}), model: model.id, approvalPolicy, sandbox: sandboxMode, @@ -3018,6 +3121,10 @@ export class CodexAgent extends Disposable implements IAgent { dynamicTools: this._buildDynamicTools(session), }); const threadId = startResult.thread.id; + if (multiRootActive && !session.workingDirectories && startResult.runtimeWorkspaceRoots?.length) { + session.workingDirectories = startResult.runtimeWorkspaceRoots.map(path => URI.file(path)); + session.workingDirectory = session.workingDirectories[0]; + } if (session.disposed) { try { await conn.client.request<'thread/unsubscribe'>('thread/unsubscribe', { threadId }); @@ -3143,11 +3250,20 @@ export class CodexAgent extends Disposable implements IAgent { } // Persist only once the prewarmed thread is claimed by a turn. This // avoids restoring an expired, never-used prewarm as a live session. - void this._metadataStore.write(session.sessionUri, { + const multiRootActive = this._isMultiRootActive(session); + const fields = { threadId: session.threadId, cwd: session.workingDirectory, modelId: session.model?.id, - }); + workingDirectories: multiRootActive ? session.workingDirectories : undefined, + }; + void this._metadataStore.write(session.sessionUri, fields); + if (multiRootActive) { + const canonicalSessionUri = AgentSession.uri(this.id, session.threadId); + if (!isEqual(session.sessionUri, canonicalSessionUri)) { + void this._metadataStore.write(canonicalSessionUri, fields); + } + } } private _claimPrewarm(session: ICodexSession): void { @@ -3165,6 +3281,12 @@ export class CodexAgent extends Disposable implements IAgent { if (session.prewarmClaimed) { if (session.threadId === undefined && !session.materializePromise) { session.workingDirectory = workingDirectory; + if (this._isMultiRootActive(session)) { + session.workingDirectories = distinctWorkingDirectories([ + workingDirectory, + ...(session.workingDirectories?.slice(1) ?? []), + ]); + } } return; } @@ -3225,7 +3347,12 @@ export class CodexAgent extends Disposable implements IAgent { // assign when the send supplied one, so the resume path keeps emitting the // singular working directory. if (workingDirectories) { - session.workingDirectories = workingDirectories; + session.workingDirectories = session.multiRootEnabled && workingDirectories.length > 1 + ? distinctWorkingDirectories([ + session.workingDirectory ?? workingDirectories[0], + ...workingDirectories.slice(1), + ]) + : workingDirectories; } const conn = await this._ensureConnection(); const effectiveTurnId = turnId ?? generateUuid(); @@ -3281,7 +3408,16 @@ export class CodexAgent extends Disposable implements IAgent { // so a resumed thread reconnects auth-gated servers, matching // the config a fresh `thread/start` would apply. const mcpServers = this._buildSessionMcpServers(session); - await conn.client.request<'thread/resume'>('thread/resume', buildCodexResumeParams(this._usageSource, threadId, mcpServers)); + const multiRootActive = this._isMultiRootActive(session); + const runtimeWorkspaceRoots = multiRootActive ? this._runtimeWorkspaceRoots(session) : undefined; + const resumeResult = await conn.client.request<'thread/resume', ThreadResumeResponse>( + 'thread/resume', + buildCodexResumeParams(this._usageSource, threadId, mcpServers, runtimeWorkspaceRoots), + ); + if (multiRootActive && !session.workingDirectories && resumeResult.runtimeWorkspaceRoots?.length) { + session.workingDirectories = resumeResult.runtimeWorkspaceRoots.map(path => URI.file(path)); + session.workingDirectory = session.workingDirectories[0]; + } session.materializedMcpSig = mcpServersSignature(mcpServers); session.needsResume = false; } catch (err) { @@ -3662,10 +3798,14 @@ export class CodexAgent extends Disposable implements IAgent { // thread/resume (Decision 8). The threadId came from the metadata // overlay or from `thread/list` (when the session was materialized // in a prior process); `_readSession` returns the resolved id. + const metadata = this._withWorkingDirectories( + this._threadToMetadata(read.thread, session), + read.persistedWorkingDirectories, + ); if (!this._sessions.has(sessionId)) { const workingDirectory = read.thread.cwd ? URI.file(read.thread.cwd) : undefined; const threadId = read.thread.id; - const restored = this._createResumedSessionEntry(sessionId, threadId, session, workingDirectory, undefined); + const restored = this._createResumedSessionEntry(sessionId, threadId, session, workingDirectory, undefined, metadata.workingDirectories); this._sessions.set(sessionId, restored); this._sessionIdByThreadId.set(threadId, sessionId); if (!isCodexThreadProviderCompatible(this._usageSource, read.thread.modelProvider)) { @@ -3679,10 +3819,10 @@ export class CodexAgent extends Disposable implements IAgent { this._serverToolHost.advertise(restored.sessionUri.toString()); } } - return this._threadToMetadata(read.thread, session); + return metadata; } - private async _readSession(session: URI): Promise { + private async _readSession(session: URI): Promise { // Resolve the codex thread id for this session URI. Resolution // order: in-memory session → persisted metadata overlay → URI host // (for sessions materialized in a prior process where sessionId @@ -3690,9 +3830,11 @@ export class CodexAgent extends Disposable implements IAgent { const sessionId = AgentSession.id(session); const existing = this._sessions.get(sessionId); let threadId = existing?.threadId; + let persistedWorkingDirectories = existing?.workingDirectories; if (threadId === undefined) { const overlay = await this._metadataStore.read(session); threadId = overlay.threadId ?? sessionId; + persistedWorkingDirectories = overlay.workingDirectories; } try { const conn = await this._ensureConnection(); @@ -3700,7 +3842,7 @@ export class CodexAgent extends Disposable implements IAgent { threadId, includeTurns: true, }); - return response; + return { ...response, persistedWorkingDirectories }; } catch (err) { const message = err instanceof Error ? err.message : String(err); // `thread not loaded` is app-server's expected response for any @@ -3716,9 +3858,12 @@ export class CodexAgent extends Disposable implements IAgent { } async listSessions(): Promise { - if (!this._githubToken) { - return []; - } + // Reject rather than reporting an empty list while the GitHub token is + // still landing: the workbench treats a successful listing as the + // authoritative session set and would evict — and permanently unpin and + // ungroup — every Codex session. A rejection instead leaves the cached + // list intact and self-heals through the caller's backoff retry. + this._ensureAuthenticated(); // Don't connect (and trigger a cold SDK download) just to list threads // at startup. When the SDK isn't local yet, surface an empty list; the // download fires (with host-level progress) once the user starts a @@ -3747,10 +3892,11 @@ export class CodexAgent extends Disposable implements IAgent { liveUriByThreadId.set(s.threadId, s.sessionUri); } } - return response.data.map(t => this._threadToMetadata( - t, - liveUriByThreadId.get(t.id) ?? AgentSession.uri(this.id, t.id), - )); + return response.data.map(thread => { + const sessionUri = liveUriByThreadId.get(thread.id) ?? AgentSession.uri(this.id, thread.id); + const liveWorkingDirectories = this._sessions.get(AgentSession.id(sessionUri))?.workingDirectories; + return this._withWorkingDirectories(this._threadToMetadata(thread, sessionUri), liveWorkingDirectories); + }); } catch (err) { this._logService.warn(`[Codex] thread/list failed: ${err instanceof Error ? err.message : String(err)}`); return []; @@ -3768,6 +3914,20 @@ export class CodexAgent extends Disposable implements IAgent { }; } + private _withWorkingDirectories(metadata: IAgentSessionMetadata, storedWorkingDirectories: readonly URI[] | undefined): IAgentSessionMetadata { + const primary = metadata.workingDirectories?.[0]; + if (!primary || !storedWorkingDirectories || storedWorkingDirectories.length <= 1) { + return metadata; + } + const workingDirectories = distinctWorkingDirectories([ + primary, + ...storedWorkingDirectories.slice(1), + ]); + return workingDirectories && workingDirectories.length > 1 + ? { ...metadata, workingDirectories } + : metadata; + } + setServerToolHost(host: IAgentServerToolHost): void { this._serverToolHost = host; } diff --git a/src/vs/platform/agentHost/node/codex/codexLaunchConfig.ts b/src/vs/platform/agentHost/node/codex/codexLaunchConfig.ts index 3d508f37f37..ef84bbadde5 100644 --- a/src/vs/platform/agentHost/node/codex/codexLaunchConfig.ts +++ b/src/vs/platform/agentHost/node/codex/codexLaunchConfig.ts @@ -23,10 +23,14 @@ export function isCodexThreadProviderCompatible(usageSource: CodexUsageSource, m } /** Explicitly bind a compatible resumed thread to the current global usage source. */ -export function buildCodexResumeParams(usageSource: CodexUsageSource, threadId: string, mcpServers: Readonly>): ThreadResumeParams { +export function buildCodexResumeParams(usageSource: CodexUsageSource, threadId: string, mcpServers: Readonly>, workingDirectories?: readonly string[]): ThreadResumeParams { return { threadId, modelProvider: usageSource === 'copilot' ? 'vscode-proxy' : 'openai', + ...(workingDirectories?.length ? { + cwd: workingDirectories[0], + runtimeWorkspaceRoots: [...workingDirectories], + } : {}), ...(Object.keys(mcpServers).length > 0 ? { config: { mcp_servers: mcpServers as JsonValue } } : {}), }; } diff --git a/src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts b/src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts index 14ded96e49e..f8634bdd5a4 100644 --- a/src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts +++ b/src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts @@ -21,6 +21,8 @@ import { ISessionDataService } from '../../common/sessionDataService.js'; * materialize time. * `codex.cwd` — absolute path to the working directory the * session was created against (URI string). + * Multi-root sessions store a JSON object in this same + * field so single-root reads retain their original shape. * `codex.model` — serialized {@link ModelSelection.id} string, * remembered for restore so resumed sessions reuse * the model picked during the prior process. @@ -30,12 +32,14 @@ export interface ICodexSessionOverlay { readonly threadId?: string; readonly cwd?: URI; readonly modelId?: string; + readonly workingDirectories?: readonly URI[]; } export interface ICodexSessionOverlayUpdate { readonly threadId?: string; readonly cwd?: URI; readonly modelId?: string; + readonly workingDirectories?: readonly URI[]; } export class CodexSessionMetadataStore { @@ -43,7 +47,6 @@ export class CodexSessionMetadataStore { private static readonly KEY_THREAD_ID = 'codex.threadId'; private static readonly KEY_CWD = 'codex.cwd'; private static readonly KEY_MODEL = 'codex.model'; - constructor( @ISessionDataService private readonly _sessionDataService: ISessionDataService, @ILogService private readonly _logService: ILogService, @@ -65,7 +68,10 @@ export class CodexSessionMetadataStore { work.push(db.setMetadata(CodexSessionMetadataStore.KEY_THREAD_ID, fields.threadId)); } if (fields.cwd !== undefined) { - work.push(db.setMetadata(CodexSessionMetadataStore.KEY_CWD, fields.cwd.toString())); + work.push(db.setMetadata( + CodexSessionMetadataStore.KEY_CWD, + serializeCwd(fields.cwd, fields.workingDirectories), + )); } if (fields.modelId !== undefined) { work.push(db.setMetadata(CodexSessionMetadataStore.KEY_MODEL, fields.modelId)); @@ -96,10 +102,12 @@ export class CodexSessionMetadataStore { ref.object.getMetadata(CodexSessionMetadataStore.KEY_CWD), ref.object.getMetadata(CodexSessionMetadataStore.KEY_MODEL), ]); + const cwd = parseCwd(cwdRaw); return { threadId: threadId ?? undefined, - cwd: cwdRaw ? URI.parse(cwdRaw) : undefined, + cwd: cwd.cwd, modelId: modelId ?? undefined, + workingDirectories: cwd.workingDirectories, }; } finally { ref.dispose(); @@ -109,4 +117,41 @@ export class CodexSessionMetadataStore { return {}; } } + +} + +function serializeCwd(cwd: URI, workingDirectories: readonly URI[] | undefined): string { + if (!workingDirectories || workingDirectories.length <= 1) { + return cwd.toString(); + } + return JSON.stringify({ + cwd: cwd.toString(), + workingDirectories: workingDirectories.map(directory => directory.toString()), + }); +} + +function parseCwd(raw: string | undefined): { readonly cwd?: URI; readonly workingDirectories?: readonly URI[] } { + if (!raw) { + return {}; + } + if (!raw.startsWith('{')) { + return { cwd: URI.parse(raw) }; + } + try { + const value: { cwd?: unknown; workingDirectories?: unknown } = JSON.parse(raw); + if (typeof value.cwd !== 'string') { + return {}; + } + const workingDirectories = Array.isArray(value.workingDirectories) + ? value.workingDirectories + .filter((directory): directory is string => typeof directory === 'string') + .map(directory => URI.parse(directory)) + : undefined; + return { + cwd: URI.parse(value.cwd), + workingDirectories: workingDirectories && workingDirectories.length > 1 ? workingDirectories : undefined, + }; + } catch { + return {}; + } } diff --git a/src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts b/src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts index f101cd10c64..9928fd2782a 100644 --- a/src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts +++ b/src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts @@ -16,12 +16,13 @@ import { readProxyRequestBody, } from '../shared/loopbackProxyServer.js'; import { - IOpenAiChatRequest, - OpenAiTranslationError, - bridgeResultToSseFrames, - openAiErrorBody, - openAiRequestToBridge, -} from './byokOpenAiTranslation.js'; + bridgeResultToResponsesBody, + bridgeResultToResponsesSseFrames, + IResponsesRequest, + responsesErrorBody, + responsesRequestToBridge, + ResponsesTranslationError, +} from './byokResponsesTranslation.js'; // #region Public types @@ -45,7 +46,7 @@ export interface IByokLmProxyHandle extends ILoopbackProxyHandle { /** * Build the provider `baseUrl` for a given BYOK vendor. The vendor is * encoded into the path so a single proxy can serve every vendor; the - * runtime appends `/chat/completions` to this URL. + * runtime appends `/responses` to this URL. */ providerBaseUrl(vendor: string): string; } @@ -69,7 +70,7 @@ export interface IByokLmProxyService { const PROXY_USER_FACING_NAME = 'ByokLmProxyService'; const VENDOR_PATH_PREFIX = '/v/'; -const CHAT_COMPLETIONS_SUFFIX = '/chat/completions'; +const RESPONSES_SUFFIX = '/responses'; /** * The BYOK proxy keeps no per-bind mutable state: the active renderer bridge is @@ -81,11 +82,11 @@ type ByokLmProxyState = undefined; /** * Local OpenAI-compatible HTTP proxy that lets the Copilot SDK runtime run * BYOK models provided by VS Code extensions. The runtime is configured with a - * `type: 'openai'`, `wireApi: 'completions'` provider whose `baseUrl` points - * here; inbound `POST /v//chat/completions` requests are authenticated, + * `type: 'openai'`, `wireApi: 'responses'` provider whose `baseUrl` points + * here; inbound `POST /v//responses` requests are authenticated, * translated, and forwarded to the renderer LM API via * {@link IByokLmBridgeRegistry}, and the buffered completion is streamed back - * as OpenAI Chat Completions SSE. + * as OpenAI Responses SSE. * * The server lifecycle — lazy bind on `127.0.0.1`, nonce minting, refcounted * handles, in-flight tracking, and teardown — is inherited from @@ -149,9 +150,9 @@ export class ByokLmProxyService extends LoopbackProxyServer im return; } - const vendor = this._parseVendorFromChatPath(pathname); + const vendor = this._parseVendorFromResponsesPath(pathname); if (method === 'POST' && vendor !== undefined) { - await this._handleChatCompletions(req, res, runtime, vendor); + await this._handleResponses(req, res, runtime, vendor); return; } @@ -159,14 +160,13 @@ export class ByokLmProxyService extends LoopbackProxyServer im } /** - * Extract the vendor from a `/v//chat/completions` path, or return - * `undefined` when the path is not a chat-completions route. + * Extract the vendor from a `/v//responses` path. */ - private _parseVendorFromChatPath(pathname: string): string | undefined { - if (!pathname.startsWith(VENDOR_PATH_PREFIX) || !pathname.endsWith(CHAT_COMPLETIONS_SUFFIX)) { + private _parseVendorFromResponsesPath(pathname: string): string | undefined { + if (!pathname.startsWith(VENDOR_PATH_PREFIX) || !pathname.endsWith(RESPONSES_SUFFIX)) { return undefined; } - const vendorSegment = pathname.slice(VENDOR_PATH_PREFIX.length, pathname.length - CHAT_COMPLETIONS_SUFFIX.length); + const vendorSegment = pathname.slice(VENDOR_PATH_PREFIX.length, pathname.length - RESPONSES_SUFFIX.length); if (!vendorSegment) { return undefined; } @@ -185,11 +185,11 @@ export class ByokLmProxyService extends LoopbackProxyServer im return vendor; } - private async _handleChatCompletions(req: http.IncomingMessage, res: http.ServerResponse, runtime: ILoopbackProxyRuntime, vendor: string): Promise { - let body: IOpenAiChatRequest; + private async _handleResponses(req: http.IncomingMessage, res: http.ServerResponse, runtime: ILoopbackProxyRuntime, vendor: string): Promise { + let body: IResponsesRequest; try { const raw = await readProxyRequestBody(req); - body = JSON.parse(raw) as IOpenAiChatRequest; + body = JSON.parse(raw) as IResponsesRequest; } catch (err) { this._writeJsonError(res, 400, `Invalid request body: ${err instanceof Error ? err.message : String(err)}`, 'invalid_request_error'); return; @@ -197,9 +197,9 @@ export class ByokLmProxyService extends LoopbackProxyServer im let bridgeRequest; try { - bridgeRequest = openAiRequestToBridge(vendor, body); + bridgeRequest = responsesRequestToBridge(vendor, body); } catch (err) { - const message = err instanceof OpenAiTranslationError ? err.message : String(err); + const message = err instanceof ResponsesTranslationError ? err.message : String(err); this._writeJsonError(res, 400, message, 'invalid_request_error'); return; } @@ -231,15 +231,20 @@ export class ByokLmProxyService extends LoopbackProxyServer im this._writeJsonError(res, 502, result.error, 'api_error'); return; } - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }); - for (const frame of bridgeResultToSseFrames(result, bridgeRequest.modelId)) { - res.write(frame); + if (body.stream === true) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + for (const frame of bridgeResultToResponsesSseFrames(result, bridgeRequest.modelId)) { + res.write(frame); + } + res.end(); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(bridgeResultToResponsesBody(result, bridgeRequest.modelId)); } - res.end(); } catch (err) { if (entry.ac.signal.aborted || res.writableEnded) { return; @@ -261,7 +266,7 @@ export class ByokLmProxyService extends LoopbackProxyServer im return; } res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(openAiErrorBody(message, type)); + res.end(responsesErrorBody(message, type)); } } diff --git a/src/vs/platform/agentHost/node/copilot/byokOpenAiTranslation.ts b/src/vs/platform/agentHost/node/copilot/byokOpenAiTranslation.ts deleted file mode 100644 index 2adb7944a8d..00000000000 --- a/src/vs/platform/agentHost/node/copilot/byokOpenAiTranslation.ts +++ /dev/null @@ -1,262 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { - IByokLmChatMessage, - IByokLmChatRequest, - IByokLmChatResult, - IByokLmTool, - IByokLmToolCall, -} from '../../common/agentHostByokLm.js'; - -/** - * Minimal subset of the OpenAI Chat Completions wire format the Copilot SDK - * runtime emits for a `type: 'openai'`, `wireApi: 'completions'` provider - * (verified against the runtime's `chat_completion_transport.rs`, which POSTs - * to `{baseUrl}/chat/completions`). Only the fields this proxy understands are - * modeled; unknown fields are ignored. - */ - -interface IOpenAiTextContentPart { - readonly type: 'text'; - readonly text: string; -} - -type IOpenAiContentPart = IOpenAiTextContentPart | { readonly type: string;[k: string]: unknown }; - -interface IOpenAiFunctionToolCall { - readonly id?: string; - readonly type?: 'function'; - readonly function?: { - readonly name?: string; - readonly arguments?: string; - }; -} - -interface IOpenAiCustomToolCall { - readonly id?: string; - readonly type: 'custom'; - readonly custom?: { - readonly name?: string; - readonly input?: string; - }; -} - -type IOpenAiToolCall = IOpenAiFunctionToolCall | IOpenAiCustomToolCall; - -interface IOpenAiRequestMessage { - readonly role?: string; - readonly content?: string | IOpenAiContentPart[] | null; - readonly tool_calls?: IOpenAiToolCall[]; - readonly tool_call_id?: string; -} - -interface IOpenAiToolDefinition { - readonly type?: string; - readonly function?: { - readonly name?: string; - readonly description?: string; - readonly parameters?: object; - }; -} - -export interface IOpenAiChatRequest { - readonly model?: string; - readonly messages?: IOpenAiRequestMessage[]; - readonly tools?: IOpenAiToolDefinition[]; - readonly stream?: boolean; - readonly temperature?: number; - readonly top_p?: number; - readonly max_tokens?: number; - readonly [k: string]: unknown; -} - -/** Thrown when the inbound body cannot be mapped to a bridge request. */ -export class OpenAiTranslationError extends Error { } - -function flattenContent(content: string | IOpenAiContentPart[] | null | undefined): string { - if (typeof content === 'string') { - return content; - } - if (Array.isArray(content)) { - let out = ''; - for (const part of content) { - if (part && part.type === 'text' && typeof (part as IOpenAiTextContentPart).text === 'string') { - out += (part as IOpenAiTextContentPart).text; - } - } - return out; - } - return ''; -} - -function toBridgeRole(role: string | undefined): IByokLmChatMessage['role'] { - switch (role) { - case 'system': - case 'developer': - return 'system'; - case 'assistant': - return 'assistant'; - case 'tool': - case 'function': - return 'tool'; - case 'user': - default: - return 'user'; - } -} - -function toBridgeToolCalls(toolCalls: IOpenAiToolCall[] | undefined): IByokLmToolCall[] | undefined { - if (!toolCalls || toolCalls.length === 0) { - return undefined; - } - const mapped: IByokLmToolCall[] = []; - for (let i = 0; i < toolCalls.length; i++) { - const call = toolCalls[i]; - if (call.type === 'custom') { - const name = call.custom?.name; - if (!name) { - throw new OpenAiTranslationError(`tool_calls[${i}].custom.name is required`); - } - mapped.push({ - id: call.id ?? `call_${i}`, - name, - argumentsJson: JSON.stringify({ input: call.custom?.input ?? '' }), - }); - continue; - } - - const name = call.function?.name; - if (!name) { - throw new OpenAiTranslationError(`tool_calls[${i}].function.name is required`); - } - mapped.push({ - id: call.id ?? `call_${i}`, - name, - argumentsJson: call.function?.arguments ?? '{}', - }); - } - return mapped; -} - -function toBridgeTools(tools: IOpenAiToolDefinition[] | undefined): IByokLmTool[] | undefined { - if (!tools || tools.length === 0) { - return undefined; - } - const mapped: IByokLmTool[] = []; - for (const tool of tools) { - const fn = tool.function; - if (!fn?.name) { - continue; - } - mapped.push({ - name: fn.name, - description: fn.description, - parametersSchema: fn.parameters, - }); - } - return mapped.length ? mapped : undefined; -} - -/** - * Convert a parsed OpenAI Chat Completions request into the serializable - * bridge request. `vendor` is the synthesized provider name the runtime used - * (it is not present in the OpenAI body); `model` becomes the provider-local - * wire model id resolved on the renderer. - */ -export function openAiRequestToBridge(vendor: string, body: IOpenAiChatRequest): IByokLmChatRequest { - const model = typeof body.model === 'string' ? body.model : ''; - if (!model) { - throw new OpenAiTranslationError('Request is missing the "model" field'); - } - const sourceMessages = Array.isArray(body.messages) ? body.messages : []; - const messages: IByokLmChatMessage[] = sourceMessages.map(message => ({ - role: toBridgeRole(message.role), - content: flattenContent(message.content), - toolCalls: toBridgeToolCalls(message.tool_calls), - toolCallId: message.tool_call_id, - })); - - const modelOptions: Record = {}; - if (typeof body.temperature === 'number') { - modelOptions.temperature = body.temperature; - } - if (typeof body.top_p === 'number') { - modelOptions.top_p = body.top_p; - } - if (typeof body.max_tokens === 'number') { - modelOptions.max_tokens = body.max_tokens; - } - - return { - vendor, - modelId: model, - messages, - tools: toBridgeTools(body.tools), - modelOptions: Object.keys(modelOptions).length ? modelOptions : undefined, - }; -} - -let chunkCounter = 0; - -function nextCompletionId(): string { - chunkCounter = (chunkCounter + 1) % Number.MAX_SAFE_INTEGER; - return `chatcmpl-byok-${Date.now().toString(36)}-${chunkCounter.toString(36)}`; -} - -/** Serialize a single SSE `data:` frame. */ -function sseFrame(payload: unknown): string { - return `data: ${JSON.stringify(payload)}\n\n`; -} - -/** - * Encode a buffered {@link IByokLmChatResult} as a sequence of OpenAI - * `chat.completion.chunk` SSE frames terminated by `data: [DONE]`. - * - * The whole completion is emitted in one content delta (Stage 1 is - * non-streaming end-to-end); the runtime's SSE parser accepts this shape. - */ -export function bridgeResultToSseFrames(result: IByokLmChatResult, model: string): string[] { - const id = nextCompletionId(); - const created = Math.floor(Date.now() / 1000); - const base = { id, object: 'chat.completion.chunk', created, model }; - const frames: string[] = []; - - // Role delta first, matching the OpenAI streaming contract. - frames.push(sseFrame({ ...base, choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] })); - - if (result.content) { - frames.push(sseFrame({ ...base, choices: [{ index: 0, delta: { content: result.content }, finish_reason: null }] })); - } - - let finishReason: 'stop' | 'tool_calls' = 'stop'; - if (result.toolCalls && result.toolCalls.length > 0) { - finishReason = 'tool_calls'; - const toolCallsDelta = result.toolCalls.map((call, index) => ({ - index, - id: call.id, - type: 'function', - function: { name: call.name, arguments: call.argumentsJson }, - })); - frames.push(sseFrame({ ...base, choices: [{ index: 0, delta: { tool_calls: toolCallsDelta }, finish_reason: null }] })); - } - - const finalChunk: Record = { ...base, choices: [{ index: 0, delta: {}, finish_reason: finishReason }] }; - if (result.usage) { - finalChunk.usage = { - prompt_tokens: result.usage.promptTokens ?? 0, - completion_tokens: result.usage.completionTokens ?? 0, - total_tokens: (result.usage.promptTokens ?? 0) + (result.usage.completionTokens ?? 0), - }; - } - frames.push(sseFrame(finalChunk)); - frames.push('data: [DONE]\n\n'); - return frames; -} - -/** Build an OpenAI-style error envelope body. */ -export function openAiErrorBody(message: string, type = 'api_error'): string { - return JSON.stringify({ error: { message, type } }); -} diff --git a/src/vs/platform/agentHost/node/copilot/byokResponsesTranslation.ts b/src/vs/platform/agentHost/node/copilot/byokResponsesTranslation.ts new file mode 100644 index 00000000000..acd2767a83a --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/byokResponsesTranslation.ts @@ -0,0 +1,489 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + IByokLmChatRequest, + IByokLmChatResult, + IByokLmInputItem, + IByokLmOutputItem, + IByokLmTool, +} from '../../common/agentHostByokLm.js'; + +interface IResponsesContentPart { + readonly type?: string; + readonly text?: string; +} + +interface IResponsesSummaryPart { + readonly type?: string; + readonly text?: string; +} + +interface IResponsesInputItem { + readonly type?: string; + readonly role?: string; + readonly content?: string | IResponsesContentPart[]; + readonly id?: string; + readonly summary?: IResponsesSummaryPart[]; + readonly encrypted_content?: string | null; + readonly call_id?: string; + readonly name?: string; + readonly arguments?: string; + readonly input?: string; + readonly output?: string; +} + +interface IResponsesTool { + readonly type?: string; + readonly name?: string; + readonly description?: string; + readonly parameters?: object; +} + +export interface IResponsesRequest { + readonly model?: string; + readonly instructions?: string; + readonly input?: string | IResponsesInputItem[]; + readonly tools?: IResponsesTool[]; + readonly previous_response_id?: string; + readonly reasoning?: { + readonly effort?: string; + }; + readonly temperature?: number; + readonly top_p?: number; + readonly max_output_tokens?: number; + readonly [key: string]: unknown; +} + +export class ResponsesTranslationError extends Error { } + +function toBridgeRole(role: string | undefined): 'system' | 'developer' | 'user' | 'assistant' { + switch (role) { + case 'system': + case 'developer': + case 'assistant': + case 'user': + return role; + default: + throw new ResponsesTranslationError(`Unsupported message role '${role ?? ''}'`); + } +} + +function toTextParts(content: string | IResponsesContentPart[] | undefined, itemIndex: number): Array<{ type: 'text'; text: string }> { + if (typeof content === 'string') { + return content ? [{ type: 'text', text: content }] : []; + } + if (!Array.isArray(content)) { + return []; + } + return content.map((part, contentIndex) => { + if ((part.type === 'input_text' || part.type === 'output_text' || part.type === 'text') && typeof part.text === 'string') { + return { type: 'text' as const, text: part.text }; + } + throw new ResponsesTranslationError(`Unsupported input[${itemIndex}].content[${contentIndex}] type '${part.type ?? ''}'`); + }); +} + +function requiredString(value: string | undefined, path: string): string { + if (!value) { + throw new ResponsesTranslationError(`${path} is required`); + } + return value; +} + +function toBridgeInputItem(item: IResponsesInputItem, index: number): IByokLmInputItem { + switch (item.type) { + case 'message': + return { + type: 'message', + role: toBridgeRole(item.role), + content: toTextParts(item.content, index), + }; + case 'reasoning': + return { + type: 'reasoning', + id: item.id, + summary: (item.summary ?? []).map((part, summaryIndex) => { + if (part.type !== 'summary_text' || typeof part.text !== 'string') { + throw new ResponsesTranslationError(`Unsupported input[${index}].summary[${summaryIndex}]`); + } + return part.text; + }), + encryptedContent: item.encrypted_content ?? undefined, + }; + case 'function_call': + return { + type: 'function_call', + callId: requiredString(item.call_id, `input[${index}].call_id`), + name: requiredString(item.name, `input[${index}].name`), + argumentsJson: item.arguments ?? '{}', + }; + case 'function_call_output': + return { + type: 'function_call_output', + callId: requiredString(item.call_id, `input[${index}].call_id`), + output: item.output ?? '', + }; + case 'custom_tool_call': + return { + type: 'custom_tool_call', + callId: requiredString(item.call_id, `input[${index}].call_id`), + name: requiredString(item.name, `input[${index}].name`), + input: item.input ?? '', + }; + case 'custom_tool_call_output': + return { + type: 'custom_tool_call_output', + callId: requiredString(item.call_id, `input[${index}].call_id`), + output: item.output ?? '', + }; + default: + throw new ResponsesTranslationError(`Unsupported input[${index}] type '${item.type ?? ''}'`); + } +} + +function toBridgeTools(tools: IResponsesTool[] | undefined): IByokLmTool[] | undefined { + if (!tools?.length) { + return undefined; + } + return tools.map((tool, index) => { + switch (tool.type) { + case 'function': + return { + type: 'function', + name: requiredString(tool.name, `tools[${index}].name`), + description: tool.description, + parametersSchema: tool.parameters, + }; + case 'custom': + return { + type: 'custom', + name: requiredString(tool.name, `tools[${index}].name`), + description: tool.description, + }; + default: + throw new ResponsesTranslationError(`Unsupported tools[${index}] type '${tool.type ?? ''}'`); + } + }); +} + +export function responsesRequestToBridge(vendor: string, body: IResponsesRequest): IByokLmChatRequest { + const modelId = requiredString(body.model, 'model'); + let input: IByokLmInputItem[]; + if (typeof body.input === 'string') { + input = [{ type: 'message', role: 'user', content: [{ type: 'text', text: body.input }] }]; + } else if (Array.isArray(body.input)) { + input = body.input.map(toBridgeInputItem); + } else { + input = []; + } + + const modelOptions: Record = {}; + if (typeof body.temperature === 'number') { + modelOptions.temperature = body.temperature; + } + if (typeof body.top_p === 'number') { + modelOptions.top_p = body.top_p; + } + if (typeof body.max_output_tokens === 'number') { + modelOptions.max_tokens = body.max_output_tokens; + } + + return { + vendor, + modelId, + instructions: body.instructions, + input, + tools: toBridgeTools(body.tools), + previousResponseId: body.previous_response_id, + reasoningEffort: body.reasoning?.effort, + modelOptions: Object.keys(modelOptions).length ? modelOptions : undefined, + }; +} + +let responseCounter = 0; + +function nextId(prefix: string): string { + responseCounter = (responseCounter + 1) % Number.MAX_SAFE_INTEGER; + return `${prefix}_byok_${Date.now().toString(36)}_${responseCounter.toString(36)}`; +} + +function sseEvent(eventName: string, data: unknown): string { + return `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`; +} + +type ResponsesOutputItem = + | { readonly id: string; readonly type: 'message'; readonly role: 'assistant'; readonly status: 'completed'; readonly content: Array<{ readonly type: 'output_text'; readonly text: string; readonly annotations: unknown[]; readonly logprobs: unknown[] }> } + | { readonly id: string; readonly type: 'reasoning'; readonly status: 'completed'; readonly summary: Array<{ readonly type: 'summary_text'; readonly text: string }>; readonly encrypted_content: string | null } + | { readonly id: string; readonly type: 'function_call'; readonly status: 'completed'; readonly call_id: string; readonly name: string; readonly arguments: string } + | { readonly id: string; readonly type: 'custom_tool_call'; readonly status: 'completed'; readonly call_id: string; readonly name: string; readonly input: string }; + +function toInProgressOutputItem(item: ResponsesOutputItem): object { + switch (item.type) { + case 'message': + return { ...item, status: 'in_progress', content: [] }; + case 'reasoning': + return { ...item, status: 'in_progress', summary: [], encrypted_content: null }; + case 'function_call': + return { ...item, status: 'in_progress', arguments: '' }; + case 'custom_tool_call': + return { ...item, status: 'in_progress', input: '' }; + } +} + +function toResponsesOutputItem(item: IByokLmOutputItem): ResponsesOutputItem { + switch (item.type) { + case 'message': + return { + id: nextId('msg'), + type: 'message', + role: 'assistant', + status: 'completed', + content: item.content.map(part => ({ type: 'output_text', text: part.text, annotations: [], logprobs: [] })), + }; + case 'reasoning': + return { + id: item.id?.startsWith('rs') ? item.id : nextId('rs'), + type: 'reasoning', + status: 'completed', + summary: item.summary.map(text => ({ type: 'summary_text', text })), + encrypted_content: item.encryptedContent ?? null, + }; + case 'function_call': + return { + id: nextId('fc'), + type: 'function_call', + status: 'completed', + call_id: item.callId, + name: item.name, + arguments: item.argumentsJson, + }; + case 'custom_tool_call': + return { + id: nextId('ctc'), + type: 'custom_tool_call', + status: 'completed', + call_id: item.callId, + name: item.name, + input: item.input, + }; + } +} + +function outputText(items: readonly ResponsesOutputItem[]): string { + return items + .filter((item): item is Extract => item.type === 'message') + .flatMap(item => item.content) + .map(part => part.text) + .join(''); +} + +function responseEnvelope(responseId: string, model: string, status: 'in_progress' | 'completed', output: readonly ResponsesOutputItem[], usage: unknown) { + return { + id: responseId, + object: 'response', + created_at: Math.floor(Date.now() / 1000), + status, + error: null, + incomplete_details: null, + instructions: null, + model, + output, + output_text: outputText(output), + parallel_tool_calls: true, + temperature: 1, + tool_choice: 'auto', + tools: [], + top_p: 1, + usage, + }; +} + +function prepareResponse(result: IByokLmChatResult, model: string) { + const responseId = result.responseId ?? nextId('resp'); + const output = result.output.map(toResponsesOutputItem); + const inputTokens = result.usage?.inputTokens ?? 0; + const outputTokens = result.usage?.outputTokens ?? 0; + const usage = { + input_tokens: inputTokens, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: outputTokens, + output_tokens_details: { reasoning_tokens: result.usage?.reasoningTokens ?? 0 }, + total_tokens: inputTokens + outputTokens, + }; + return { + responseId, + output, + completed: responseEnvelope(responseId, model, 'completed', output, usage), + }; +} + +export function bridgeResultToResponsesBody(result: IByokLmChatResult, model: string): string { + return JSON.stringify(prepareResponse(result, model).completed); +} + +function reasoningFrames(item: Extract, outputIndex: number, sequence: { value: number }): string[] { + const frames: string[] = []; + item.summary.forEach((part, summaryIndex) => { + frames.push(sseEvent('response.reasoning_summary_part.added', { + type: 'response.reasoning_summary_part.added', + sequence_number: sequence.value++, + item_id: item.id, + output_index: outputIndex, + summary_index: summaryIndex, + part: { type: 'summary_text', text: '' }, + })); + frames.push(sseEvent('response.reasoning_summary_text.delta', { + type: 'response.reasoning_summary_text.delta', + sequence_number: sequence.value++, + item_id: item.id, + output_index: outputIndex, + summary_index: summaryIndex, + delta: part.text, + })); + frames.push(sseEvent('response.reasoning_summary_text.done', { + type: 'response.reasoning_summary_text.done', + sequence_number: sequence.value++, + item_id: item.id, + output_index: outputIndex, + summary_index: summaryIndex, + text: part.text, + })); + frames.push(sseEvent('response.reasoning_summary_part.done', { + type: 'response.reasoning_summary_part.done', + sequence_number: sequence.value++, + item_id: item.id, + output_index: outputIndex, + summary_index: summaryIndex, + part, + })); + }); + return frames; +} + +function messageFrames(item: Extract, outputIndex: number, sequence: { value: number }): string[] { + const frames: string[] = []; + item.content.forEach((part, contentIndex) => { + frames.push(sseEvent('response.content_part.added', { + type: 'response.content_part.added', + sequence_number: sequence.value++, + item_id: item.id, + output_index: outputIndex, + content_index: contentIndex, + part: { type: 'output_text', text: '', annotations: [], logprobs: [] }, + })); + frames.push(sseEvent('response.output_text.delta', { + type: 'response.output_text.delta', + sequence_number: sequence.value++, + item_id: item.id, + output_index: outputIndex, + content_index: contentIndex, + delta: part.text, + logprobs: [], + })); + frames.push(sseEvent('response.output_text.done', { + type: 'response.output_text.done', + sequence_number: sequence.value++, + item_id: item.id, + output_index: outputIndex, + content_index: contentIndex, + text: part.text, + logprobs: [], + })); + frames.push(sseEvent('response.content_part.done', { + type: 'response.content_part.done', + sequence_number: sequence.value++, + item_id: item.id, + output_index: outputIndex, + content_index: contentIndex, + part, + })); + }); + return frames; +} + +function callFrames(item: Extract, outputIndex: number, sequence: { value: number }): string[] { + if (item.type === 'function_call') { + return [ + sseEvent('response.function_call_arguments.delta', { + type: 'response.function_call_arguments.delta', + sequence_number: sequence.value++, + item_id: item.id, + output_index: outputIndex, + delta: item.arguments, + }), + sseEvent('response.function_call_arguments.done', { + type: 'response.function_call_arguments.done', + sequence_number: sequence.value++, + item_id: item.id, + output_index: outputIndex, + arguments: item.arguments, + }), + ]; + } + return [ + sseEvent('response.custom_tool_call_input.delta', { + type: 'response.custom_tool_call_input.delta', + sequence_number: sequence.value++, + item_id: item.id, + output_index: outputIndex, + delta: item.input, + }), + sseEvent('response.custom_tool_call_input.done', { + type: 'response.custom_tool_call_input.done', + sequence_number: sequence.value++, + item_id: item.id, + output_index: outputIndex, + input: item.input, + }), + ]; +} + +export function bridgeResultToResponsesSseFrames(result: IByokLmChatResult, model: string): string[] { + const { responseId, output, completed } = prepareResponse(result, model); + const sequence = { value: 0 }; + const frames: string[] = []; + const skeleton = responseEnvelope(responseId, model, 'in_progress', [], undefined); + frames.push(sseEvent('response.created', { type: 'response.created', sequence_number: sequence.value++, response: skeleton })); + frames.push(sseEvent('response.in_progress', { type: 'response.in_progress', sequence_number: sequence.value++, response: skeleton })); + + output.forEach((item, outputIndex) => { + frames.push(sseEvent('response.output_item.added', { + type: 'response.output_item.added', + sequence_number: sequence.value++, + output_index: outputIndex, + item: toInProgressOutputItem(item), + })); + switch (item.type) { + case 'message': + frames.push(...messageFrames(item, outputIndex, sequence)); + break; + case 'reasoning': + frames.push(...reasoningFrames(item, outputIndex, sequence)); + break; + case 'function_call': + case 'custom_tool_call': + frames.push(...callFrames(item, outputIndex, sequence)); + break; + } + frames.push(sseEvent('response.output_item.done', { + type: 'response.output_item.done', + sequence_number: sequence.value++, + output_index: outputIndex, + item, + })); + }); + + frames.push(sseEvent('response.completed', { + type: 'response.completed', + sequence_number: sequence.value++, + response: completed, + })); + return frames; +} + +export function responsesErrorBody(message: string, type = 'api_error'): string { + return JSON.stringify({ error: { message, type } }); +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 827e3b6bbef..7e8227e7bd0 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -76,7 +76,7 @@ import { CopilotAgentSession, type CopilotSdkMode } from './copilotAgentSession. import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; import { CopilotGitHubTelemetryForwarder } from './copilotGitHubTelemetryForwarder.js'; -import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, isCopilotReasoningEffort, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { ShellManager } from './copilotShellTools.js'; import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; import { ICopilotApiService, type IRestrictedTelemetryContext } from '../shared/copilotApiService.js'; @@ -1092,12 +1092,16 @@ export class CopilotAgent extends Disposable implements IAgent { } this._byokModels = this._byokBridgeRegistry.getModels().map((m): IAgentModelInfo => { const byokMeta = createAgentModelByokMeta(m.modelIdentifier); + const supportedReasoningEfforts = m.supportedReasoningEfforts?.filter(isCopilotReasoningEffort); + const defaultReasoningEffort = supportedReasoningEfforts?.find(effort => effort === m.defaultReasoningEffort) ?? supportedReasoningEfforts?.[0]; + const thinkingLevel = this._createThinkingLevelConfigSchemaProperty(supportedReasoningEfforts, defaultReasoningEffort); return { provider: this.id, id: `${m.vendor}/${m.id}`, name: m.name ?? m.id, maxContextWindow: m.maxContextWindowTokens, supportsVision: m.supportsVision ?? false, + ...(thinkingLevel ? { configSchema: { type: 'object', properties: { [ThinkingLevelConfigKey]: thinkingLevel } } satisfies ConfigSchema } : {}), ...(byokMeta && { _meta: byokMeta }), }; }); @@ -2097,8 +2101,13 @@ export class CopilotAgent extends Disposable implements IAgent { const project = await projectFromCopilotContext({ cwd: workingDirectory?.fsPath }, this._gitService); + // The resolved root set (index 0 = process root, e.g. a worktree). + // Shared by the persisted metadata, the baseline checkpoint and the + // materialize receipt so all three agree on the same directories. + const materializedWorkingDirectories = resolvedWorkingDirectories ?? (workingDirectory ? [workingDirectory] : undefined); + this._provisionalSessions.delete(sessionId); - await this._storeSessionMetadata(sessionUri, provisional.model, workingDirectory, resolvedWorkingDirectories ?? (workingDirectory ? [workingDirectory] : undefined), customizationDirectory, project, true); + await this._storeSessionMetadata(sessionUri, provisional.model, workingDirectory, materializedWorkingDirectories, customizationDirectory, project, true); if (agent !== undefined) { await this._storeSessionAgentMetadata(sessionUri, agent); } @@ -2109,14 +2118,18 @@ export class CopilotAgent extends Disposable implements IAgent { // invisible to the FileEditTracker pipeline. Best-effort: a // non-git folder or capture failure leaves the session running // with the legacy `file_edits`-based per-turn diff path. - this._checkpointService.captureBaseline(sessionUri, workingDirectory).catch(err => { + // + // The resolved directories are passed explicitly: the state manager + // does not learn about them until it observes the materialize event + // fired below, so a lookup here would still see the pre-worktree set. + this._checkpointService.captureBaselineCheckpoint(sessionUri, materializedWorkingDirectories).catch(err => { this._logService.warn(`[Copilot:${sessionId}] Baseline checkpoint capture failed: ${err instanceof Error ? err.message : String(err)}`); }); this._logService.info(`[Copilot] Session materialized: ${sessionUri.toString()}`); // Emit the resolved working-directory set (index 0 = process root). The host // replaces index 0 of the session set with it, preserving the tail. - this._onDidMaterializeSession.fire({ session: sessionUri, project, workingDirectories: resolvedWorkingDirectories ?? (workingDirectory ? [workingDirectory] : undefined) }); + this._onDidMaterializeSession.fire({ session: sessionUri, project, workingDirectories: materializedWorkingDirectories }); return agentSession; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index bc5c841f534..25f04dc1ce0 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import type { CopilotSession, CurrentToolMetadata, ExitPlanModeRequest, McpServersLoadedServer, MessageOptions, PermissionAllowAllMode, PermissionAutoApproval, PermissionRequestResult, PermissionResult, SessionConfig, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; -import { raceCancellation, Sequencer } from '../../../../base/common/async.js'; +import { raceCancellation, RunOnceScheduler, Sequencer, Throttler } from '../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.js'; import { CancellationError, getErrorMessage } from '../../../../base/common/errors.js'; import { escapeMarkdownSyntaxTokens } from '../../../../base/common/htmlContent.js'; -import { Disposable, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { isAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { safeStringify } from '../../../../base/common/objects.js'; @@ -27,11 +27,12 @@ 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 { getCopilotHomePath } from '../../common/copilotHome.js'; import { CopilotCliConfigKey, applyModelFamilyAlias, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js'; import { gitHubMcpServerUrl } from '../../common/githubEndpoints.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; -import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; +import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; import { AgentSession, AgentSignal, AuthenticateParams, IMcpNotification, IRestoredSubagentSession, subagentChatTitle, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; @@ -39,11 +40,12 @@ import { readToolCallMeta, toToolCallMeta, type IToolCallMeta, type IToolCallUiM import { OtelData, type OtelAttributeValue } from '../../common/otlp/otlpLogEmitter.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { resolveCopilotConfigSlashCommandOnSend } from '../../common/copilotConfigSlashCommands.js'; +import { STREAMING_TOOL_DISPLAY_INTERVAL_MS, streamingToolDisplayText } from '../../common/streamingToolCallDisplay.js'; import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAttachment } from '../../common/meta/agentFeedbackAttachments.js'; import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js'; -import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment } from '../../common/state/protocol/state.js'; +import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment, type ToolCallContributor } from '../../common/state/protocol/state.js'; import { ActionType, isChatAction, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; -import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, getToolSubagentContent, isDefaultChatUri, isSubagentSession, withSessionPromptCacheState, type Message, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type ToolResultTerminalContent, type Turn, type UsageInfo, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, getToolSubagentContent, isDefaultChatUri, isSubagentSession, readSessionPromptCacheState, withSessionPromptCacheState, type Message, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type ToolResultTerminalContent, type Turn, type UsageInfo, type UsageInfoMeta, type IContextAttributionData, type ISessionPromptCacheState } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import type { IExitPlanModeResponse } from './copilotAgent.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; @@ -59,7 +61,7 @@ import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './cop import { NonPtyShellTerminalStreams } from './copilotNonPtyShellTerminals.js'; import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.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 { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getStreamingInvocationMessage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isAgentCoordinationTool, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, parseCopilotStreamingToolInput, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; import { FileEditTracker } from '../shared/fileEditTracker.js'; import { ICopilotApiService, type IRestrictedTelemetryContext } from '../shared/copilotApiService.js'; import type { IAgentHostRestrictedTelemetryContext } from '../agentHostRestrictedTelemetry.js'; @@ -101,8 +103,28 @@ interface IMcpAuthToolCall { readonly parentToolCallId: string | undefined; } -const COPILOT_HOME_DIRECTORY = '.copilot'; -const SESSION_STATE_DIRECTORY = join(COPILOT_HOME_DIRECTORY, 'session-state'); +interface ICopilotActiveToolCall { + readonly toolName: string; + readonly displayName: string; + readonly parameters: Record | undefined; + readonly content: ToolResultContent[]; + readonly parentToolCallId: string | undefined; + readonly mcpServerName: string | undefined; + readonly contributor: ToolCallContributor | undefined; + readonly intention: string | undefined; + meta: IToolCallMeta | undefined; +} + +interface ICopilotStreamingToolCall { + input: string; + toolName: string | undefined; + parentToolCallId: string | undefined; + started: boolean; + displayedInputLength: number; + displayedMessage: string | undefined; +} + +const SESSION_STATE_DIRECTORY = 'session-state'; const EMPTY_TOOL_RESULT_TEXT = ''; function isPermissionDeniedKind(kind: PermissionResult['kind'] | undefined): boolean { @@ -332,8 +354,7 @@ function elicitationAnswerToFieldValue(field: ElicitationSchemaField, answer: Ch } function getCopilotCLISessionStateDir(userHome: string): string { - const xdgHome = process.env['XDG_STATE_HOME']; - return xdgHome ? join(xdgHome, SESSION_STATE_DIRECTORY) : join(userHome, SESSION_STATE_DIRECTORY); + return join(getCopilotHomePath(userHome, process.env), SESSION_STATE_DIRECTORY); } /** @@ -488,21 +509,33 @@ class CopilotTurn { private readonly _stopWatch = StopWatch.create(false); /** - * Accumulated Copilot usage for this turn, in nano-AIU, keyed by scope. - * Scope `''` is the parent turn aggregate (parent agent calls plus every - * subagent call), so the parent turn's reported cost is the full turn - * total. Each subagent additionally accumulates under its `parentToolCallId` - * so its own component cost can be reported on the subagent's child session. + * This turn's own Copilot cost in nano-AIU, summed from the `copilotUsage` + * carried by the model calls the turn caused — its own, every subagent's, + * and any compaction that ran mid-turn. + * + * Accumulated synchronously as each event arrives rather than derived from + * the SDK's session-wide total: that total is read asynchronously, and the + * terminal `session.idle` can close the turn while a read is in flight, + * which would drop the turn's last model call from its reported cost. */ - readonly copilotUsageTotalNanoAiuByScope = new Map(); + copilotNanoAiu = 0; + + /** + * Per-subagent component cost, in nano-AIU, keyed by `parentToolCallId`. + * The SDK's session metrics are session-wide and carry no per-agent + * breakdown, so a subagent's own running total is still accumulated from + * its usage events in order to report it on the subagent's child session. + */ + readonly subagentNanoAiuByToolCallId = new Map(); /** * The parent (main-agent) turn's own last context usage — model plus token - * counts and per-event cost. Subagent usage events are folded into the - * parent aggregate for credit purposes only, so they must not overwrite the - * parent turn's model/context-token usage. Retaining the parent's own last - * values lets each subagent usage event refresh the parent aggregate's - * credit total while preserving the model that produced the parent response. + * counts and per-event cost. A subagent's model call contributes to the + * turn's credits (the SDK's session metrics already include it) but must not + * overwrite the parent turn's model/context-token usage. Retaining the + * parent's own last values lets each subagent usage event refresh the parent + * aggregate's credit total while preserving the model that produced the + * parent response. */ parentContextUsage: UsageContext | undefined; @@ -570,7 +603,9 @@ export class CopilotAgentSession extends Disposable { get workingDirectory(): URI | undefined { return this._workingDirectory; } /** Tracks active tool invocations so we can produce past-tense messages on completion. */ - private readonly _activeToolCalls = new Map | undefined; content: ToolResultContent[]; parentToolCallId: string | undefined; mcpServerName: string | undefined; meta: IToolCallMeta | undefined }>(); + private readonly _activeToolCalls = new Map(); + private readonly _streamingToolCalls = new Map(); + private readonly _streamingToolDisplaySchedulers = this._register(new DisposableMap()); /** * Maps a subagent's stable `agentId` to its parent tool call id. Completion * ends the current subagent turn, but steering can start another turn with @@ -679,15 +714,30 @@ export class CopilotAgentSession extends Disposable { */ private _lastSeenModelId: string | undefined; /** - * Compaction credits (nano-AIU) billed while no turn was active, carried - * forward onto the next turn. Automatic compaction can run outside a turn - * (e.g. after an abort, or between turns); the `chat/usage` reducer only - * applies usage to the *active* turn, so without this the cost — which is - * at its highest exactly here, since an out-of-turn compaction usually - * finds a cold prompt cache and pays the ~12x cache-write rate — would be - * dropped entirely. + * Latest session-wide nano-AIU total reported by the SDK's usage metrics + * (`rpc.usage.getMetrics`), which is authoritative for what the session as a + * whole has been billed: it folds in every model call plus compaction, + * covers work billed while no turn was active, and survives resume. + * + * Deliberately *not* used to derive per-turn cost. It is session-scoped and + * read asynchronously, so differencing it against a previous reading races + * turn boundaries — the SDK's terminal `session.idle` can close a turn while + * a read is still in flight. Per-turn cost comes from the synchronous + * per-event `copilotUsage` instead (see {@link CopilotTurn.copilotNanoAiu}). */ - private _carriedCompactionNanoAiu = 0; + private _sessionTotalNanoAiu = 0; + private _promptCacheState: ISessionPromptCacheState | undefined; + private _promptCacheRefreshGeneration = 0; + /** + * Serializes the metrics reads behind {@link _refreshSessionUsageMetrics}. Several + * handlers refresh the total, so without this their RPCs overlap and an older + * one resolving last would publish a session cost that visibly regresses. A + * high-water mark cannot be used to reject stale reads instead, because the + * total is legitimately non-monotonic (see the truncation note below). Keeping + * one read in flight makes out-of-order resolution impossible, and coalesces + * the redundant reads that a burst of usage events would otherwise issue. + */ + private readonly _sessionUsageMetricsRefreshThrottler = this._register(new Throttler()); /** SDK session wrapper, set by {@link initializeSession}. */ private _wrapper!: CopilotSessionWrapper; private readonly _slashCommandProvider: CopilotSlashCommandProvider; @@ -1037,31 +1087,159 @@ export class CopilotAgentSession extends Disposable { return false; } + private _getToolCallContributor(toolName: string, mcpServerName: string | undefined): ToolCallContributor | undefined { + const clientToolName = this._clientToolName(toolName); + if (this._clientToolNames.has(clientToolName)) { + const clientId = this._activeClientToolSet.ownerOf(clientToolName, this._currentTurn?.senderClientId); + return clientId ? { kind: ToolCallContributorKind.Client, clientId } : undefined; + } + if (mcpServerName) { + const customizationId = this._mcpCustomizations.customizationIdForServer(mcpServerName); + return customizationId ? { kind: ToolCallContributorKind.MCP, customizationId } : undefined; + } + return undefined; + } + + private _createToolCallMeta(toolName: string, parameters: Record | undefined): Mutable { + const toolKind = getToolKind(toolName); + const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; + return { + toolKind, + language: toolKind === 'terminal' ? getShellLanguage(toolName) : undefined, + subagentDescription: subagentMeta?.description, + subagentAgentName: subagentMeta?.agentName, + }; + } + + private _getStreamingToolCallDisplay(toolName: string, input: string) { + const partialInput = parseCopilotStreamingToolInput(input); + const parameters = partialInput !== null && typeof partialInput === 'object' && !Array.isArray(partialInput) + ? partialInput as Record + : undefined; + return { + parameters, + meta: this._createToolCallMeta(toolName, parameters), + invocationMessage: getStreamingInvocationMessage(toolName, getToolDisplayName(toolName), partialInput, path => this._resolveEditFilePath(path)), + }; + } + + private _emitStreamingToolCallDisplay(toolCallId: string, streaming: ICopilotStreamingToolCall): void { + if (!streaming.toolName) { + return; + } + const display = this._getStreamingToolCallDisplay(streaming.toolName, streaming.input); + streaming.displayedInputLength = streaming.input.length; + const message = streamingToolDisplayText(display.invocationMessage); + if (message === streaming.displayedMessage) { + return; + } + streaming.displayedMessage = message; + this._emitAction({ + type: ActionType.ChatToolCallDelta, + turnId: this._turnId, + toolCallId, + content: '', + invocationMessage: display.invocationMessage, + _meta: toToolCallMeta(display.meta), + }, streaming.parentToolCallId); + } + + private _scheduleStreamingToolCallDisplay(toolCallId: string): void { + let scheduler = this._streamingToolDisplaySchedulers.get(toolCallId); + if (!scheduler) { + scheduler = new RunOnceScheduler(() => { + const streaming = this._streamingToolCalls.get(toolCallId); + if (!streaming?.started || !streaming.toolName) { + return; + } + if (streaming.displayedInputLength === streaming.input.length) { + return; + } + this._emitStreamingToolCallDisplay(toolCallId, streaming); + }, STREAMING_TOOL_DISPLAY_INTERVAL_MS); + this._streamingToolDisplaySchedulers.set(toolCallId, scheduler); + } + if (!scheduler.isScheduled()) { + scheduler.schedule(); + } + } + + private _beginToolCallRound(parentToolCallId: string | undefined): void { + const scope = parentToolCallId ?? ''; + this._currentTurn?.markdownPartIds.delete(scope); + this._currentTurn?.reasoningPartIds.delete(scope); + } + /** * Starts a fresh `pending` turn, discarding any per-turn streaming state * from a previous turn so the next text/reasoning chunk allocates a new * response part. The turn becomes `running` on the first SDK event. */ resetTurnState(turnId: string, senderClientId?: string, clientType = AgentHostClientType.Unknown): void { + this._streamingToolCalls.clear(); + this._streamingToolDisplaySchedulers.clearAndDisposeAll(); this._currentTurn = new CopilotTurn(turnId, this._nextTurnOrdinal++, senderClientId, clientType); - // Seed the parent scope with any compaction billed while no turn was active so the cost - // surfaces on this turn rather than being lost. The bank is deliberately NOT cleared here: - // a turn can end without ever reporting usage (it fails before the first SDK usage event, - // runs as a purely local slash command, or is replaced by another reset), and clearing on - // seed would drop the credits on the floor. It is cleared only once a report actually - // carries it — see {@link _onCarriedCompactionReported}. - if (this._carriedCompactionNanoAiu > 0) { - this._currentTurn.copilotUsageTotalNanoAiuByScope.set('', this._carriedCompactionNanoAiu); + } + + /** Refreshes prompt-cache state and the session-wide nano-AIU total from the SDK's authoritative usage metrics. */ + private async _refreshSessionUsageMetrics(): Promise { + try { + return await this._sessionUsageMetricsRefreshThrottler.queue(async () => { + const promptCacheRefreshGeneration = this._promptCacheRefreshGeneration; + const metrics = await this._wrapper.session.rpc.usage.getMetrics(); + const modelId = metrics.currentModel; + if (!this._store.isDisposed && modelId && promptCacheRefreshGeneration === this._promptCacheRefreshGeneration) { + const cacheExpiresAt = metrics.modelMetrics[modelId]?.cacheExpiresAt; + this._setPromptCacheState(cacheExpiresAt ? { modelId, cacheExpiresAt } : undefined); + } + + const total = metrics.totalNanoAiu; + if (typeof total !== 'number' || !Number.isFinite(total) || total < 0 || total === this._sessionTotalNanoAiu) { + return false; + } + this._sessionTotalNanoAiu = total; + return true; + }); + } catch (err) { + // Also covers the rejection from a throttler disposed mid-read. + this._logService.trace(`[Copilot:${this.sessionId}] usage.getMetrics RPC failed: ${getErrorMessage(err)}`); + return false; } } /** - * Clears the out-of-turn compaction bank once a parent-scope usage report has carried it, so - * the credits are billed to exactly one turn. Called from every site that emits a parent-scope - * running total, since any of them can be the one that first reports the carry. + * The parent-scope Copilot billing metadata for the active turn: the turn's + * own accumulated cost plus the SDK's session-wide total. Absent until + * something has actually been billed. */ - private _onCarriedCompactionReported(): void { - this._carriedCompactionNanoAiu = 0; + private _parentCopilotUsageMeta(): UsageInfoMeta['copilotUsage'] | undefined { + const turnNanoAiu = this._currentTurn?.copilotNanoAiu ?? 0; + if (!turnNanoAiu && !this._sessionTotalNanoAiu) { + return undefined; + } + return { + ...(turnNanoAiu ? { totalNanoAiu: turnNanoAiu } : {}), + ...(this._sessionTotalNanoAiu ? { sessionTotalNanoAiu: this._sessionTotalNanoAiu } : {}), + }; + } + + /** Reads the SDK's per-source context-window attribution, or `undefined` when unavailable. */ + private async _readContextAttribution(): Promise { + let attribution: IContextAttributionData | undefined; + try { + attribution = (await this._wrapper.session.rpc.metadata.getContextAttribution())?.contextAttribution ?? undefined; + } catch (err) { + this._logService.trace(`[Copilot:${this.sessionId}] contextAttribution RPC failed: ${getErrorMessage(err)}`); + return undefined; + } + if (!attribution) { + this._logService.trace(`[Copilot:${this.sessionId}] contextAttribution: null/empty`); + return undefined; + } + if (this._logService.getLevel() <= LogLevel.Trace) { + this._logService.trace(`[Copilot:${this.sessionId}] contextAttribution: totalTokens=${attribution.totalTokens}, entries=${JSON.stringify(attribution.entries.map(e => ({ kind: e.kind, id: e.id, label: e.label, tokens: e.tokens, parentId: e.parentId })))}`); + } + return attribution; } private _completeActiveTurn(): void { @@ -1087,6 +1265,8 @@ export class CopilotAgentSession extends Disposable { */ private _clearActiveTurn(): void { this._currentTurn = undefined; + this._streamingToolCalls.clear(); + this._streamingToolDisplaySchedulers.clearAndDisposeAll(); try { this._onTurnEnded(); } catch (err) { @@ -1380,7 +1560,9 @@ export class CopilotAgentSession extends Disposable { type: ActionType.ChatToolCallReady, turnId: this._turnId, toolCallId, - invocationMessage: getInvocationMessage(tracked.toolName, tracked.displayName, tracked.parameters), + ...(tracked.contributor ? { contributor: tracked.contributor } : {}), + ...(tracked.intention !== undefined ? { intention: tracked.intention } : {}), + invocationMessage: getInvocationMessage(tracked.toolName, tracked.displayName, tracked.parameters, path => this._resolveEditFilePath(path)), toolInput: getToolInputString(tracked.toolName, tracked.parameters, tracked.parameters ? tryStringify(tracked.parameters) : undefined), confirmed: ToolCallConfirmationReason.NotNeeded, _meta: toToolCallMeta({ ...(tracked.meta ?? {}), toolSearchCandidates: candidates }), @@ -1525,6 +1707,13 @@ export class CopilotAgentSession extends Disposable { this._subscribeForMemoInvalidation(); this._subscribeForInstructionsCollectedTelemetry(); this._subscribeToPermissionConfigChanges(); + this._promptCacheState = readSessionPromptCacheState(this._stateManager.getSessionSummary(this.sessionUri.toString())?._meta); + if (this._launchPlan.kind === 'resume') { + await this._refreshSessionUsageMetrics(); + if (this._store.isDisposed) { + throw new CancellationError(); + } + } // Advertise the agent host's server tools for this session so clients // see them as server-provided. Execution happens in-process via the SDK @@ -1532,8 +1721,17 @@ export class CopilotAgentSession extends Disposable { this._serverToolHost?.advertise(this._storageUri.toString()); } - private _setPromptCacheState(promptCache: { readonly modelId: string; readonly cacheExpiresAt: string } | undefined): void { - const currentMeta = this._stateManager.getSessionSummary(this.sessionUri.toString())?._meta; + private _setPromptCacheState(promptCache: ISessionPromptCacheState | undefined): void { + const currentSummary = this._stateManager.getSessionSummary(this.sessionUri.toString()); + const currentMeta = currentSummary?._meta; + // Concurrent sessions can share `sessionUri`, so the persisted metadata — not this + // instance's cached value — is authoritative whenever a summary is available. + const currentPromptCache = currentSummary ? readSessionPromptCacheState(currentMeta) : this._promptCacheState; + this._promptCacheState = currentPromptCache; + if (currentPromptCache?.modelId === promptCache?.modelId && currentPromptCache?.cacheExpiresAt === promptCache?.cacheExpiresAt) { + return; + } + this._promptCacheState = promptCache; this._stateManager.setSessionMeta(this.sessionUri.toString(), withSessionPromptCacheState(currentMeta, promptCache)); } @@ -1756,13 +1954,11 @@ export class CopilotAgentSession extends Disposable { // `_completeActiveTurn` since the reducer drops usage for a non-active turn. const usedTokens = result.contextWindow?.currentTokens; if (typeof usedTokens === 'number') { - // `session.compaction_complete` accumulates the summarization call's credits onto the - // turn before this RPC resolves; carry that running total through so the response - // footer reports the compaction's cost instead of dropping it. - const totalNanoAiu = this._currentTurn?.copilotUsageTotalNanoAiuByScope.get(''); - if (typeof totalNanoAiu === 'number') { - this._onCarriedCompactionReported(); - } + // `session.compaction_complete` has already folded the summarization call's + // cost into the turn by the time this RPC resolves; refresh the session total + // so the report carries both. + await this._refreshSessionUsageMetrics(); + const copilotUsage = this._parentCopilotUsageMeta(); this._emitAction({ type: ActionType.ChatUsage, turnId: this._turnId, @@ -1770,7 +1966,7 @@ export class CopilotAgentSession extends Disposable { inputTokens: usedTokens, outputTokens: 0, model: this._lastSeenModelId, - ...(typeof totalNanoAiu === 'number' ? { _meta: { copilotUsage: { totalNanoAiu } } } : {}), + ...(copilotUsage ? { _meta: { copilotUsage } } : {}), }, }); } @@ -2423,7 +2619,7 @@ export class CopilotAgentSession extends Disposable { toolCallId, toolName: request.toolName, displayName, - invocationMessage: getInvocationMessage(request.toolName, displayName, parameters), + invocationMessage: getInvocationMessage(request.toolName, displayName, parameters, path => this._resolveEditFilePath(path)), toolInput: getToolInputString(request.toolName, parameters, tryStringify(parameters)), riskAssessment: autoApproval?.reason ? { @@ -2571,7 +2767,8 @@ export class CopilotAgentSession extends Disposable { // route the resulting ChatToolCallReady to the correct // subagent session — without it the action would land on the // parent session, which has no matching ChatToolCallStart. - const parentToolCallId = this._activeToolCalls.get(toolCallId)?.parentToolCallId; + const trackedToolCall = this._activeToolCalls.get(toolCallId); + const parentToolCallId = trackedToolCall?.parentToolCallId; this._onDidSessionProgress.fire({ kind: 'pending_confirmation', chat: this._chatChannelUri, @@ -2580,6 +2777,8 @@ export class CopilotAgentSession extends Disposable { toolCallId, toolName, displayName: getToolDisplayName(toolName), + contributor: trackedToolCall?.contributor, + intention: trackedToolCall?.intention, invocationMessage, toolInput, confirmationTitle, @@ -2936,10 +3135,50 @@ export class CopilotAgentSession extends Disposable { request: UserInputRequest, _invocation: { sessionId: string }, ): Promise { + const requestId = generateUuid(); + const questionId = generateUuid(); + const inputRequest: ChatInputRequest = { + id: requestId, + questions: [request.choices && request.choices.length > 0 + ? { + kind: ChatInputQuestionKind.SingleSelect, + id: questionId, + message: request.question, + required: true, + options: request.choices.map(c => ({ id: c, label: c })), + allowFreeformInput: request.allowFreeform ?? true, + } + : { + kind: ChatInputQuestionKind.Text, + id: questionId, + message: request.question, + required: true, + }, + ], + }; + const isAutopilot = this._isAutopilotMode(); if (isAutopilot || this._isAutoReplyEnabled()) { + this._emitAction({ + type: ActionType.ChatInputRequested, + request: inputRequest, + }); + this._emitAction({ + type: ActionType.ChatInputCompleted, + requestId, + response: ChatInputResponseKind.Accept, + answers: { + [questionId]: { + state: ChatInputAnswerState.Submitted, + value: { + kind: ChatInputAnswerValueKind.Text, + value: AgentHostAutoReplyAnswer, + }, + }, + }, + }); return { - answer: 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.', + answer: AgentHostAutoReplyAnswer, wasFreeform: true, }; } @@ -2950,33 +3189,10 @@ export class CopilotAgentSession extends Disposable { const questionPreview = request.question.substring(0, 100); try { - const requestId = generateUuid(); - const questionId = generateUuid(); this._logService.info(`[Copilot:${this.sessionId}] User input request: requestId=${requestId}, question="${questionPreview}"`); const pendingInput = this._pendingUserInputs.register(requestId, { questionId }); - // Build the protocol ChatInputRequest from the SDK's simple format - const inputRequest: ChatInputRequest = { - id: requestId, - questions: [request.choices && request.choices.length > 0 - ? { - kind: ChatInputQuestionKind.SingleSelect, - id: questionId, - message: request.question, - required: true, - options: request.choices.map(c => ({ id: c, label: c })), - allowFreeformInput: request.allowFreeform ?? true, - } - : { - kind: ChatInputQuestionKind.Text, - id: questionId, - message: request.question, - required: true, - }, - ], - }; - this._emitAction({ type: ActionType.ChatInputRequested, request: inputRequest, @@ -3450,24 +3666,24 @@ export class CopilotAgentSession extends Disposable { // 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. - if (!e.data.content) { - return; - } if (this._shouldDropUnmappedSubagentEvent(e, 'assistant.message')) { return; } const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); const markdownScope = parentToolCallId ?? ''; - if (this._currentTurn?.markdownPartIds.has(markdownScope)) { - return; + if (e.data.content && !this._currentTurn?.markdownPartIds.has(markdownScope)) { + const partId = generateUuid(); + this._currentTurn?.markdownPartIds.set(markdownScope, partId); + this._emitAction({ + type: ActionType.ChatResponsePart, + turnId: this._turnId, + part: { kind: ResponsePartKind.Markdown, id: partId, content: e.data.content }, + }, parentToolCallId); + } + if (e.data.toolRequests?.length) { + // Wait for the full message boundary; clearing on an earlier tool delta would duplicate assembled markdown. + this._beginToolCallRound(parentToolCallId); } - const partId = generateUuid(); - this._currentTurn?.markdownPartIds.set(markdownScope, partId); - this._emitAction({ - type: ActionType.ChatResponsePart, - turnId: this._turnId, - part: { kind: ResponsePartKind.Markdown, id: partId, content: e.data.content }, - }, parentToolCallId); })); // TODO@connor4312: Remove this correlation once the SDK permission callback includes auto-approval data. @@ -3512,8 +3728,61 @@ export class CopilotAgentSession extends Disposable { } })); + this._register(wrapper.onToolCallDelta(e => { + this._logService.trace(`[Copilot:${sessionId}] Tool call delta: ${e.data.toolName ?? ''} (${e.data.toolCallId})`); + this._resumeSubagentForEvent(e); + if (this._shouldDropUnmappedSubagentEvent(e, 'assistant.tool_call_delta')) { + return; + } + + const existing = this._streamingToolCalls.get(e.data.toolCallId); + const streaming = existing ?? { + input: '', + toolName: undefined, + parentToolCallId: undefined, + started: false, + displayedInputLength: 0, + displayedMessage: undefined, + }; + streaming.input += e.data.inputDelta; + if (e.data.toolName) { + if (streaming.toolName && streaming.toolName !== e.data.toolName) { + this._logService.warn(`[Copilot:${sessionId}] Tool call ${e.data.toolCallId} changed name while streaming from ${streaming.toolName} to ${e.data.toolName}`); + } else { + streaming.toolName = e.data.toolName; + } + } + this._streamingToolCalls.set(e.data.toolCallId, streaming); + + const toolName = streaming.toolName; + if (!toolName || isHiddenTool(toolName) || isTaskCompleteTool(toolName) || this._clientToolNames.has(this._clientToolName(toolName))) { + return; + } + if (!streaming.started) { + streaming.parentToolCallId = this._parentToolCallIdForSubagentEvent(e); + } + + if (!streaming.started) { + streaming.started = true; + this._emitAction({ + type: ActionType.ChatToolCallStart, + turnId: this._turnId, + toolCallId: e.data.toolCallId, + toolName, + displayName: getToolDisplayName(toolName), + contributor: this._getToolCallContributor(toolName, undefined), + _meta: toToolCallMeta(this._createToolCallMeta(toolName, undefined)), + }, streaming.parentToolCallId); + this._emitStreamingToolCallDisplay(e.data.toolCallId, streaming); + return; + } + this._scheduleStreamingToolCallDisplay(e.data.toolCallId); + })); + this._register(wrapper.onToolStart(e => { if (isHiddenTool(e.data.toolName)) { + this._streamingToolDisplaySchedulers.deleteAndDispose(e.data.toolCallId); + this._streamingToolCalls.delete(e.data.toolCallId); this._logService.trace(`[Copilot:${sessionId}] Tool started (hidden): ${e.data.toolName}`); return; } @@ -3530,13 +3799,36 @@ export class CopilotAgentSession extends Disposable { toolArgs = tryStringify(parameters); } const displayName = getToolDisplayName(e.data.toolName); + const streamed = this._streamingToolCalls.get(e.data.toolCallId); + this._streamingToolDisplaySchedulers.deleteAndDispose(e.data.toolCallId); + if (streamed?.started && streamed.displayedInputLength < streamed.input.length) { + this._emitStreamingToolCallDisplay(e.data.toolCallId, streamed); + } + this._streamingToolCalls.delete(e.data.toolCallId); + if (streamed?.toolName && streamed.toolName !== e.data.toolName) { + this._logService.warn(`[Copilot:${sessionId}] Tool call ${e.data.toolCallId} started as ${e.data.toolName} after streaming as ${streamed.toolName}`); + } this._resumeSubagentForEvent(e); - if (this._shouldDropUnmappedSubagentEvent(e, 'tool.execution_start')) { + if (!streamed?.started && this._shouldDropUnmappedSubagentEvent(e, 'tool.execution_start')) { this._unroutableSubagentToolCallIds.add(e.data.toolCallId); return; } - const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); - this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [], parentToolCallId, mcpServerName: e.data.mcpServerName, meta: undefined }); + const parentToolCallId = streamed?.parentToolCallId ?? this._parentToolCallIdForSubagentEvent(e); + const clientToolName = this._clientToolName(e.data.toolName); + const isClientTool = this._clientToolNames.has(clientToolName); + const contributor = this._getToolCallContributor(e.data.toolName, e.data.mcpServerName); + const intention = getShellIntention(e.data.toolName, parameters); + this._activeToolCalls.set(e.data.toolCallId, { + toolName: e.data.toolName, + displayName, + parameters, + content: [], + parentToolCallId, + mcpServerName: e.data.mcpServerName, + contributor, + intention, + meta: undefined, + }); const existingApproval = this._toolApprovalRecords.get(e.data.toolCallId); const approvalRecord = { permissionRequested: existingApproval?.permissionRequested ?? false, @@ -3555,42 +3847,15 @@ export class CopilotAgentSession extends Disposable { this._nonPtyShellTerminals.track(e.data.toolCallId, displayName); } if (isTaskCompleteTool(e.data.toolName)) { - const scope = parentToolCallId ?? ''; - this._currentTurn?.markdownPartIds.delete(scope); - this._currentTurn?.reasoningPartIds.delete(scope); + this._beginToolCallRound(parentToolCallId); return; } - const toolKind = getToolKind(e.data.toolName); - const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(parameters) : undefined; - let contributor: { readonly kind: ToolCallContributorKind.Client; readonly clientId: string } | { readonly kind: ToolCallContributorKind.MCP; readonly customizationId: string } | undefined; - const clientToolName = this._clientToolName(e.data.toolName); - const isClientTool = this._clientToolNames.has(clientToolName); - const ownerClientId = isClientTool ? this._activeClientToolSet.ownerOf(clientToolName, this._currentTurn?.senderClientId) : undefined; - if (ownerClientId) { - contributor = { kind: ToolCallContributorKind.Client, clientId: ownerClientId }; - } else if (e.data.mcpServerName) { - const customizationId = this._mcpCustomizations.customizationIdForServer(e.data.mcpServerName); - if (customizationId !== undefined) { - contributor = { kind: ToolCallContributorKind.MCP, customizationId }; - } + if (!streamed?.started) { + this._beginToolCallRound(parentToolCallId); } - // A new tool call invalidates the current markdown and reasoning - // parts so the next text/reasoning delta after the tool call - // starts a fresh part. Without invalidating reasoning here, a - // later round of reasoning (after tool_start/tool_complete) - // would silently append to the pre-tool-call reasoning block. - this._currentTurn?.markdownPartIds.delete(parentToolCallId ?? ''); - this._currentTurn?.reasoningPartIds.delete(parentToolCallId ?? ''); - - const meta: Mutable = { toolKind, language: toolKind === 'terminal' ? getShellLanguage(e.data.toolName) : undefined }; - if (subagentMeta?.description) { - meta.subagentDescription = subagentMeta.description; - } - if (subagentMeta?.agentName) { - meta.subagentAgentName = subagentMeta.agentName; - } + const meta = this._createToolCallMeta(e.data.toolName, parameters); if (e.data.mcpServerName) { meta.mcpServerName = e.data.mcpServerName; } @@ -3610,16 +3875,18 @@ export class CopilotAgentSession extends Disposable { tracked.meta = meta; } - this._emitAction({ - type: ActionType.ChatToolCallStart, - turnId: this._turnId, - toolCallId: e.data.toolCallId, - toolName: e.data.toolName, - displayName, - intention: getShellIntention(e.data.toolName, parameters), - contributor, - _meta: toToolCallMeta(meta), - }, parentToolCallId); + if (!streamed?.started) { + this._emitAction({ + type: ActionType.ChatToolCallStart, + turnId: this._turnId, + toolCallId: e.data.toolCallId, + toolName: e.data.toolName, + displayName, + intention, + contributor, + _meta: toToolCallMeta(meta), + }, parentToolCallId); + } // No client is connected to run this client tool. Fail it // immediately instead of leaving it pending until the @@ -3635,9 +3902,12 @@ export class CopilotAgentSession extends Disposable { type: ActionType.ChatToolCallReady, turnId: this._turnId, toolCallId: e.data.toolCallId, - invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters), + ...(contributor ? { contributor } : {}), + ...(intention !== undefined ? { intention } : {}), + invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters, path => this._resolveEditFilePath(path)), toolInput: getToolInputString(e.data.toolName, parameters, toolArgs), confirmed: ToolCallConfirmationReason.NotNeeded, + _meta: toToolCallMeta(meta), }, parentToolCallId); this._emitAction({ type: ActionType.ChatToolCallComplete, @@ -3669,10 +3939,12 @@ export class CopilotAgentSession extends Disposable { type: ActionType.ChatToolCallReady, turnId: this._turnId, toolCallId: e.data.toolCallId, - invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters), + ...(contributor ? { contributor } : {}), + ...(intention !== undefined ? { intention } : {}), + invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters, path => this._resolveEditFilePath(path)), toolInput: getToolInputString(e.data.toolName, parameters, toolArgs), confirmed: ToolCallConfirmationReason.NotNeeded, - ...(clientToolAutoApproved ? { _meta: toToolCallMeta({ autoApproveBySetting: true }) } : {}), + _meta: toToolCallMeta(clientToolAutoApproved ? { ...meta, autoApproveBySetting: true } : meta), }, parentToolCallId); })); @@ -3768,7 +4040,7 @@ export class CopilotAgentSession extends Disposable { toolCallId: e.data.toolCallId, result: { success: e.data.success, - pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success, e.data.success ? toolOutput : undefined), + pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success, e.data.success ? toolOutput : undefined, path => this._resolveEditFilePath(path)), content: content.length > 0 ? content : undefined, error: e.data.error, }, @@ -3977,18 +4249,19 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onUsage(e => { this._resumeSubagentForEvent(e); // Usage events for a subagent's model calls carry the subagent's - // `agentId`. Such an event is reported twice: - // 1. Folded into the parent turn (scope `''`) so the parent turn's - // reported cost stays the full turn aggregate (parent + every - // subagent), and - // 2. Emitted to the subagent's own child session (via - // `parentToolCallId`) carrying just that subagent's running - // component total, so the subagent tool can show its own cost. - // Main-agent (or unmapped subagent) events only contribute to the - // parent aggregate. + // `agentId`. Every model call — the parent's own and every subagent's — + // is folded into the turn's cost below, so such an event additionally + // needs only the subagent's own running component total emitted to its + // child session (via `parentToolCallId`) for the subagent tool to show + // its own cost. const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); - if (!parentToolCallId && !e.agentId && !e.data.parentToolCallId && e.data.model) { - this._setPromptCacheState(e.data.cacheExpiresAt ? { modelId: e.data.model, cacheExpiresAt: e.data.cacheExpiresAt } : undefined); + if (!parentToolCallId && !e.agentId && !e.data.parentToolCallId) { + this._promptCacheRefreshGeneration++; + if (e.data.model && e.data.cacheExpiresAt) { + this._setPromptCacheState({ modelId: e.data.model, cacheExpiresAt: e.data.cacheExpiresAt }); + } else if (e.data.model && this._promptCacheState?.modelId !== e.data.model) { + this._setPromptCacheState(undefined); + } } // `copilotUsage` is marked `asInternal` in the SDK schema so it is not exposed on the generated // `AssistantUsageData` type, but it is present at runtime. Read it dynamically. @@ -4018,27 +4291,19 @@ export class CopilotAgentSession extends Disposable { turn.parentContextUsage = eventContext; } - // Builds a usage object carrying the given context's tokens/model - // and the running credit total for the given scope. - const buildUsage = (scope: string, context: UsageContext): UsageInfo => { + // Builds a usage object carrying the given context's tokens/model plus + // the credit total for the given scope. `copilotUsage` is the scope's + // Copilot billing metadata, or `undefined` when nothing is billed yet. + const buildUsage = (context: UsageContext, scopedCopilotUsage: UsageInfoMeta['copilotUsage'], isParentScope: boolean): UsageInfo => { const metadata: UsageInfoMeta = {}; if (typeof context.cost === 'number') { metadata.cost = context.cost; } - if (scope === '' && autoModeResolved?.turnId === this._turnId) { + if (isParentScope && autoModeResolved?.turnId === this._turnId) { metadata.autoModeResolved = autoModeResolved.data; } - if (turn && typeof copilotUsage?.totalNanoAiu === 'number') { - const scopedTotal = (turn.copilotUsageTotalNanoAiuByScope.get(scope) ?? 0) + copilotUsage.totalNanoAiu; - turn.copilotUsageTotalNanoAiuByScope.set(scope, scopedTotal); - if (scope === '') { - // The parent-scope total includes any seeded out-of-turn compaction credits. - this._onCarriedCompactionReported(); - } - metadata.copilotUsage = { - ...copilotUsage, - totalNanoAiu: scopedTotal, - }; + if (scopedCopilotUsage) { + metadata.copilotUsage = scopedCopilotUsage; } if (quotaSnapshots) { metadata.quotaSnapshots = quotaSnapshots; @@ -4052,12 +4317,23 @@ export class CopilotAgentSession extends Disposable { }; }; - // Parent turn aggregate (scope `''`): every model call contributes - // its credits, but a subagent event must not replace the parent - // turn's own model/context-token usage. Fold subagent credits into - // the parent aggregate while preserving the parent's context. + // Fold this call's cost into the turn before building any report, so the + // emission below already carries it. Every model call the turn caused + // counts toward it, subagents included. Done synchronously here rather + // than from the SDK's session total, which is read across an await that + // the terminal `session.idle` can beat. + if (turn && copilotUsage) { + turn.copilotNanoAiu += copilotUsage.totalNanoAiu; + if (parentToolCallId) { + const scopedTotal = (turn.subagentNanoAiuByToolCallId.get(parentToolCallId) ?? 0) + copilotUsage.totalNanoAiu; + turn.subagentNanoAiuByToolCallId.set(parentToolCallId, scopedTotal); + } + } + + // Parent turn aggregate: a subagent event must not replace the parent + // turn's own model/context-token usage, so preserve the parent's context. const parentContext = parentToolCallId ? (turn?.parentContextUsage ?? {}) : eventContext; - const parentUsage = buildUsage('', parentContext); + const parentUsage = buildUsage(parentContext, this._parentCopilotUsageMeta(), true); lastParentUsage = parentUsage; lastParentUsageTurnId = this._turnId; this._emitAction({ @@ -4067,25 +4343,33 @@ export class CopilotAgentSession extends Disposable { }); // Subagent component: additionally report the subagent's own running - // total to its child session. + // total to its child session. The SDK's session metrics carry no + // per-agent breakdown, so this is the only source for it. if (parentToolCallId) { + const scopedTotal = turn?.subagentNanoAiuByToolCallId.get(parentToolCallId); + const subagentCopilotUsage = copilotUsage && scopedTotal !== undefined + ? { ...copilotUsage, totalNanoAiu: scopedTotal } + : undefined; this._emitAction({ type: ActionType.ChatUsage, turnId: this._turnId, - usage: buildUsage(parentToolCallId, eventContext), + usage: buildUsage(eventContext, subagentCopilotUsage, false), }, parentToolCallId); } })); - // After each usage event, asynchronously fetch the per-source context- - // window attribution from the SDK and re-emit the usage action enriched - // with the attribution data. The reducer replaces `activeTurn.usage` so - // the widget picks up the detailed breakdown on the next render cycle. + // After each usage event, asynchronously refresh the SDK's session-wide total + // (authoritative for the session, and the only source that sees work billed + // outside a turn) and re-emit the parent aggregate with it. For main-agent + // calls the per-source context-window attribution is fetched and merged in + // too — a subagent runs against its own context, so its events must not + // rewrite the parent's attribution. The reducer replaces `activeTurn.usage`, + // so the widget picks up the update on the next render cycle. + // + // Losing this re-emit to a turn that ended mid-flight costs only the session + // total's freshness; the turn's own cost was already reported synchronously. this._register(wrapper.onUsage(async e => { - // Only enrich the parent-turn aggregate (not subagent scopes). - if (this._parentToolCallIdForSubagentEvent(e)) { - return; - } + const isSubagentEvent = !!this._parentToolCallIdForSubagentEvent(e); const turnId = this._turnId; // Capture the base usage before the await boundary so concurrent // usage events don't overwrite what we merge into. @@ -4096,91 +4380,95 @@ export class CopilotAgentSession extends Disposable { model: e.data.model, cacheReadTokens: e.data.cacheReadTokens, }; - try { - const result = await this._wrapper.session.rpc.metadata.getContextAttribution(); - const attribution = result?.contextAttribution; - if (!attribution || !turnId) { - this._logService.trace(`[Copilot:${sessionId}] contextAttribution: null/empty (turnId=${turnId})`); - return; - } - // If the turn changed while we were awaiting, don't pollute the - // new turn's state with stale attribution data. - if (turnId !== this._turnId) { - return; - } - // Guard against a newer usage event having arrived while we - // were awaiting — only enrich if baseUsage is still current. - if (usage !== lastParentUsage || lastParentUsageTurnId !== turnId) { - return; - } - if (this._logService.getLevel() <= LogLevel.Trace) { - this._logService.trace(`[Copilot:${sessionId}] contextAttribution: totalTokens=${attribution.totalTokens}, entries=${JSON.stringify(attribution.entries.map(e => ({ kind: e.kind, id: e.id, label: e.label, tokens: e.tokens, parentId: e.parentId })))}`); - } - // Re-emit the usage action preserving the captured parent-scope - // usage (with accumulated credits) but adding the attribution. - const enriched: UsageInfo = { - ...usage, - _meta: { - ...(usage._meta ?? {}), - contextAttribution: attribution, - }, - }; - lastParentUsage = enriched; - lastParentUsageTurnId = turnId; - this._emitAction({ - type: ActionType.ChatUsage, - turnId, - usage: enriched, - }); - } catch (err) { - this._logService.trace(`[Copilot:${sessionId}] contextAttribution RPC failed: ${(err as Error)?.message ?? err}`); - } - })); - - // Compaction (manual `/compact` or automatic mid-turn) runs its own summarization model call. - // The SDK bills it separately and reports it on `session.compaction_complete` rather than as an - // `assistant.usage` event, so fold those credits into the turn's parent-scope running total the - // same way `buildUsage` does. This makes the turn's response footer include the compaction cost. - this._register(wrapper.onSessionCompactionComplete(e => { - if (e.agentId || e.data.success === false) { + await this._refreshSessionUsageMetrics(); + const attribution = isSubagentEvent ? undefined : await this._readContextAttribution(); + if (!turnId) { return; } - const turn = this._currentTurn; - const turnId = this._turnId; - const copilotUsage = readCopilotUsage(e.data.compactionTokensUsed); - if (!copilotUsage) { + // If the turn changed while we were awaiting, don't pollute the + // new turn's state with stale data. Likewise, guard against a newer + // usage event having arrived — only enrich if baseUsage is current. + if (turnId !== this._turnId || usage !== lastParentUsage || lastParentUsageTurnId !== turnId) { return; } - if (!turn || !turnId) { - // Compaction outside a turn: the reducer would discard usage for a non-active - // turn, so bank the credits for the next one instead of losing them. - this._carriedCompactionNanoAiu += copilotUsage.totalNanoAiu; + const copilotUsage = this._parentCopilotUsageMeta(); + if (!attribution && !copilotUsage) { return; } - const scopedTotal = (turn.copilotUsageTotalNanoAiuByScope.get('') ?? 0) + copilotUsage.totalNanoAiu; - turn.copilotUsageTotalNanoAiuByScope.set('', scopedTotal); - // This report carries any credits banked from an earlier out-of-turn compaction. - this._onCarriedCompactionReported(); - // Preserve the parent turn's own model/context tokens: the compaction call's tokens describe - // the summarization request, not the conversation, so they must not replace what is shown. - const base = lastParentUsageTurnId === turnId ? lastParentUsage : undefined; - const usage: UsageInfo = { - ...base, - model: base?.model ?? this._lastSeenModelId, + const enriched: UsageInfo = { + ...usage, _meta: { - ...(base?._meta ?? {}), - copilotUsage: { ...copilotUsage, totalNanoAiu: scopedTotal }, + ...(usage._meta ?? {}), + ...(copilotUsage ? { copilotUsage } : {}), + ...(attribution ? { contextAttribution: attribution } : {}), }, }; - lastParentUsage = usage; + lastParentUsage = enriched; lastParentUsageTurnId = turnId; this._emitAction({ type: ActionType.ChatUsage, turnId, - usage, + usage: enriched, }); })); + // Compaction (manual `/compact` or automatic) runs its own summarization model call, which the + // SDK bills on `session.compaction_complete` rather than as an `assistant.usage` event. + // + // A compaction that runs *during* a turn is that turn's cost, so fold it in like any other + // call. One that runs between turns belongs to no turn: it is reflected in the session total + // only, rather than being carried onto whatever runs next and inflating an unrelated + // response footer by what is often the session's single most expensive call. + this._register(wrapper.onSessionCompactionComplete(async e => { + if (e.agentId || e.data.success === false) { + return; + } + const copilotUsage = readCopilotUsage(e.data.compactionTokensUsed); + // Report the turn's cost before awaiting anything. The terminal `session.idle` + // can arrive while the metrics read is in flight and close the turn, after + // which the reducer drops usage for it — so a compaction whose turn ends + // immediately (e.g. one followed by a failing model call) would never be + // persisted if this waited. + const emitParentUsage = (): string | undefined => { + const turnId = this._turnId; + const parentCopilotUsage = this._parentCopilotUsageMeta(); + if (!turnId || !parentCopilotUsage) { + return undefined; + } + // Preserve the parent turn's own model/context tokens: the compaction call's tokens describe + // the summarization request, not the conversation, so they must not replace what is shown. + const base = lastParentUsageTurnId === turnId ? lastParentUsage : undefined; + const usage: UsageInfo = { + ...base, + model: base?.model ?? this._lastSeenModelId, + _meta: { + ...(base?._meta ?? {}), + copilotUsage: parentCopilotUsage, + }, + }; + lastParentUsage = usage; + lastParentUsageTurnId = turnId; + this._emitAction({ + type: ActionType.ChatUsage, + turnId, + usage, + }); + return turnId; + }; + + const turn = this._currentTurn; + if (turn && copilotUsage) { + turn.copilotNanoAiu += copilotUsage.totalNanoAiu; + emitParentUsage(); + } + // Then pick up the session-wide total, which also covers a compaction billed + // while no turn was active, and re-emit so the widget reflects it. + const turnIdBeforeRefresh = this._turnId; + if (await this._refreshSessionUsageMetrics() && turnIdBeforeRefresh === this._turnId) { + emitParentUsage(); + } + })); + this._register(wrapper.onReasoningDelta(e => { this._logService.trace(`[Copilot:${sessionId}] Reasoning delta: ${e.data.deltaContent.length} chars`); this._resumeSubagentForEvent(e); @@ -4693,6 +4981,13 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onSessionModelChange(e => { this._logService.trace(`[Copilot:${sessionId}] Model changed: ${e.data.previousModel ?? '(none)'} -> ${e.data.newModel}`); + if (!e.agentId) { + this._promptCacheRefreshGeneration++; + if (e.data.previousModel !== e.data.newModel) { + this._setPromptCacheState(undefined); + } + void this._refreshSessionUsageMetrics(); + } })); this._register(wrapper.onManagedSettingsResolved(e => { @@ -5012,10 +5307,13 @@ function countUnifiedDiffLines(diff: string): { added: number; removed: number } } /** - * Reads the SDK's internal `copilotUsage` billing payload. It is marked `asInternal` in the SDK - * schema, so it is absent from the generated event types (`AssistantUsageData`, + * Reads the SDK's internal `copilotUsage` billing payload, carried on both the `assistant.usage` + * event and `session.compaction_complete`'s `compactionTokensUsed`. It is marked `asInternal` in + * the SDK schema, so it is absent from the generated types (`AssistantUsageData`, * `CompactionCompleteCompactionTokensUsed`) even though it is present at runtime — hence the - * dynamic read. Returns `undefined` when the payload carries no usable nano-AIU total. + * dynamic read. This is the source for per-turn and per-subagent cost, accumulated synchronously + * as each event arrives; only the session-wide total comes from the SDK's usage metrics. + * Returns `undefined` when the payload carries no usable nano-AIU total. */ function readCopilotUsage(raw: unknown): { totalNanoAiu: number } & Record | undefined { if (!raw || typeof raw !== 'object') { diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts b/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts index 5f08a309877..74553a7fa00 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts @@ -7,7 +7,7 @@ import { Schemas } from '../../../../base/common/network.js'; import { basename } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import type { IAgentSessionProjectInfo } from '../../common/agentService.js'; -import type { IAgentHostGitService } from '../../common/agentHostGitService.js'; +import { tryResolvePrimaryWorktreeRoot, type IAgentHostGitService } from '../../common/agentHostGitService.js'; export interface ICopilotSessionContext { readonly cwd?: string; @@ -25,10 +25,7 @@ export async function resolveGitProject(workingDirectory: URI | undefined, gitSe return undefined; } - const uri = (await gitService.getWorktreeRoots(workingDirectory))[0] ?? repositoryRoot; - if (!uri) { - return undefined; - } + const uri = await tryResolvePrimaryWorktreeRoot(gitService, repositoryRoot) ?? repositoryRoot; return { uri, displayName: basename(uri.fsPath) || uri.toString() }; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts index 76aa44634c7..1d56eedb343 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotNonPtyShellTerminals.ts @@ -23,8 +23,8 @@ interface INonPtyShellStream { readonly uri: string; readonly title: string; created: boolean; - /** The last cumulative snapshot written to the channel. */ - lastEmitted: string; + lastSnapshot: string; + sourceTruncated: boolean; finalized: boolean; } @@ -44,6 +44,38 @@ function parseCompletedShell(text: string | undefined): TerminalCommandResult | }; } +const enum StitchConstants { + /** Minimum characters of overlap required to treat a rewritten snapshot as a rolling tail. */ + MinimumOverlapLength = 8 +} + +const partialOutputTruncationMarker = /\n?$/; + +function getTruncatedOutputPrefix(output: string): string | undefined { + const match = partialOutputTruncationMarker.exec(output); + return match ? output.slice(0, match.index) : undefined; +} + +/** + * Finds where `next` overlaps the end of `previous` when the runtime rewrote + * its cumulative snapshot as a rolling tail. + */ +function findStitchOverlap(previous: string, next: string): number | undefined { + const probe = next.slice(0, StitchConstants.MinimumOverlapLength); + if (probe.length < StitchConstants.MinimumOverlapLength) { + return undefined; + } + let index = previous.indexOf(probe); + while (index !== -1) { + const overlapLength = previous.length - index; + if (overlapLength <= next.length && next.startsWith(previous.slice(index))) { + return overlapLength; + } + index = previous.indexOf(probe, index + 1); + } + return undefined; +} + export interface INonPtyShellToolCompletion { readonly uri: string; readonly result?: TerminalCommandResult; @@ -56,10 +88,7 @@ export interface INonPtyShellToolCompletion { * via `tool.execution_partial_result` as throttled cumulative snapshots that * may be rewritten once output is truncated (a trailing truncation marker * under the emit cap, a rolling tail past the large-output threshold); this - * class emits only the unseen suffix as `terminal/data` while the snapshot - * grows in place, and resets the channel when the snapshot was rewritten, so - * subscribed clients receive live plain-text output (`isPty: false` — no VT - * parsing needed). + * class preserves the streamed transcript across those lossy rewrites. * * Created once per session and disposed with it, matching the pty-backed * `ShellManager` lifecycle. @@ -95,7 +124,8 @@ export class NonPtyShellTerminalStreams extends Disposable { this._streams.set(toolCallId, { uri: buildNonPtyShellTerminalUri(this._sessionUri, toolCallId), title, - lastEmitted: '', + lastSnapshot: '', + sourceTruncated: false, finalized: false, created: false, }); @@ -111,19 +141,39 @@ export class NonPtyShellTerminalStreams extends Disposable { if (created) { this._createTerminal(toolCallId, stream); } - if (stream.finalized || cumulativeOutput === stream.lastEmitted) { + if (stream.finalized || cumulativeOutput === stream.lastSnapshot) { return { uri: stream.uri, created }; } - if (cumulativeOutput.startsWith(stream.lastEmitted)) { - this._terminalManager.appendOutputTerminalData(stream.uri, cumulativeOutput.slice(stream.lastEmitted.length)); + const truncatedPrefix = getTruncatedOutputPrefix(cumulativeOutput); + if (truncatedPrefix !== undefined) { + if (!stream.sourceTruncated) { + if (cumulativeOutput.startsWith(stream.lastSnapshot)) { + this._terminalManager.appendOutputTerminalData(stream.uri, cumulativeOutput.slice(stream.lastSnapshot.length)); + } else { + const overlap = findStitchOverlap(stream.lastSnapshot, cumulativeOutput); + this._terminalManager.appendOutputTerminalData(stream.uri, overlap === undefined ? cumulativeOutput.slice(truncatedPrefix.length) : cumulativeOutput.slice(overlap)); + } + stream.sourceTruncated = true; + } + } else if (cumulativeOutput.startsWith(stream.lastSnapshot)) { + this._terminalManager.appendOutputTerminalData(stream.uri, cumulativeOutput.slice(stream.lastSnapshot.length)); } else { - // The snapshot no longer extends what we emitted — the runtime - // rewrote it after truncation (marker under the emit cap, rolling - // tail past the large-output threshold). Start the channel over. - this._terminalManager.resetOutputTerminal(stream.uri); - this._terminalManager.appendOutputTerminalData(stream.uri, cumulativeOutput); + const previousSnapshot = getTruncatedOutputPrefix(stream.lastSnapshot) ?? stream.lastSnapshot; + const overlap = findStitchOverlap(previousSnapshot, cumulativeOutput); + if (overlap !== undefined) { + const unseen = cumulativeOutput.slice(overlap); + if (unseen) { + this._terminalManager.appendOutputTerminalData(stream.uri, unseen); + } + } else if (stream.sourceTruncated || cumulativeOutput.length < stream.lastSnapshot.length) { + this._terminalManager.appendOutputTerminalData(stream.uri, cumulativeOutput); + stream.sourceTruncated = true; + } else { + this._terminalManager.resetOutputTerminal(stream.uri); + this._terminalManager.appendOutputTerminalData(stream.uri, cumulativeOutput); + } } - stream.lastEmitted = cumulativeOutput; + stream.lastSnapshot = cumulativeOutput; return { uri: stream.uri, created }; } @@ -145,11 +195,20 @@ export class NonPtyShellTerminalStreams extends Disposable { } return { uri: stream.uri, shouldRetire: false }; } - if (!stream.created) { + const created = !stream.created; + if (created) { this._createTerminal(toolCallId, stream); } - if (result.preview !== undefined) { - this.append(toolCallId, result.preview); + if (!stream.finalized && result.preview !== undefined) { + if (created) { + this.append(toolCallId, result.preview); + } else if (!result.truncated) { + if (stream.sourceTruncated || !result.preview.startsWith(stream.lastSnapshot)) { + this._replaceOutput(stream, result.preview); + } else { + this.append(toolCallId, result.preview); + } + } } if (result.exitCode !== undefined) { this._finalize(stream, result.exitCode); @@ -184,6 +243,15 @@ export class NonPtyShellTerminalStreams extends Disposable { this._terminalManager.finalizeOutputTerminal(stream.uri, exitCode); } + private _replaceOutput(stream: INonPtyShellStream, output: string): void { + this._terminalManager.resetOutputTerminal(stream.uri); + if (output) { + this._terminalManager.appendOutputTerminalData(stream.uri, output); + } + stream.lastSnapshot = output; + stream.sourceTruncated = false; + } + private _createTerminal(toolCallId: string, stream: INonPtyShellStream): void { const claim: TerminalSessionClaim = { kind: TerminalClaimKind.Session, diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 442640397bf..ecaeed7ffdb 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -175,7 +175,7 @@ export interface ICopilotResumeSessionLaunchPlan extends ICopilotSessionLaunchBa export type CopilotSessionLaunchPlan = ICopilotCreateSessionLaunchPlan | ICopilotResumeSessionLaunchPlan; -function isReasoningEffort(value: unknown): value is ReasoningEffort { +export function isCopilotReasoningEffort(value: unknown): value is ReasoningEffort { return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); } @@ -238,11 +238,11 @@ function isCustomAgentNotFoundError(err: unknown): boolean { * caller/operator is responsible for choosing a level the model supports. */ export function getCopilotReasoningEffort(model: ModelSelection | undefined, effortOverride?: string): SessionConfig['reasoningEffort'] { - if (isReasoningEffort(effortOverride)) { + if (isCopilotReasoningEffort(effortOverride)) { return effortOverride; } const thinkingLevel = model?.config?.[ThinkingLevelConfigKey]; - return isReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; + return isCopilotReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; } /** @@ -255,7 +255,7 @@ export function resolveCopilotReasoningEffort(model: ModelSelection | undefined, // '' is the schema's unset marker, so an unset override reads as `undefined`. const override = rawOverride ? rawOverride : undefined; if (override !== undefined) { - if (isReasoningEffort(override)) { + if (isCopilotReasoningEffort(override)) { logService.info(`[Copilot:${sessionId}] Applying reasoning-effort override '${override}'`); } else { logService.warn(`[Copilot:${sessionId}] Ignoring invalid reasoning-effort override '${override}'; expected one of [${ReasoningEfforts.join(', ')}]`); @@ -296,7 +296,7 @@ export function getCopilotContextTier(model: ModelSelection | undefined, longCon * no BYOK models, or when enumeration fails; `startProxy` is invoked only once * at least one model is present. * - * Each vendor maps to one `type: 'openai'` / `wireApi: 'completions'` provider + * Each vendor maps to one `type: 'openai'` / `wireApi: 'responses'` provider * whose `baseUrl` points at the proxy and authenticates with the session-scoped * `Bearer .`; each model is surfaced under the * provider-qualified selection id `vendor/id`, matching what the renderer's @@ -352,7 +352,7 @@ export async function resolveByokSessionConfig( const providers: NamedProviderConfig[] = [...new Set(byokModels.map(m => m.vendor))].map(vendor => ({ name: vendor, type: 'openai', - wireApi: 'completions', + wireApi: 'responses', baseUrl: handle.providerBaseUrl(vendor), bearerToken: `${handle.nonce}.${sessionId}`, })); diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts index 44701c3dc5d..edece0b24e9 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts @@ -43,6 +43,11 @@ export class CopilotSessionWrapper extends Disposable { return this._onMessage ??= this._sdkEvent('assistant.message'); } + private _onToolCallDelta: Event> | undefined; + get onToolCallDelta(): Event> { + return this._onToolCallDelta ??= this._sdkEvent('assistant.tool_call_delta'); + } + private _onToolStart: Event> | undefined; get onToolStart(): Event> { return this._onToolStart ??= this._sdkEvent('tool.execution_start'); diff --git a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts index 6cb9dfc6d0a..56653c579f0 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts @@ -55,6 +55,13 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste messageText: localize('agentHost.copilot.systemNotification.instructionDiscovered', "Instruction discovered: {0}", kind.description ?? kind.sourcePath), startsTurn: false, }; + case 'unclassified': + // External-host notifications that do not match a runtime-owned kind. + // Use the cleaned content and wake the agent when idle. + return { + messageText: content, + startsTurn: true, + }; default: softAssertNever(kind); return undefined; diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index 4f76a9a1091..ccd83b108ec 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -11,8 +11,10 @@ import { hash } from '../../../../base/common/hash.js'; import { localize } from '../../../../nls.js'; import type { IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; +import { parsePartialToolInput } from '../../common/partialToolInput.js'; import { StringOrMarkdown } from '../../common/state/protocol/state.js'; import { basename } from '../../../../base/common/resources.js'; +import { getStreamingCreateMessage, getStreamingInsertMessage, getStreamingPatchMessage, getStreamingReplaceMessage, streamingToolTextLineCount, type ToolPathResolver } from '../../common/streamingToolCallDisplay.js'; import { getServerToolDisplay } from '../shared/serverToolGroups.js'; // ============================================================================= @@ -104,6 +106,24 @@ interface ICopilotFileToolArgs { path: string; } +interface ICopilotEditToolArgs extends ICopilotFileToolArgs { + old_str?: string; + new_str?: string; +} + +interface ICopilotCreateToolArgs extends ICopilotFileToolArgs { + file_text?: string; +} + +interface ICopilotInsertToolArgs extends ICopilotFileToolArgs { + insert_line?: number; + new_str?: string; +} + +interface ICopilotStrReplaceEditorToolArgs extends ICopilotEditToolArgs, ICopilotCreateToolArgs, ICopilotInsertToolArgs { + command?: string; +} + /** * Parameters for the `view` tool. The Copilot CLI accepts an optional * `view_range: [startLine, endLine]` (1-based, inclusive). `endLine` may be @@ -521,6 +541,12 @@ function md(value: string): StringOrMarkdown { return { markdown: value }; } +const identityPathResolver: ToolPathResolver = path => path; + +export function parseCopilotStreamingToolInput(raw: string): unknown { + return parsePartialToolInput(raw) ?? raw; +} + export function getToolDisplayName(toolName: string): string { const serverDisplay = getServerToolDisplay(toolName, undefined)?.displayName; if (serverDisplay !== undefined) { @@ -584,7 +610,7 @@ export function getToolDisplayName(toolName: string): string { } } -export function getInvocationMessage(toolName: string, displayName: string, parameters: Record | undefined): StringOrMarkdown { +export function getInvocationMessage(toolName: string, displayName: string, parameters: Record | undefined, resolvePath: ToolPathResolver = identityPathResolver): StringOrMarkdown { const serverDisplay = getServerToolDisplay(toolName, parameters)?.invocationMessage; if (serverDisplay !== undefined) { return serverDisplay; @@ -615,8 +641,8 @@ export function getInvocationMessage(toolName: string, displayName: string, para switch (toolName) { case CopilotToolName.View: { const args = parameters as ICopilotViewToolArgs | undefined; - if (args?.path) { - const link = formatPathAsMarkdownLink(args.path); + if (typeof args?.path === 'string' && args.path) { + const link = formatPathAsMarkdownLink(resolvePath(args.path)); const range = formatViewRange(args.view_range); if (range) { if (range.endLine === -1) { @@ -631,20 +657,43 @@ export function getInvocationMessage(toolName: string, displayName: string, para } return localize('toolInvoke.view', "Reading file"); } - case CopilotToolName.Edit: { + case CopilotToolName.Edit: + case CopilotToolName.StrReplace: { const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.path) { - return md(localize('toolInvoke.editFile', "Editing {0}", formatPathAsMarkdownLink(args.path))); + if (typeof args?.path === 'string' && args.path) { + return md(localize('toolInvoke.editFile', "Editing {0}", formatPathAsMarkdownLink(resolvePath(args.path)))); } return localize('toolInvoke.edit', "Editing file"); } + case CopilotToolName.Insert: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (typeof args?.path === 'string' && args.path) { + return md(localize('toolInvoke.insertFile', "Inserting text in {0}", formatPathAsMarkdownLink(resolvePath(args.path)))); + } + return localize('toolInvoke.insert', "Inserting text"); + } case CopilotToolName.Create: { const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.path) { - return md(localize('toolInvoke.createFile', "Creating {0}", formatPathAsMarkdownLink(args.path))); + if (typeof args?.path === 'string' && args.path) { + return md(localize('toolInvoke.createFile', "Creating {0}", formatPathAsMarkdownLink(resolvePath(args.path)))); } return localize('toolInvoke.create', "Creating file"); } + case CopilotToolName.StrReplaceEditor: { + const command = (parameters as ICopilotStrReplaceEditorToolArgs | undefined)?.command; + switch (command) { + case 'view': + return getInvocationMessage(CopilotToolName.View, displayName, parameters, resolvePath); + case 'create': + return getInvocationMessage(CopilotToolName.Create, displayName, parameters, resolvePath); + case 'insert': + return getInvocationMessage(CopilotToolName.Insert, displayName, parameters, resolvePath); + case 'edit': + case 'str_replace': + default: + return getInvocationMessage(CopilotToolName.Edit, displayName, parameters, resolvePath); + } + } case CopilotToolName.Grep: { const args = parameters as ICopilotGrepToolArgs | undefined; if (args?.pattern) { @@ -668,7 +717,7 @@ export function getInvocationMessage(toolName: string, displayName: string, para } case CopilotToolName.ApplyPatch: case CopilotToolName.GitApplyPatch: { - const files = getEditFilePaths(parameters); + const files = getEditFilePaths(parameters).map(resolvePath); if (files.length === 1) { return md(localize('toolInvoke.patchFile', "Editing {0}", formatPathAsMarkdownLink(files[0]))); } @@ -704,7 +753,55 @@ export function getInvocationMessage(toolName: string, displayName: string, para } } -export function getPastTenseMessage(toolName: string, displayName: string, parameters: Record | undefined, success: boolean, resultText?: string): StringOrMarkdown { +/** + * Returns the progressively refined message shown while Copilot generates tool input. + */ +export function getStreamingInvocationMessage(toolName: string, displayName: string, parameters: unknown, resolvePath: ToolPathResolver = identityPathResolver): StringOrMarkdown { + const objectParameters = parameters !== null && typeof parameters === 'object' && !Array.isArray(parameters) + ? parameters as Record + : undefined; + switch (toolName) { + case CopilotToolName.Edit: + case CopilotToolName.StrReplace: { + const args = objectParameters as ICopilotEditToolArgs | undefined; + return getStreamingReplaceMessage(args?.path, streamingToolTextLineCount(args?.old_str), streamingToolTextLineCount(args?.new_str), resolvePath); + } + case CopilotToolName.Create: { + const args = objectParameters as ICopilotCreateToolArgs | undefined; + return getStreamingCreateMessage(args?.path, streamingToolTextLineCount(args?.file_text), resolvePath); + } + case CopilotToolName.Insert: { + const args = objectParameters as ICopilotInsertToolArgs | undefined; + return getStreamingInsertMessage(args?.path, streamingToolTextLineCount(args?.new_str), resolvePath); + } + case CopilotToolName.StrReplaceEditor: { + const args = objectParameters as ICopilotStrReplaceEditorToolArgs | undefined; + const command = args?.command; + switch (command) { + case 'view': + return getInvocationMessage(CopilotToolName.View, displayName, objectParameters, resolvePath); + case 'create': + return getStreamingCreateMessage(args?.path, streamingToolTextLineCount(args?.file_text), resolvePath); + case 'insert': + return getStreamingInsertMessage(args?.path, streamingToolTextLineCount(args?.new_str), resolvePath); + case 'edit': + case 'str_replace': + default: + return getStreamingReplaceMessage(args?.path, streamingToolTextLineCount(args?.old_str), streamingToolTextLineCount(args?.new_str), resolvePath); + } + } + case CopilotToolName.ApplyPatch: + case CopilotToolName.GitApplyPatch: { + const args = objectParameters as ICopilotApplyPatchToolArgs | undefined; + const patch = typeof parameters === 'string' ? parameters : args?.input ?? args?.patch; + return getStreamingPatchMessage(getEditFilePaths(parameters), streamingToolTextLineCount(patch), resolvePath); + } + default: + return getInvocationMessage(toolName, displayName, objectParameters, resolvePath); + } +} + +export function getPastTenseMessage(toolName: string, displayName: string, parameters: Record | undefined, success: boolean, resultText?: string, resolvePath: ToolPathResolver = identityPathResolver): StringOrMarkdown { if (!success) { return localize('toolComplete.failed', "\"{0}\" failed", displayName); } @@ -739,8 +836,8 @@ export function getPastTenseMessage(toolName: string, displayName: string, param switch (toolName) { case CopilotToolName.View: { const args = parameters as ICopilotViewToolArgs | undefined; - if (args?.path) { - const link = formatPathAsMarkdownLink(args.path); + if (typeof args?.path === 'string' && args.path) { + const link = formatPathAsMarkdownLink(resolvePath(args.path)); const range = formatViewRange(args.view_range); if (range) { if (range.endLine === -1) { @@ -755,20 +852,43 @@ export function getPastTenseMessage(toolName: string, displayName: string, param } return localize('toolComplete.view', "Read file"); } - case CopilotToolName.Edit: { + case CopilotToolName.Edit: + case CopilotToolName.StrReplace: { const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.path) { - return md(localize('toolComplete.editFile', "Edited {0}", formatPathAsMarkdownLink(args.path))); + if (typeof args?.path === 'string' && args.path) { + return md(localize('toolComplete.editFile', "Edited {0}", formatPathAsMarkdownLink(resolvePath(args.path)))); } return localize('toolComplete.edit', "Edited file"); } + case CopilotToolName.Insert: { + const args = parameters as ICopilotFileToolArgs | undefined; + if (typeof args?.path === 'string' && args.path) { + return md(localize('toolComplete.insertFile', "Inserted text in {0}", formatPathAsMarkdownLink(resolvePath(args.path)))); + } + return localize('toolComplete.insert', "Inserted text"); + } case CopilotToolName.Create: { const args = parameters as ICopilotFileToolArgs | undefined; - if (args?.path) { - return md(localize('toolComplete.createFile', "Created {0}", formatPathAsMarkdownLink(args.path))); + if (typeof args?.path === 'string' && args.path) { + return md(localize('toolComplete.createFile', "Created {0}", formatPathAsMarkdownLink(resolvePath(args.path)))); } return localize('toolComplete.create', "Created file"); } + case CopilotToolName.StrReplaceEditor: { + const command = (parameters as ICopilotStrReplaceEditorToolArgs | undefined)?.command; + switch (command) { + case 'view': + return getPastTenseMessage(CopilotToolName.View, displayName, parameters, success, resultText, resolvePath); + case 'create': + return getPastTenseMessage(CopilotToolName.Create, displayName, parameters, success, resultText, resolvePath); + case 'insert': + return getPastTenseMessage(CopilotToolName.Insert, displayName, parameters, success, resultText, resolvePath); + case 'edit': + case 'str_replace': + default: + return getPastTenseMessage(CopilotToolName.Edit, displayName, parameters, success, resultText, resolvePath); + } + } case CopilotToolName.Grep: { const args = parameters as ICopilotGrepToolArgs | undefined; if (args?.pattern) { @@ -792,7 +912,7 @@ export function getPastTenseMessage(toolName: string, displayName: string, param } case CopilotToolName.ApplyPatch: case CopilotToolName.GitApplyPatch: { - const files = getEditFilePaths(parameters); + const files = getEditFilePaths(parameters).map(resolvePath); if (files.length === 1) { return md(localize('toolComplete.patchFile', "Edited {0}", formatPathAsMarkdownLink(files[0]))); } diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index ce3c1c3a84e..8656e5077df 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -5,7 +5,8 @@ import type { AssistantMessageToolRequest, Attachment, SessionEvent, ToolExecutionCompleteContent, ToolExecutionCompleteData } from '@github/copilot-sdk'; import { decodeBase64 } from '../../../../base/common/buffer.js'; -import { basename } from '../../../../base/common/path.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { basename, isAbsolute, join } from '../../../../base/common/path.js'; import { isString } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; @@ -31,6 +32,12 @@ function tryStringify(value: unknown): string | undefined { } } +function resolveToolDisplayPath(path: string, workingDirectory: URI | undefined): string { + return isAbsolute(path) || !workingDirectory || workingDirectory.scheme !== Schemas.file + ? path + : join(workingDirectory.fsPath, path); +} + /** * Returns true if the event is a SDK-injected `user.message` that should not * be shown to the user (e.g. skill-content injection). @@ -210,7 +217,7 @@ function makeToolStartInfo(toolName: string, rawArguments: unknown, parentToolCa return { toolName, displayName, - invocationMessage: getInvocationMessage(toolName, displayName, parameters), + invocationMessage: getInvocationMessage(toolName, displayName, parameters, path => resolveToolDisplayPath(path, workingDirectory)), toolInput: getToolInputString(toolName, parameters, toolArgs), toolKind, language: toolKind === 'terminal' ? getShellLanguage(toolName) : undefined, @@ -590,7 +597,7 @@ export async function mapSessionEvents( // No active turn to attach this completion to. continue; } - const completedPart = makeCompletedToolCallPart(d, info, sessionUriStr, providerId, rawSessionId, storedEdits, subagentInfoByToolCallId.get(d.toolCallId)); + const completedPart = makeCompletedToolCallPart(d, info, sessionUriStr, providerId, rawSessionId, storedEdits, subagentInfoByToolCallId.get(d.toolCallId), workingDirectory); builder.responseParts.push(completedPart); // When a parent tool call that spawned a subagent completes, // flush the subagent's accumulated turn. @@ -681,6 +688,7 @@ export async function mapSessionEvents( rawSessionId, storedEdits, subagentInfoByToolCallId.get(request.toolCallId), + workingDirectory, )); } } @@ -783,6 +791,7 @@ function makeCompletedToolCallPart( rawSessionId: string, storedEdits: Map | undefined, subagent: ISubagentInfo | undefined, + workingDirectory: URI | undefined, ): ResponsePart { const toolOutput = d.error?.message ?? d.result?.content; const content: ToolResultContent[] = []; @@ -848,7 +857,7 @@ function makeCompletedToolCallPart( invocationMessage: info.invocationMessage, toolInput: info.toolInput, success: d.success, - pastTenseMessage: getPastTenseMessage(info.toolName, info.displayName, info.parameters, d.success, d.success ? toolOutput : undefined), + pastTenseMessage: getPastTenseMessage(info.toolName, info.displayName, info.parameters, d.success, d.success ? toolOutput : undefined, path => resolveToolDisplayPath(path, workingDirectory)), content: content.length > 0 ? content : undefined, error: d.error, confirmed: ToolCallConfirmationReason.NotNeeded, diff --git a/src/vs/platform/agentHost/node/sessionDataService.ts b/src/vs/platform/agentHost/node/sessionDataService.ts index 6e96324a49a..e4ac6ec491b 100644 --- a/src/vs/platform/agentHost/node/sessionDataService.ts +++ b/src/vs/platform/agentHost/node/sessionDataService.ts @@ -110,7 +110,7 @@ export class SessionDataService implements ISessionDataService { return this._databases.acquire(key); } - async deleteSessionData(session: URI): Promise { + async deleteSessionData(session: URI, workingDirectories?: readonly string[]): Promise { const dir = this.getSessionDataDir(session); // Fire the will-delete event first so subscribers (notably the // checkpoint service) can perform async cleanup that needs the @@ -120,6 +120,7 @@ export class SessionDataService implements ISessionDataService { try { this._onWillDeleteSessionData.fire({ session, + workingDirectories, waitUntil: p => { pending.push(p); }, }); } catch (err) { diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index 84444d9004d..087b16958c4 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -384,6 +384,8 @@ export class SessionPermissionManager extends Disposable { type: ActionType.ChatToolCallReady, turnId, toolCallId: state.toolCallId, + ...(state.contributor ? { contributor: state.contributor } : {}), + ...(state.intention !== undefined ? { intention: state.intention } : {}), invocationMessage: state.invocationMessage, toolInput: state.toolInput, confirmationTitle: state.confirmationTitle, @@ -405,6 +407,8 @@ export class SessionPermissionManager extends Disposable { type: ActionType.ChatToolCallReady, turnId, toolCallId: state.toolCallId, + ...(state.contributor ? { contributor: state.contributor } : {}), + ...(state.intention !== undefined ? { intention: state.intention } : {}), invocationMessage: state.invocationMessage, toolInput: state.toolInput, confirmed: ToolCallConfirmationReason.NotNeeded, diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index 4928bb35440..22bdef3015a 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'fs/promises'; -import { SequencerByKey } from '../../../../base/common/async.js'; +import { RunOnceScheduler, SequencerByKey } from '../../../../base/common/async.js'; import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; @@ -14,7 +14,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentSessionProjectInfo } from '../../common/agentService.js'; -import { getBranchCompletions, IAgentHostGitService, IDefaultBranch, IWorktreeFileProgress, META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; +import { getBranchCompletions, IAgentHostGitService, IDefaultBranch, IWorktreeFileProgress, META_DIFF_BASE_BRANCH, tryResolvePrimaryWorktreeRoot } from '../../common/agentHostGitService.js'; import { ISchemaProperty, schemaProperty } from '../../common/agentHostSchema.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; @@ -47,6 +47,7 @@ export class SessionWorkingDirectoryMissingError extends Error { /** Default upper bound on branch names returned for the branch picker. */ const BRANCH_COMPLETION_LIMIT = 25; +const WORKTREE_PROGRESS_DEBOUNCE_MS = 40; interface ICreatedWorktree { readonly repositoryRoot: URI; @@ -135,20 +136,37 @@ export function buildWorktreeProgressText(phase: WorktreeCreationPhase, percent? /** * Adapts the raw file counts the git service reports into progress labels for - * a phase. Samples can arrive several times a second, so this rounds down to - * whole percent and drops anything that doesn't advance it — a consumer never - * sees two updates that read the same, and never more than 101 per phase. + * a phase. Rounds down to whole percentages, drops non-advancing samples, and + * debounces updates to avoid overwhelming consumers, flushing the latest + * percentage when the operation completes. */ -function createPercentProgressReporter(phase: WorktreeCreationPhase, onProgress: (activity: string) => void): (progress: IWorktreeFileProgress) => void { +async function withPercentProgress( + phase: WorktreeCreationPhase, + onProgress: ((activity: string) => void) | undefined, + operation: (onProgress: ((progress: IWorktreeFileProgress) => void) | undefined) => Promise, +): Promise { + if (!onProgress) { + return operation(undefined); + } + let lastPercent = -1; - return ({ filesDone, filesTotal }) => { - const percent = Math.min(100, Math.floor(filesDone * 100 / filesTotal)); - if (percent <= lastPercent) { - return; + const scheduler = new RunOnceScheduler(() => onProgress(buildWorktreeProgressText(phase, lastPercent)), WORKTREE_PROGRESS_DEBOUNCE_MS); + try { + return await operation(({ filesDone, filesTotal }) => { + const percent = Math.min(100, Math.floor(filesDone * 100 / filesTotal)); + if (percent <= lastPercent) { + return; + } + lastPercent = percent; + scheduler.schedule(); + }); + } finally { + const shouldFlush = scheduler.isScheduled(); + scheduler.dispose(); + if (shouldFlush) { + onProgress(buildWorktreeProgressText(phase, lastPercent)); } - lastPercent = percent; - onProgress(buildWorktreeProgressText(phase, percent)); - }; + } } /** @@ -500,11 +518,12 @@ export class WorktreeIsolation extends Disposable { onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.Starting)); - const repositoryRoot = await this._gitService.getRepositoryRoot(workingDirectory); - if (!repositoryRoot) { + const checkoutRoot = await this._gitService.getRepositoryRoot(workingDirectory); + if (!checkoutRoot) { return workingDirectory; } + const repositoryRoot = await this._resolvePrimaryWorktreeRoot(checkoutRoot, checkoutRoot); const worktreesRoot = getWorktreesRoot(repositoryRoot); // Prefix (e.g. the user's `git.branchPrefix`) the client forwards for // worktree-isolated sessions. Prepended ahead of the built-in `agents/` @@ -538,7 +557,8 @@ export class WorktreeIsolation extends Disposable { onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.CheckingOut)); const worktreeBranchTrack = config[SessionConfigKey.WorktreeBranchTrack] === true; - await this._gitService.addWorktree(repositoryRoot, worktree, branchName, baseBranch, worktreeBranchTrack, onProgress && createPercentProgressReporter(WorktreeCreationPhase.CheckingOut, onProgress)); + await withPercentProgress(WorktreeCreationPhase.CheckingOut, onProgress, progress => + this._gitService.addWorktree(repositoryRoot, worktree, branchName, baseBranch, worktreeBranchTrack, progress)); return { branchName, worktree, baseBranch }; }); const worktreeIncludeFiles = Array.isArray(config[SessionConfigKey.WorktreeIncludeFiles]) @@ -548,7 +568,8 @@ export class WorktreeIsolation extends Disposable { if (worktreeIncludeFiles?.length) { try { onProgress?.(buildWorktreeProgressText(WorktreeCreationPhase.CopyingIncludeFiles)); - await this._gitService.copyWorktreeIncludeFiles(repositoryRoot, worktree, worktreeIncludeFiles, onProgress && createPercentProgressReporter(WorktreeCreationPhase.CopyingIncludeFiles, onProgress)); + await withPercentProgress(WorktreeCreationPhase.CopyingIncludeFiles, onProgress, progress => + this._gitService.copyWorktreeIncludeFiles(checkoutRoot, worktree, worktreeIncludeFiles, progress)); } catch (error) { this._logService.warn(`[${this._logLabel}:${sessionId}] Failed to copy worktree include files: ${errorMessage(error)}`); } @@ -798,6 +819,15 @@ export class WorktreeIsolation extends Disposable { return meta?.repositoryRoot ? projectFromRepositoryRoot(meta.repositoryRoot) : undefined; } + private async _resolvePrimaryWorktreeRoot(checkoutRoot: URI, fallbackRoot: URI): Promise { + try { + return await tryResolvePrimaryWorktreeRoot(this._gitService, checkoutRoot) ?? fallbackRoot; + } catch (error) { + this._logService.warn(`[${this._logLabel}] Failed to resolve primary worktree for '${checkoutRoot.fsPath}': ${errorMessage(error)}`); + return fallbackRoot; + } + } + /** * Synchronous companion to {@link resolveWorktreeProject} for the * materialize-event path: the repository project for a worktree this agent @@ -852,6 +882,10 @@ export class WorktreeIsolation extends Disposable { } } + /** + * Reads worktree metadata and migrates repository roots written before linked checkouts were canonicalized. + * It probes an existing worktree when available and otherwise falls back to the persisted root for archived sessions. + */ private async _readWorktreeMetadata(sessionUri: URI): Promise<{ branchName: string; worktreePath?: URI; repositoryRoot?: URI } | undefined> { const ref = await this._sessionDataService.tryOpenDatabase(sessionUri); if (!ref) { @@ -867,7 +901,19 @@ export class WorktreeIsolation extends Disposable { return undefined; } const worktreePath = worktreePathRaw ? URI.parse(worktreePathRaw) : undefined; - const repositoryRoot = repositoryRootRaw ? URI.parse(repositoryRootRaw) : undefined; + let repositoryRoot = repositoryRootRaw ? URI.parse(repositoryRootRaw) : undefined; + if (repositoryRoot) { + const checkoutRoot = worktreePath && await fileExists(worktreePath.fsPath) ? worktreePath : repositoryRoot; + const primaryRoot = await this._resolvePrimaryWorktreeRoot(checkoutRoot, repositoryRoot); + if (primaryRoot.toString() !== repositoryRoot.toString()) { + repositoryRoot = primaryRoot; + try { + await ref.object.setMetadata(WORKTREE_META_REPOSITORY_ROOT, primaryRoot.toString()); + } catch (error) { + this._logService.warn(`[${this._logLabel}] Failed to normalize worktree repository metadata for '${sessionUri.toString()}': ${errorMessage(error)}`); + } + } + } return { branchName, worktreePath, repositoryRoot }; } finally { ref.dispose(); diff --git a/src/vs/platform/agentHost/test/common/cloudSandboxAgentHost.test.ts b/src/vs/platform/agentHost/test/common/cloudSandboxAgentHost.test.ts index 40de4927fe0..a955162032c 100644 --- a/src/vs/platform/agentHost/test/common/cloudSandboxAgentHost.test.ts +++ b/src/vs/platform/agentHost/test/common/cloudSandboxAgentHost.test.ts @@ -8,7 +8,10 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { buildWpsUrl, cloudSandboxAddress, + CloudSandboxAuthenticationRequiredError, + CloudSandboxRequestError, ICloudSandboxClientToken, + isRetryableCloudSandboxError, } from '../../common/cloudSandboxAgentHost.js'; suite('cloudSandbox url/address helpers', () => { @@ -50,3 +53,28 @@ suite('cloudSandbox url/address helpers', () => { ); }); }); + +suite('isRetryableCloudSandboxError', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('only statuses that can change on their own are retryable', () => { + const statuses = [200, 400, 401, 403, 404, 408, 409, 410, 422, 429, 500, 502, 503]; + const retryable = statuses.filter(status => isRetryableCloudSandboxError(new CloudSandboxRequestError(status, `HTTP ${status}`))); + + assert.deepStrictEqual(retryable, [200, 408, 429, 500, 502, 503]); + }); + + test('errors without a status are retryable, including a not-yet-available sign-in', () => { + assert.deepStrictEqual( + { + transport: isRetryableCloudSandboxError(new Error('socket hang up')), + statusless: isRetryableCloudSandboxError(new CloudSandboxRequestError(undefined, 'no status')), + // Raised before any request goes out, and covers the auth provider not having + // registered yet — so callers bound it with their own ceiling rather than here. + authNotReady: isRetryableCloudSandboxError(new CloudSandboxAuthenticationRequiredError()), + }, + { transport: true, statusless: true, authNotReady: true }, + ); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/copilotHome.test.ts b/src/vs/platform/agentHost/test/common/copilotHome.test.ts index 87d9b3f9804..2b310c16fc2 100644 --- a/src/vs/platform/agentHost/test/common/copilotHome.test.ts +++ b/src/vs/platform/agentHost/test/common/copilotHome.test.ts @@ -16,9 +16,11 @@ suite('copilotHome', () => { assert.deepStrictEqual([ getCopilotHomePath('user-home', {}), getCopilotHomePath('user-home', { COPILOT_HOME: 'custom-copilot' }), + getCopilotHomePath('user-home', { XDG_STATE_HOME: 'legacy-state-home' }), ], [ join('user-home', '.copilot'), 'custom-copilot', + join('user-home', '.copilot'), ]); }); diff --git a/src/vs/platform/agentHost/test/common/githubIssueReferences.test.ts b/src/vs/platform/agentHost/test/common/githubIssueReferences.test.ts new file mode 100644 index 00000000000..2022520cafb --- /dev/null +++ b/src/vs/platform/agentHost/test/common/githubIssueReferences.test.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { parseGitHubIssueReferences } from '../../common/githubIssueReferences.js'; + +suite('parseGitHubIssueReferences', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('detects issue URLs and owner/repo shorthand, ignores everything else', () => { + const text = [ + 'Fix https://github.com/microsoft/vscode/issues/123 first.', + 'Related: microsoft/vscode#456 and octo-org/my.repo#7.', + 'Also see https://www.github.com/microsoft/vscode/issues/123#issuecomment-99 (dupe).', + 'Not an issue: #789, https://github.com/microsoft/vscode/pull/321, https://gitlab.com/o/r/issues/5.', + ].join('\n'); + + assert.deepStrictEqual(parseGitHubIssueReferences(text), [ + { owner: 'microsoft', repo: 'vscode', number: 123 }, + { owner: 'microsoft', repo: 'vscode', number: 456 }, + { owner: 'octo-org', repo: 'my.repo', number: 7 }, + ]); + }); + + test('returns nothing for text without references', () => { + assert.deepStrictEqual(parseGitHubIssueReferences('Please refactor the parser and add tests.'), []); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/partialToolInput.test.ts b/src/vs/platform/agentHost/test/common/partialToolInput.test.ts new file mode 100644 index 00000000000..f2a93cc6cb4 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/partialToolInput.test.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { parsePartialToolInput, parsePartialToolInputForDisplay } from '../../common/partialToolInput.js'; + +suite('PartialToolInput', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns useful object fields from incomplete JSON', () => { + assert.deepStrictEqual(parsePartialToolInputForDisplay('{"command":"npm test","description":"Run'), { + command: 'npm test', + description: 'Run', + }); + }); + + test('returns undefined when no object fields are parseable', () => { + assert.deepStrictEqual([ + parsePartialToolInputForDisplay('{"comm'), + parsePartialToolInputForDisplay('custom input'), + parsePartialToolInputForDisplay('["item"]'), + ], [ + undefined, + undefined, + undefined, + ]); + }); + + test('returns a snapshot instead of the cached object', () => { + const raw = '{"command":"npm test"}'; + const first = parsePartialToolInputForDisplay(raw); + assert.ok(first); + first['command'] = 'modified'; + + assert.deepStrictEqual(parsePartialToolInputForDisplay(raw), { + command: 'npm test', + }); + }); + + test('bounds generic display parsing', () => { + const raw = `{"command":"npm test","content":"${'x'.repeat(70 * 1024)}"}`; + const parsed = parsePartialToolInputForDisplay(raw); + assert.deepStrictEqual({ + command: parsed?.['command'], + contentIsTruncated: typeof parsed?.['content'] === 'string' && parsed['content'].length < raw.length, + }, { + command: 'npm test', + contentIsTruncated: true, + }); + }); + + test('supports uncapped provider parsing', () => { + const content = 'x'.repeat(70 * 1024); + assert.strictEqual(parsePartialToolInput(`{"content":"${content}"}`)?.['content'], content); + }); +}); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 07bcdd3033c..6ae23e4db13 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -29,8 +29,8 @@ import { CustomizationType, MessageAttachmentKind, MessageKind, PendingMessageKi import type { IClientTransport, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { TelemetryLevel } from '../../../telemetry/common/telemetry.js'; -import { AgentHostCodexAgentEnabledSettingId, AgentHostCopilotMultiRootEnabledSettingId, AgentHostClaudeMultiRootEnabledSettingId, AgentHostSystemProxyEnabledSettingId } from '../../common/agentService.js'; -import { AgentHostAutoReplyEnabledConfigKey, AgentHostCodexEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostClaudeMultiRootEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AUTO_REPLY_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, EDIT_TELEMETRY_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, type AgentHostTerminalAutoApproveRules } from '../../common/agentHostSchema.js'; +import { AgentHostCodexAgentEnabledSettingId, AgentHostCodexMultiRootEnabledSettingId, AgentHostCopilotMultiRootEnabledSettingId, AgentHostClaudeMultiRootEnabledSettingId, AgentHostSystemProxyEnabledSettingId } from '../../common/agentService.js'; +import { AgentHostAutoReplyEnabledConfigKey, AgentHostCodexEnabledConfigKey, AgentHostCodexMultiRootEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostClaudeMultiRootEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AUTO_REPLY_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, EDIT_TELEMETRY_ENABLED_SETTING_ID, telemetryLevelToAgentHostConfigValue, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, type AgentHostTerminalAutoApproveRules } from '../../common/agentHostSchema.js'; import type { Implementation } from '../../common/state/protocol/common/commands.js'; import { agentsWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js'; @@ -966,6 +966,23 @@ suite('RemoteAgentHostProtocolClient', () => { assert.deepStrictEqual(getRootConfig(updatedMultiRootEnabled), { [AgentHostClaudeMultiRootEnabledConfigKey]: false }); }); + test('forwards Codex multi-root enablement on connect and when the setting changes', async () => { + const configurationService = new TestConfigurationService({ [AgentHostCodexMultiRootEnabledSettingId]: true }); + const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); + + await connectClient(client, transport); + + const multiRootEnabled = findRootConfigNotification(transport.sentMessages, AgentHostCodexMultiRootEnabledConfigKey); + assert.deepStrictEqual(getRootConfig(multiRootEnabled), { [AgentHostCodexMultiRootEnabledConfigKey]: true }); + + transport.sentMessages.length = 0; + await configurationService.setUserConfiguration(AgentHostCodexMultiRootEnabledSettingId, false); + fireConfigurationChange(configurationService, AgentHostCodexMultiRootEnabledSettingId); + + const updatedMultiRootEnabled = findLastRootConfigNotification(transport.sentMessages, AgentHostCodexMultiRootEnabledConfigKey); + assert.deepStrictEqual(getRootConfig(updatedMultiRootEnabled), { [AgentHostCodexMultiRootEnabledConfigKey]: false }); + }); + test('forwards auto-reply on connect and when the setting changes', async () => { const configurationService = new TestConfigurationService({ [AUTO_REPLY_SETTING_ID]: true }); const { client, transport } = createClient(disposables.add(new TestProtocolTransport()), createPermissionService(), undefined, new NullLogService(), configurationService); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index d7c6899ba62..d011b0259ee 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -375,6 +375,7 @@ class TestGitStateService extends Disposable implements IAgentHostGitStateServic } async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } async attachSessionGitHubPullRequest(_sessionKey: string): Promise { } + async attachSessionGitHubIssues(_sessionKey: string, _text: string): Promise { } } class TestFileMonitorService extends Disposable implements IAgentHostFileMonitorService { diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts index 2d44169b9bd..e342d99d93a 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts @@ -75,6 +75,7 @@ class TestGitStateService implements IAgentHostGitStateService { async setSessionGitHubState(_sessionKey: string, _state: ISessionGitHubState): Promise { } async attachSessionGitHubPullRequest(_sessionKey: string): Promise { } + async attachSessionGitHubIssues(_sessionKey: string, _text: string): Promise { } } suite('AgentHostChangesetOperationService', () => { diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index 7530e629396..a96ecb425c1 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -24,7 +24,6 @@ import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { createNoopGitService, createNullSessionDataService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; -import { META_CHECKPOINT_WORKING_DIR } from '../../node/agentHostCheckpointService.js'; /** * Builds a test subscription service backed by a mutable set of subscribed @@ -1095,9 +1094,6 @@ suite.skip('AgentHostChangesetService', () => { const sessionStr = sessionUri.toString(); setupSession('file:///wd'); - const db = new TestSessionDatabase(); - await db.setMetadata(META_CHECKPOINT_WORKING_DIR, 'file:///wd'); - const expectedDiffs = [ { after: { uri: 'file:///wd/a.ts', content: { uri: 'file:///wd/a.ts' } }, diff: { added: 4, removed: 1 } }, ]; @@ -1110,7 +1106,7 @@ suite.skip('AgentHostChangesetService', () => { const svc = disposables.add(new AgentHostChangesetService( stateManager, new NullLogService(), - createSessionDataService(db), + createSessionDataService(new TestSessionDatabase()), gitService, makeCheckpointService({ 'orig': { parent: 'ref-orig-parent', current: 'ref-orig' }, @@ -1199,15 +1195,12 @@ suite.skip('AgentHostChangesetService', () => { const sessionStr = sessionUri.toString(); setupSession('file:///wd'); - const db = new TestSessionDatabase(); - await db.setMetadata(META_CHECKPOINT_WORKING_DIR, 'file:///wd'); - const gitService = createNoopGitService(); gitService.computeFileDiffsBetweenRefs = async () => undefined; const svc = disposables.add(new AgentHostChangesetService( stateManager, new NullLogService(), - createSessionDataService(db), + createSessionDataService(new TestSessionDatabase()), gitService, makeCheckpointService({ 'orig': { parent: 'p', current: 'ref-orig' }, diff --git a/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts b/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts index a941b5c90df..60d5aa846a3 100644 --- a/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts @@ -47,30 +47,52 @@ suite('agentHostClientByokLmChannel', () => { return createAgentHostClientByokLmConnection(channel); } - test('round-trips a chat request to the handler and back', async () => { + test('round-trips a Responses request to the handler and back', async () => { let seen: IByokLmChatRequest | undefined; const connection = bridge(handlerOf(async (request) => { seen = request; - return { content: 'pong', toolCalls: [{ id: 'c1', name: 'noop', argumentsJson: '{}' }] }; + return { + responseId: 'resp_1', + output: [ + { type: 'reasoning', id: 'rs_1', summary: ['thinking'], encryptedContent: 'opaque' }, + { type: 'message', content: [{ type: 'text', text: 'pong' }] }, + { type: 'function_call', callId: 'c1', name: 'noop', argumentsJson: '{}' }, + ], + }; })); - const request: IByokLmChatRequest = { vendor: 'acme', modelId: 'm', messages: [{ role: 'user', content: 'ping' }] }; + const request: IByokLmChatRequest = { + vendor: 'acme', + modelId: 'm', + previousResponseId: 'resp_0', + input: [ + { type: 'reasoning', id: 'rs_0', summary: ['previous'], encryptedContent: 'previous-opaque' }, + { type: 'message', role: 'user', content: [{ type: 'text', text: 'ping' }] }, + ], + }; const result = await connection.chat(request); assert.deepStrictEqual(seen, request); - assert.deepStrictEqual(result, { content: 'pong', toolCalls: [{ id: 'c1', name: 'noop', argumentsJson: '{}' }] }); + assert.deepStrictEqual(result, { + responseId: 'resp_1', + output: [ + { type: 'reasoning', id: 'rs_1', summary: ['thinking'], encryptedContent: 'opaque' }, + { type: 'message', content: [{ type: 'text', text: 'pong' }] }, + { type: 'function_call', callId: 'c1', name: 'noop', argumentsJson: '{}' }, + ], + }); }); test('forwards a bridge error result unchanged', async () => { - const connection = bridge(handlerOf(async () => ({ content: '', error: 'no model' }))); - const result = await connection.chat({ vendor: 'v', modelId: 'm', messages: [] }); + const connection = bridge(handlerOf(async () => ({ output: [], error: 'no model' }))); + const result = await connection.chat({ vendor: 'v', modelId: 'm', input: [] }); assert.strictEqual(result.error, 'no model'); }); test('pushes the current model snapshot on subscribe and re-pushes on change', async () => { const onDidChange = store.add(new Emitter()); let models: IByokLmModelInfo[] = [{ vendor: 'acme', id: 'claude', name: 'Acme Claude', maxContextWindowTokens: 128000 }]; - const connection = bridge(handlerOf(async () => ({ content: '' }), async () => models, onDidChange.event)); + const connection = bridge(handlerOf(async () => ({ output: [] }), async () => models, onDidChange.event)); const pushed: IByokLmModelInfo[][] = []; const sub = connection.onDidChangeModels(snapshot => pushed.push(snapshot)); @@ -91,7 +113,7 @@ suite('agentHostClientByokLmChannel', () => { test('coalesces a burst of changes so the final snapshot reflects the latest models', async () => { const onDidChange = store.add(new Emitter()); let models: IByokLmModelInfo[] = [{ vendor: 'acme', id: 'v1' }]; - const connection = bridge(handlerOf(async () => ({ content: '' }), async () => models, onDidChange.event)); + const connection = bridge(handlerOf(async () => ({ output: [] }), async () => models, onDidChange.event)); const pushed: IByokLmModelInfo[][] = []; const sub = connection.onDidChangeModels(snapshot => pushed.push(snapshot)); @@ -111,12 +133,12 @@ suite('agentHostClientByokLmChannel', () => { }); test('rejects unknown channel commands', async () => { - const server = new AgentHostClientByokLmChannel(handlerOf(async () => ({ content: '' })), new NullLogService()); + const server = new AgentHostClientByokLmChannel(handlerOf(async () => ({ output: [] })), new NullLogService()); await assert.rejects(() => server.call(null, 'frobnicate'), /Unknown command/); }); test('exposes only the models event', () => { - const server = new AgentHostClientByokLmChannel(handlerOf(async () => ({ content: '' })), new NullLogService()); + const server = new AgentHostClientByokLmChannel(handlerOf(async () => ({ output: [] })), new NullLogService()); assert.throws(() => server.listen(null, 'anything'), /No event/); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index 2eef875b681..7680c020366 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -11,7 +11,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import type { IAgentService } from '../../common/agentService.js'; import { readSessionGitHubState, readSessionGitState, withSessionGitState, SessionStatus, type ISessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; -import { META_GIT_STATE } from '../../common/agentHostGitStateService.js'; +import { META_GIT_STATE, META_GITHUB_STATE } from '../../common/agentHostGitStateService.js'; import { AgentHostGitStateService } from '../../node/agentHostGitStateService.js'; import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -209,6 +209,33 @@ suite('AgentHostGitStateService', () => { }); }); + test('accumulates the GitHub issues referenced across user messages', async () => { + const h = createHarness(); + seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); + + await h.service.attachSessionGitHubIssues(SESSION, 'Fix https://github.com/microsoft/vscode/issues/1 please'); + await h.service.attachSessionGitHubIssues(SESSION, 'Also microsoft/vscode#1 and octo/repo#2, but not #3'); + await h.service.attachSessionGitHubIssues(SESSION, 'Nothing to see here'); + + assert.deepStrictEqual({ + github: readSessionGitHubState(h.stateManager.getSessionState(SESSION)?._meta), + persistedGitHub: await h.db.getMetadata(META_GITHUB_STATE), + }, { + github: { + issueUrls: [ + 'https://github.com/microsoft/vscode/issues/1', + 'https://github.com/octo/repo/issues/2', + ] + }, + persistedGitHub: JSON.stringify({ + issueUrls: [ + 'https://github.com/microsoft/vscode/issues/1', + 'https://github.com/octo/repo/issues/2', + ] + }), + }); + }); + test('swallows git errors and fires no events', async () => { const h = createHarness(); seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts index 43853936954..4aaf86da92f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationProvider.test.ts @@ -21,6 +21,7 @@ const nullGitStateService = new class implements IAgentHostGitStateService { async getSessionGitHubState(): Promise { return undefined; } async setSessionGitHubState(): Promise { } async attachSessionGitHubPullRequest(): Promise { } + async attachSessionGitHubIssues(): Promise { } }; const githubBranchWithUncommittedChanges: ISessionGitState = { diff --git a/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts index 06d116f5db9..e67c7335492 100644 --- a/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts @@ -164,7 +164,7 @@ suite.skip('AgentHostReviewService (real git)', () => { await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); const beforeDispose = chainLength(); - await svc!.disposeSessionData(sessionUri.toString()); + await svc!.disposeSessionData(sessionUri.toString(), [wd().toString()]); const afterDispose = chainLength(); assert.deepStrictEqual({ beforeDispose, afterDispose }, { beforeDispose: 1, afterDispose: 0 }); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index deec21e2b0e..5c0370a51f2 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -17,7 +17,7 @@ import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/tel import { AgentSession, IAgent } from '../../common/agentService.js'; import { SessionInputRequestKind } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; -import { buildDefaultChatUri, MessageKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, MessageKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; @@ -138,7 +138,12 @@ suite('AgentSideEffects — tool call telemetry', () => { const data = e.data as Record; return { eventName: e.eventName, - data: { ...data, invocationTimeMs: typeof data.invocationTimeMs === 'number' && data.invocationTimeMs >= 0 }, + data: { + ...data, + invocationTimeMs: data.invocationTimeMs === undefined + ? undefined + : typeof data.invocationTimeMs === 'number' && data.invocationTimeMs >= 0, + }, }; }); } @@ -211,6 +216,13 @@ suite('AgentSideEffects — tool call telemetry', () => { startTurn('turn-1'); toolStart('turn-1', 'tc-1', 'bash'); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-1', + invocationMessage: 'run', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); toolComplete('turn-1', 'tc-1', { success: true, pastTenseMessage: 'ran' }); assert.deepStrictEqual(toolEvents(), [{ @@ -243,7 +255,7 @@ suite('AgentSideEffects — tool call telemetry', () => { toolExtensionId: undefined, toolSourceKind: 'mcp', provider: 'mock', - invocationTimeMs: true, + invocationTimeMs: undefined, }, }]); }); @@ -253,6 +265,13 @@ suite('AgentSideEffects — tool call telemetry', () => { startTurn('turn-1'); toolStart('turn-1', 'tc-client', 'run_tests', { kind: ToolCallContributorKind.Client, clientId: 'client-1' }); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-client', + invocationMessage: 'run tests', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); toolComplete('turn-1', 'tc-client', { success: true, pastTenseMessage: 'ran tests' }); assert.deepStrictEqual(toolEvents(), [{ @@ -269,6 +288,83 @@ suite('AgentSideEffects — tool call telemetry', () => { }]); }); + test('only accepts contributor refinements that preserve execution ownership', async () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-mcp-ready', 'lookup'); + agent.fireProgress({ + kind: 'pending_confirmation', + chat: URI.parse(defaultChatUri), + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-mcp-ready', + toolName: 'lookup', + displayName: 'Lookup', + contributor: { kind: ToolCallContributorKind.MCP, customizationId: 'mcp-1' }, + invocationMessage: 'Looking up metadata', + toolInput: '{}', + }, + }); + toolStart('turn-1', 'tc-late-client', 'run_tests'); + agent.fireProgress({ + kind: 'pending_confirmation', + chat: URI.parse(defaultChatUri), + state: { + status: ToolCallStatus.PendingConfirmation, + toolCallId: 'tc-late-client', + toolName: 'run_tests', + displayName: 'Run Tests', + contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-1' }, + invocationMessage: 'Running tests', + toolInput: '{}', + }, + }); + await timeout(0); + toolComplete('turn-1', 'tc-mcp-ready', { success: true, pastTenseMessage: 'looked up metadata' }); + toolComplete('turn-1', 'tc-late-client', { success: true, pastTenseMessage: 'ran tests' }); + + assert.deepStrictEqual(toolEvents().map(event => event.data.toolSourceKind), ['mcp', 'agentHost']); + }); + + test('excludes pending confirmation time from invocation timing', async () => { + await runWithFakedTimers({}, async () => { + setupSession(); + startTurn('turn-1'); + toolStart('turn-1', 'tc-confirm-timing', 'write'); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-confirm-timing', + invocationMessage: 'Write file', + confirmationTitle: 'Write file', + }); + await timeout(10_000); + + const confirmed: ChatAction = { + type: ActionType.ChatToolCallConfirmed, + turnId: 'turn-1', + toolCallId: 'tc-confirm-timing', + approved: true, + confirmed: ToolCallConfirmationReason.UserAction, + }; + stateManager.dispatchClientAction(defaultChatUri, confirmed, { clientId: 'test', clientSeq: 2 }); + sideEffects.handleAction(defaultChatUri, confirmed); + await timeout(25); + toolComplete('turn-1', 'tc-confirm-timing', { success: true, pastTenseMessage: 'wrote file' }); + }); + + const event = telemetry.events.find(event => event.eventName === 'languageModelToolInvoked'); + const invocationTimeMs = (event?.data as { invocationTimeMs?: number } | undefined)?.invocationTimeMs; + assert.deepStrictEqual({ + isMeasured: typeof invocationTimeMs === 'number', + excludesConfirmationDelay: typeof invocationTimeMs === 'number' && invocationTimeMs < 1000, + }, { + isMeasured: true, + excludesConfirmationDelay: true, + }); + }); + test('emits error for a failure without a cancellation code', () => { setupSession(); startTurn('turn-1'); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 2cd41bc5e3d..06a6951907c 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -36,10 +36,9 @@ import { MockAgent, ScriptedMockAgent } from './mockAgent.js'; import { mapSessionEventsToHistoryRecords } from './historyRecordFixtures.js'; import { type ISessionEvent } from './copilotTestEvents.js'; import { createNoopGitService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; -import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; -import { WorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; +import { WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT } from '../../node/shared/worktreeIsolation.js'; import { AhpErrorCodes, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../../common/state/sessionProtocol.js'; import type { INetworkDiagnosticsService } from '../../node/networkDiagnosticsService.js'; @@ -631,7 +630,6 @@ suite('AgentService (node dispatcher)', () => { sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), - NULL_CHECKPOINT_SERVICE, undefined, undefined, undefined, @@ -658,7 +656,7 @@ suite('AgentService (node dispatcher)', () => { const localDisposables = new DisposableStore(); try { const rootConfigResource = joinPath(tempDir, 'agent-host-config.json'); - const svc = localDisposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), NULL_CHECKPOINT_SERVICE, rootConfigResource)); + const svc = localDisposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService(), rootConfigResource)); const agent = new MockAgent('copilot'); localDisposables.add(toDisposable(() => agent.dispose())); svc.registerProvider(agent); @@ -1201,6 +1199,28 @@ suite('AgentService (node dispatcher)', () => { // Should not throw await service.disposeSession(unknownSession); }); + + test('deletes session data before removing the worktree', async () => { + // Subscribers of the will-delete event drop this session's git refs, + // which requires resolving the repository from the working directory. + // For a worktree-isolated session that directory *is* the worktree, so + // removing it first would strand the refs in the main repository. + const order: string[] = []; + const sessionDataService: ISessionDataService = { + ...nullSessionDataService, + deleteSessionData: async () => { order.push('deleteSessionData'); }, + }; + const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.registerProvider(copilotAgent); + const session = await svc.createSession({ provider: 'copilot' }); + svc.setWorktreeIsolation({ + removeCreatedWorktree: async () => { order.push('removeCreatedWorktree'); }, + } as unknown as WorktreeIsolation); + + await svc.disposeSession(session); + + assert.deepStrictEqual(order, ['deleteSessionData', 'removeCreatedWorktree']); + }); }); // ---- listSessions / listModels -------------------------------------- @@ -1299,6 +1319,44 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(sessions[0]._meta, { workspaceless: true }); }); + test('listSessions normalizes a persisted linked-worktree project without probing a missing session worktree', async () => { + const db = disposables.add(new TestSessionDatabase()); + const primaryRoot = URI.file('/workspace/vscode'); + const linkedCheckout = URI.file('/workspace/vscode.worktrees/parent'); + const sessionWorktree = URI.file('/workspace/vscode.worktrees/parent.worktrees/child'); + await db.setMetadata(WORKTREE_META_REPOSITORY_ROOT, linkedCheckout.toString()); + const sessionId = 'test-session-linked-worktree'; + const sessionUri = AgentSession.uri('copilot', sessionId); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + agent.sessionMetadataOverrides = { + workingDirectories: [sessionWorktree], + project: { uri: linkedCheckout, displayName: 'parent' }, + }; + (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); + const gitService = createNoopGitService(); + const resolvedFrom: URI[] = []; + gitService.getWorktreeRoots = async workingDirectory => { + resolvedFrom.push(workingDirectory); + return [primaryRoot, linkedCheckout, sessionWorktree]; + }; + const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, gitService)); + svc.registerProvider(agent); + + const sessions = await svc.listSessions(); + await svc.listSessions(); + + assert.deepStrictEqual({ + resolvedFrom: resolvedFrom.map(uri => uri.toString()), + project: sessions[0].project && { uri: sessions[0].project.uri.toString(), displayName: sessions[0].project.displayName }, + persistedRepositoryRoot: await db.getMetadata(WORKTREE_META_REPOSITORY_ROOT), + }, { + resolvedFrom: [linkedCheckout.toString()], + project: { uri: primaryRoot.toString(), displayName: 'vscode' }, + persistedRepositoryRoot: primaryRoot.toString(), + }); + }); + test('listSessions uses SDK title when no custom title exists', async () => { service.registerProvider(copilotAgent); copilotAgent.sessionMetadataOverrides = { summary: 'Auto-generated Title' }; diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 13937f3897c..73eb9efc613 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -106,6 +106,7 @@ function createTestSideEffects( telemetryService: ITelemetryService = NullTelemetryService, changesets: IAgentHostChangesetService = new FakeChangesetService(), terminalManager: IAgentHostTerminalManager = disposables.add(new TestAgentHostTerminalManager()), + checkpointService: IAgentHostCheckpointService = NULL_CHECKPOINT_SERVICE, ): AgentSideEffects { const logService = new NullLogService(); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); @@ -113,7 +114,7 @@ function createTestSideEffects( [ILogService, logService], [IAgentConfigurationService, configService], [IAgentHostChangesetService, changesets], - [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], + [IAgentHostCheckpointService, checkpointService], [ITelemetryService, telemetryService], [IAgentHostTerminalManager, terminalManager], [ISessionDataService, options.sessionDataService], @@ -5642,6 +5643,35 @@ suite('AgentSideEffects', () => { assert.deepStrictEqual(changesets.turnCompletes, [{ session: sessionUri.toString(), turnId: 'turn-1' }]); }); + test('turn complete passes the resolved working directories to the checkpoint capture', async () => { + const workingDirectory = URI.file('/wd').toString(); + setupSession(workingDirectory); + startTurn('turn-1'); + + const captures: { turnId: string; workingDirectories: readonly string[] | undefined }[] = []; + const checkpoints: IAgentHostCheckpointService = { + ...NULL_CHECKPOINT_SERVICE, + captureTurnCheckpoint: async (_session, turnId, workingDirectories) => { + captures.push({ turnId, workingDirectories: workingDirectories?.map(w => w.toString()) }); + }, + }; + const localSideEffects = createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createNullSessionDataService(), + onTurnComplete: () => { }, + }, undefined, NullTelemetryService, new FakeChangesetService(), undefined, checkpoints); + disposables.add(localSideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), + action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, + }); + await Promise.resolve(); + + assert.deepStrictEqual(captures, [{ turnId: 'turn-1', workingDirectories: [workingDirectory] }]); + }); + test('ChatTruncated fires onSessionTruncated once', () => { setupSession(); diff --git a/src/vs/platform/agentHost/test/node/byokLmBridgeRegistry.test.ts b/src/vs/platform/agentHost/test/node/byokLmBridgeRegistry.test.ts index 60b1edbc1b9..f461b01d40e 100644 --- a/src/vs/platform/agentHost/test/node/byokLmBridgeRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/byokLmBridgeRegistry.test.ts @@ -29,7 +29,7 @@ suite('ByokLmBridgeRegistry', () => { const emitter = store.add(new Emitter()); return { connection: { - chat: async (): Promise => ({ content: '' }), + chat: async (): Promise => ({ output: [] }), onDidChangeModels: emitter.event, }, push: models => emitter.fire(models), @@ -161,4 +161,53 @@ suite('ByokLmBridgeRegistry', () => { reg.dispose(); }); + + test('compares reasoning effort metadata structurally', () => { + const registry = new ByokLmBridgeRegistry(); + const conn = pushable(); + const reg = store.add(registry.register('client-a', conn.connection)); + conn.push([{ + vendor: 'acme', + id: 'reasoning', + supportedReasoningEfforts: ['low', 'high'], + defaultReasoningEffort: 'low', + }]); + + let changes = 0; + store.add(registry.onDidChangeModels(() => { changes++; })); + + conn.push([{ + vendor: 'acme', + id: 'reasoning', + supportedReasoningEfforts: ['low', 'high'], + defaultReasoningEffort: 'low', + }]); + conn.push([{ + vendor: 'acme', + id: 'reasoning', + supportedReasoningEfforts: ['low', 'high'], + defaultReasoningEffort: 'high', + }]); + conn.push([{ + vendor: 'acme', + id: 'reasoning', + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'high', + }]); + + assert.deepStrictEqual({ + changes, + models: registry.getModels(), + }, { + changes: 2, + models: [{ + vendor: 'acme', + id: 'reasoning', + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'high', + }], + }); + + reg.dispose(); + }); }); diff --git a/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts b/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts index a98902a349b..7f036f71355 100644 --- a/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts +++ b/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts @@ -13,7 +13,7 @@ import { ByokLmProxyService, type IByokLmProxyHandle } from '../../node/copilot/ /** * Exercises the inference path end-to-end without the Copilot SDK runtime: - * the test plays the runtime's role by POSTing OpenAI Chat Completions + * the test plays the runtime's role by POSTing OpenAI Responses * requests at the loopback proxy, and plays the renderer's role with a fake * {@link IByokLmChatRequest} -> {@link IByokLmChatResult} bridge function. The * only contract under test is the OpenAI wire format in, the bridge DTO out, @@ -53,8 +53,8 @@ suite('ByokLmProxyService', () => { } } - function chatUrl(handle: IByokLmProxyHandle, vendor: string): string { - return `${handle.providerBaseUrl(vendor)}/chat/completions`; + function responsesUrl(handle: IByokLmProxyHandle, vendor: string): string { + return `${handle.providerBaseUrl(vendor)}/responses`; } function authHeaders(handle: IByokLmProxyHandle): Record { @@ -63,7 +63,7 @@ suite('ByokLmProxyService', () => { test('serves the unauthenticated health check', async () => { await withProxy( - async () => ({ content: 'unused' }), + async () => ({ output: [] }), async (handle) => { const response = await fetch(`${handle.baseUrl}/`); assert.strictEqual(response.status, 200); @@ -74,12 +74,12 @@ suite('ByokLmProxyService', () => { test('rejects requests without a valid bearer token', async () => { await withProxy( - async () => ({ content: 'unused' }), + async () => ({ output: [] }), async (handle) => { - const response = await fetch(chatUrl(handle, 'acme'), { + const response = await fetch(responsesUrl(handle, 'acme'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: 'm', messages: [] }), + body: JSON.stringify({ model: 'm', input: [] }), }); assert.strictEqual(response.status, 401); }, @@ -88,12 +88,12 @@ suite('ByokLmProxyService', () => { test('rejects a nonce-only bearer token (no session id)', async () => { await withProxy( - async () => ({ content: 'unused' }), + async () => ({ output: [] }), async (handle) => { - const response = await fetch(chatUrl(handle, 'acme'), { + const response = await fetch(responsesUrl(handle, 'acme'), { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${handle.nonce}` }, - body: JSON.stringify({ model: 'm', messages: [] }), + body: JSON.stringify({ model: 'm', input: [] }), }); assert.strictEqual(response.status, 401); }, @@ -102,9 +102,9 @@ suite('ByokLmProxyService', () => { test('returns 404 for an authenticated but unknown route', async () => { await withProxy( - async () => ({ content: 'unused' }), + async () => ({ output: [] }), async (handle) => { - const response = await fetch(`${handle.baseUrl}/v/acme/responses`, { + const response = await fetch(`${handle.baseUrl}/v/acme/chat/completions`, { method: 'POST', headers: authHeaders(handle), body: '{}', @@ -114,29 +114,28 @@ suite('ByokLmProxyService', () => { ); }); - test('forwards a chat request to the bridge and streams an SSE completion', async () => { + test('forwards a Responses request to the bridge and returns JSON by default', async () => { let captured: IByokLmChatRequest | undefined; await withProxy( async (request) => { captured = request; - return { content: 'hello from byok' }; + return { output: [{ type: 'message', content: [{ type: 'text', text: 'hello from byok' }] }] }; }, async (handle) => { - const response = await fetch(chatUrl(handle, 'acme'), { + const response = await fetch(responsesUrl(handle, 'acme'), { method: 'POST', headers: authHeaders(handle), - body: JSON.stringify({ model: 'claude', messages: [{ role: 'user', content: 'hi' }] }), + body: JSON.stringify({ model: 'claude', input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hi' }] }] }), }); assert.strictEqual(response.status, 200); - assert.strictEqual(response.headers.get('content-type'), 'text/event-stream'); - const text = await response.text(); - assert.ok(text.includes('hello from byok'), `expected content in SSE: ${text}`); - assert.ok(text.trimEnd().endsWith('data: [DONE]')); + assert.strictEqual(response.headers.get('content-type'), 'application/json'); + const body = await response.json() as { output: Array<{ content: Array<{ text: string }> }> }; + assert.strictEqual(body.output[0].content[0].text, 'hello from byok'); }, ); assert.strictEqual(captured?.vendor, 'acme'); assert.strictEqual(captured?.modelId, 'claude'); - assert.deepStrictEqual(captured?.messages, [{ role: 'user', content: 'hi', toolCalls: undefined, toolCallId: undefined }]); + assert.deepStrictEqual(captured?.input, [{ type: 'message', role: 'user', content: [{ type: 'text', text: 'hi' }] }]); }); test('forwards custom tool call history with freeform input', async () => { @@ -144,21 +143,22 @@ suite('ByokLmProxyService', () => { await withProxy( async (request) => { captured = request; - return { content: 'done' }; + return { output: [{ type: 'message', content: [{ type: 'text', text: 'done' }] }] }; }, async (handle) => { - const response = await fetch(chatUrl(handle, 'acme'), { + const response = await fetch(responsesUrl(handle, 'acme'), { method: 'POST', headers: authHeaders(handle), body: JSON.stringify({ model: 'm', - messages: [ + input: [ { - role: 'assistant', - content: '', - tool_calls: [{ id: 'call_1', type: 'custom', custom: { name: 'apply_patch', input: '*** Begin Patch\n*** End Patch' } }], + type: 'custom_tool_call', + call_id: 'call_1', + name: 'apply_patch', + input: '*** Begin Patch\n*** End Patch', }, - { role: 'tool', tool_call_id: 'call_1', content: 'Done!' }, + { type: 'custom_tool_call_output', call_id: 'call_1', output: 'Done!' }, ], }), }); @@ -166,26 +166,26 @@ suite('ByokLmProxyService', () => { await response.text(); }, ); - assert.deepStrictEqual(captured?.messages, [ + assert.deepStrictEqual(captured?.input, [ { - role: 'assistant', - content: '', - toolCalls: [{ id: 'call_1', name: 'apply_patch', argumentsJson: '{"input":"*** Begin Patch\\n*** End Patch"}' }], - toolCallId: undefined, + type: 'custom_tool_call', + callId: 'call_1', + name: 'apply_patch', + input: '*** Begin Patch\n*** End Patch', }, - { role: 'tool', content: 'Done!', toolCalls: undefined, toolCallId: 'call_1' }, + { type: 'custom_tool_call_output', callId: 'call_1', output: 'Done!' }, ]); }); test('decodes a url-encoded vendor path segment', async () => { let captured: IByokLmChatRequest | undefined; await withProxy( - async (request) => { captured = request; return { content: 'ok' }; }, + async (request) => { captured = request; return { output: [{ type: 'message', content: [{ type: 'text', text: 'ok' }] }] }; }, async (handle) => { - const response = await fetch(chatUrl(handle, 'acme corp'), { + const response = await fetch(responsesUrl(handle, 'acme corp'), { method: 'POST', headers: authHeaders(handle), - body: JSON.stringify({ model: 'm', messages: [] }), + body: JSON.stringify({ model: 'm', input: [] }), }); assert.strictEqual(response.status, 200); await response.text(); @@ -196,14 +196,14 @@ suite('ByokLmProxyService', () => { test('rejects a vendor that decodes to a multi-segment path (%2F)', async () => { await withProxy( - async () => ({ content: 'unused' }), + async () => ({ output: [] }), async (handle) => { // `encodeURIComponent('a/b')` → `a%2Fb`, which survives the // pre-decode segment check but decodes back into `a/b`. - const response = await fetch(chatUrl(handle, 'a/b'), { + const response = await fetch(responsesUrl(handle, 'a/b'), { method: 'POST', headers: authHeaders(handle), - body: JSON.stringify({ model: 'm', messages: [] }), + body: JSON.stringify({ model: 'm', input: [] }), }); assert.strictEqual(response.status, 404); }, @@ -212,16 +212,16 @@ suite('ByokLmProxyService', () => { test('streams assistant tool calls as OpenAI tool_call deltas', async () => { await withProxy( - async () => ({ content: '', toolCalls: [{ id: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }] }), + async () => ({ output: [{ type: 'function_call', callId: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }] }), async (handle) => { - const response = await fetch(chatUrl(handle, 'acme'), { + const response = await fetch(responsesUrl(handle, 'acme'), { method: 'POST', headers: authHeaders(handle), - body: JSON.stringify({ model: 'm', messages: [{ role: 'user', content: 'weather?' }] }), + body: JSON.stringify({ model: 'm', input: 'weather?', stream: true }), }); const text = await response.text(); - assert.ok(text.includes('"tool_calls"'), `expected tool_calls in SSE: ${text}`); - assert.ok(text.includes('"finish_reason":"tool_calls"'), `expected tool_calls finish reason: ${text}`); + assert.ok(text.includes('"type":"function_call"'), `expected function_call in SSE: ${text}`); + assert.ok(text.includes('event: response.completed'), `expected completed response: ${text}`); assert.ok(text.includes('getWeather')); }, ); @@ -229,12 +229,12 @@ suite('ByokLmProxyService', () => { test('returns a 502 when the bridge reports an error', async () => { await withProxy( - async () => ({ content: '', error: 'model unavailable' }), + async () => ({ output: [], error: 'model unavailable' }), async (handle) => { - const response = await fetch(chatUrl(handle, 'acme'), { + const response = await fetch(responsesUrl(handle, 'acme'), { method: 'POST', headers: authHeaders(handle), - body: JSON.stringify({ model: 'm', messages: [] }), + body: JSON.stringify({ model: 'm', input: [] }), }); assert.strictEqual(response.status, 502); const body = await response.json() as { error?: { message?: string } }; @@ -247,10 +247,10 @@ suite('ByokLmProxyService', () => { await withProxy( async () => { throw new Error('bridge exploded'); }, async (handle) => { - const response = await fetch(chatUrl(handle, 'acme'), { + const response = await fetch(responsesUrl(handle, 'acme'), { method: 'POST', headers: authHeaders(handle), - body: JSON.stringify({ model: 'm', messages: [] }), + body: JSON.stringify({ model: 'm', input: [] }), }); assert.strictEqual(response.status, 502); const body = await response.json() as { error?: { message?: string } }; @@ -261,9 +261,9 @@ suite('ByokLmProxyService', () => { test('rejects a malformed JSON body with 400', async () => { await withProxy( - async () => ({ content: 'unused' }), + async () => ({ output: [] }), async (handle) => { - const response = await fetch(chatUrl(handle, 'acme'), { + const response = await fetch(responsesUrl(handle, 'acme'), { method: 'POST', headers: authHeaders(handle), body: 'not json', @@ -278,10 +278,10 @@ suite('ByokLmProxyService', () => { const service = new ByokLmProxyService(new NullLogService(), registry); const handle = await service.start(); try { - const response = await fetch(chatUrl(handle, 'acme'), { + const response = await fetch(responsesUrl(handle, 'acme'), { method: 'POST', headers: authHeaders(handle), - body: JSON.stringify({ model: 'm', messages: [] }), + body: JSON.stringify({ model: 'm', input: [] }), }); assert.strictEqual(response.status, 503); } finally { @@ -295,21 +295,21 @@ suite('ByokLmProxyService', () => { const calls: string[] = []; // The serving window (editor): pushes models and answers chat. const regServing = registry.register('editor', servingConnection( - async () => { calls.push('serving'); return { content: 'from serving' }; }, + async () => { calls.push('serving'); return { output: [{ type: 'message', content: [{ type: 'text', text: 'from serving' }] }] }; }, [{ vendor: 'acme', id: 'claude' }], )); // A non-serving window (connected without a BYOK handler): it never pushes // a snapshot, so it must never be picked for routing even though connected. const regNonServing = registry.register('no-handler', { - chat: async () => { calls.push('no-handler'); return { content: 'from non-serving' }; }, + chat: async () => { calls.push('no-handler'); return { output: [{ type: 'message', content: [{ type: 'text', text: 'from non-serving' }] }] }; }, onDidChangeModels: Event.None, }); const service = new ByokLmProxyService(new NullLogService(), registry); const handle = await service.start(); try { - const res = await fetch(chatUrl(handle, 'acme'), { + const res = await fetch(responsesUrl(handle, 'acme'), { method: 'POST', headers: authHeaders(handle), - body: JSON.stringify({ model: 'claude', messages: [] }), + body: JSON.stringify({ model: 'claude', input: [] }), }); assert.deepStrictEqual({ routedToServing: (await res.text()).includes('from serving'), @@ -325,7 +325,7 @@ suite('ByokLmProxyService', () => { test('rebinds with a fresh nonce after every handle is disposed', async () => { const registry = new ByokLmBridgeRegistry(); - const registration = registry.register('client-1', servingConnection(async () => ({ content: 'ok' }))); + const registration = registry.register('client-1', servingConnection(async () => ({ output: [{ type: 'message', content: [{ type: 'text', text: 'ok' }] }] }))); const service = new ByokLmProxyService(new NullLogService(), registry); const first = await service.start(); const firstNonce = first.nonce; diff --git a/src/vs/platform/agentHost/test/node/byokOpenAiTranslation.test.ts b/src/vs/platform/agentHost/test/node/byokOpenAiTranslation.test.ts deleted file mode 100644 index 48ed0781ea1..00000000000 --- a/src/vs/platform/agentHost/test/node/byokOpenAiTranslation.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import type { IByokLmChatResult } from '../../common/agentHostByokLm.js'; -import { - bridgeResultToSseFrames, - openAiRequestToBridge, - OpenAiTranslationError, - type IOpenAiChatRequest, -} from '../../node/copilot/byokOpenAiTranslation.js'; - -suite('byokOpenAiTranslation', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - suite('openAiRequestToBridge', () => { - - test('maps roles, text content, tools and options', () => { - const body: IOpenAiChatRequest = { - model: 'claude-sonnet', - temperature: 0.5, - max_tokens: 256, - messages: [ - { role: 'system', content: 'be helpful' }, - { role: 'user', content: [{ type: 'text', text: 'hi ' }, { type: 'text', text: 'there' }] }, - { - role: 'assistant', - content: '', - tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'getWeather', arguments: '{"city":"NYC"}' } }], - }, - { role: 'tool', tool_call_id: 'call_1', content: 'sunny' }, - ], - tools: [{ type: 'function', function: { name: 'getWeather', description: 'weather', parameters: { type: 'object' } } }], - }; - - const result = openAiRequestToBridge('acme', body); - - assert.deepStrictEqual(result, { - vendor: 'acme', - modelId: 'claude-sonnet', - messages: [ - { role: 'system', content: 'be helpful', toolCalls: undefined, toolCallId: undefined }, - { role: 'user', content: 'hi there', toolCalls: undefined, toolCallId: undefined }, - { role: 'assistant', content: '', toolCalls: [{ id: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }], toolCallId: undefined }, - { role: 'tool', content: 'sunny', toolCalls: undefined, toolCallId: 'call_1' }, - ], - tools: [{ name: 'getWeather', description: 'weather', parametersSchema: { type: 'object' } }], - modelOptions: { temperature: 0.5, max_tokens: 256 }, - }); - }); - - test('throws when model is missing', () => { - assert.throws(() => openAiRequestToBridge('acme', { messages: [] }), OpenAiTranslationError); - }); - - test('throws when an assistant tool call is missing its function name', () => { - assert.throws(() => openAiRequestToBridge('acme', { - model: 'm', - messages: [{ role: 'assistant', content: '', tool_calls: [{ id: 'call_1', type: 'function', function: { arguments: '{}' } }] }], - }), OpenAiTranslationError); - }); - - test('maps a custom assistant tool call with freeform input', () => { - const result = openAiRequestToBridge('acme', { - model: 'm', - messages: [ - { - role: 'assistant', - content: '', - tool_calls: [{ id: 'call_1', type: 'custom', custom: { name: 'apply_patch', input: '*** Begin Patch\n*** End Patch' } }], - }, - ], - }); - - assert.deepStrictEqual(result.messages, [{ - role: 'assistant', - content: '', - toolCalls: [{ id: 'call_1', name: 'apply_patch', argumentsJson: '{"input":"*** Begin Patch\\n*** End Patch"}' }], - toolCallId: undefined, - }]); - }); - - test('throws when a custom assistant tool call is missing its name', () => { - assert.throws(() => openAiRequestToBridge('acme', { - model: 'm', - messages: [{ role: 'assistant', content: '', tool_calls: [{ id: 'call_1', type: 'custom', custom: { input: 'patch' } }] }], - }), OpenAiTranslationError); - }); - - test('treats an omitted tool call type as a function call', () => { - const result = openAiRequestToBridge('acme', { - model: 'm', - messages: [{ role: 'assistant', content: '', tool_calls: [{ id: 'call_1', function: { name: 'getWeather', arguments: '{}' } }] }], - }); - - assert.deepStrictEqual(result.messages[0].toolCalls, [ - { id: 'call_1', name: 'getWeather', argumentsJson: '{}' }, - ]); - }); - - test('omits tools and options when absent', () => { - const result = openAiRequestToBridge('acme', { model: 'm', messages: [{ role: 'user', content: 'hello' }] }); - assert.strictEqual(result.tools, undefined); - assert.strictEqual(result.modelOptions, undefined); - }); - }); - - suite('bridgeResultToSseFrames', () => { - - function parseFrames(frames: string[]): unknown[] { - return frames - .map(frame => frame.replace(/^data: /, '').trim()) - .filter(payload => payload !== '[DONE]') - .map(payload => JSON.parse(payload)); - } - - test('emits role, content and stop frames terminated by [DONE]', () => { - const result: IByokLmChatResult = { content: 'hello world' }; - const frames = bridgeResultToSseFrames(result, 'm'); - - assert.strictEqual(frames[frames.length - 1], 'data: [DONE]\n\n'); - const parsed = parseFrames(frames) as Array<{ choices: Array<{ delta: Record; finish_reason: string | null }> }>; - assert.deepStrictEqual(parsed.map(p => p.choices[0].delta), [ - { role: 'assistant' }, - { content: 'hello world' }, - {}, - ]); - assert.strictEqual(parsed[parsed.length - 1].choices[0].finish_reason, 'stop'); - }); - - test('encodes tool calls and a tool_calls finish reason', () => { - const result: IByokLmChatResult = { - content: '', - toolCalls: [{ id: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }], - }; - const frames = bridgeResultToSseFrames(result, 'm'); - const parsed = parseFrames(frames) as Array<{ choices: Array<{ delta: Record; finish_reason: string | null }> }>; - - const toolDelta = parsed.find(p => p.choices[0].delta.tool_calls !== undefined); - assert.deepStrictEqual(toolDelta?.choices[0].delta.tool_calls, [ - { index: 0, id: 'call_1', type: 'function', function: { name: 'getWeather', arguments: '{"city":"NYC"}' } }, - ]); - assert.strictEqual(parsed[parsed.length - 1].choices[0].finish_reason, 'tool_calls'); - }); - }); -}); diff --git a/src/vs/platform/agentHost/test/node/byokResponsesTranslation.test.ts b/src/vs/platform/agentHost/test/node/byokResponsesTranslation.test.ts new file mode 100644 index 00000000000..df68148e7bb --- /dev/null +++ b/src/vs/platform/agentHost/test/node/byokResponsesTranslation.test.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { IByokLmChatResult } from '../../common/agentHostByokLm.js'; +import { + bridgeResultToResponsesBody, + bridgeResultToResponsesSseFrames, + IResponsesRequest, + responsesRequestToBridge, + ResponsesTranslationError, +} from '../../node/copilot/byokResponsesTranslation.js'; + +suite('byokResponsesTranslation', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('maps ordered Responses input, tools, continuation, reasoning and options', () => { + const body: IResponsesRequest = { + model: 'gpt-5', + instructions: 'be helpful', + previous_response_id: 'resp_previous', + reasoning: { effort: 'high' }, + temperature: 0.5, + top_p: 0.9, + max_output_tokens: 256, + input: [ + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] }, + { type: 'reasoning', id: 'rs_1', summary: [{ type: 'summary_text', text: 'considered it' }], encrypted_content: 'encrypted' }, + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'checking' }] }, + { type: 'function_call', call_id: 'call_1', name: 'getWeather', arguments: '{"city":"NYC"}' }, + { type: 'function_call_output', call_id: 'call_1', output: 'sunny' }, + { type: 'custom_tool_call', call_id: 'call_2', name: 'apply_patch', input: '*** Begin Patch' }, + { type: 'custom_tool_call_output', call_id: 'call_2', output: 'Done!' }, + ], + tools: [ + { type: 'function', name: 'getWeather', description: 'weather', parameters: { type: 'object' } }, + { type: 'custom', name: 'apply_patch', description: 'patch files' }, + ], + }; + + assert.deepStrictEqual(responsesRequestToBridge('acme', body), { + vendor: 'acme', + modelId: 'gpt-5', + instructions: 'be helpful', + input: [ + { type: 'message', role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { type: 'reasoning', id: 'rs_1', summary: ['considered it'], encryptedContent: 'encrypted' }, + { type: 'message', role: 'assistant', content: [{ type: 'text', text: 'checking' }] }, + { type: 'function_call', callId: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }, + { type: 'function_call_output', callId: 'call_1', output: 'sunny' }, + { type: 'custom_tool_call', callId: 'call_2', name: 'apply_patch', input: '*** Begin Patch' }, + { type: 'custom_tool_call_output', callId: 'call_2', output: 'Done!' }, + ], + tools: [ + { type: 'function', name: 'getWeather', description: 'weather', parametersSchema: { type: 'object' } }, + { type: 'custom', name: 'apply_patch', description: 'patch files' }, + ], + previousResponseId: 'resp_previous', + reasoningEffort: 'high', + modelOptions: { temperature: 0.5, top_p: 0.9, max_tokens: 256 }, + }); + }); + + test('maps string input to a user message', () => { + assert.deepStrictEqual(responsesRequestToBridge('acme', { model: 'm', input: 'hello' }).input, [ + { type: 'message', role: 'user', content: [{ type: 'text', text: 'hello' }] }, + ]); + }); + + test('rejects missing models and unsupported input items', () => { + assert.throws(() => responsesRequestToBridge('acme', { input: [] }), ResponsesTranslationError); + assert.throws(() => responsesRequestToBridge('acme', { + model: 'm', + input: [{ type: 'computer_call' }], + }), /Unsupported input\[0\]/); + }); + + test('emits ordered Responses SSE for reasoning, text and tool calls', () => { + const result: IByokLmChatResult = { + responseId: 'resp_provider', + output: [ + { type: 'reasoning', id: 'rs_1', summary: ['first', 'second'], encryptedContent: 'encrypted' }, + { type: 'message', content: [{ type: 'text', text: 'hello' }] }, + { type: 'function_call', callId: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }, + { type: 'custom_tool_call', callId: 'call_2', name: 'apply_patch', input: 'patch' }, + ], + usage: { inputTokens: 10, outputTokens: 5, reasoningTokens: 2 }, + }; + + const events = bridgeResultToResponsesSseFrames(result, 'gpt-5').map(frame => { + const lines = frame.trim().split('\n'); + return { + event: lines[0].slice('event: '.length), + data: JSON.parse(lines[1].slice('data: '.length)) as Record, + }; + }); + const completed = events.at(-1)?.data.response as { id: string; output: Array<{ type: string }>; usage: unknown }; + + assert.deepStrictEqual({ + eventTypes: events.map(event => event.event), + addedStatuses: events + .filter(event => event.event === 'response.output_item.added') + .map(event => (event.data.item as { status: string }).status), + responseId: completed.id, + outputTypes: completed.output.map(item => item.type), + usage: completed.usage, + }, { + eventTypes: [ + 'response.created', + 'response.in_progress', + 'response.output_item.added', + 'response.reasoning_summary_part.added', + 'response.reasoning_summary_text.delta', + 'response.reasoning_summary_text.done', + 'response.reasoning_summary_part.done', + 'response.reasoning_summary_part.added', + 'response.reasoning_summary_text.delta', + 'response.reasoning_summary_text.done', + 'response.reasoning_summary_part.done', + 'response.output_item.done', + 'response.output_item.added', + 'response.content_part.added', + 'response.output_text.delta', + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.output_item.added', + 'response.function_call_arguments.delta', + 'response.function_call_arguments.done', + 'response.output_item.done', + 'response.output_item.added', + 'response.custom_tool_call_input.delta', + 'response.custom_tool_call_input.done', + 'response.output_item.done', + 'response.completed', + ], + addedStatuses: ['in_progress', 'in_progress', 'in_progress', 'in_progress'], + responseId: 'resp_provider', + outputTypes: ['reasoning', 'message', 'function_call', 'custom_tool_call'], + usage: { + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 5, + output_tokens_details: { reasoning_tokens: 2 }, + total_tokens: 15, + }, + }); + }); + + test('encodes a completed non-streaming Responses body', () => { + const body = JSON.parse(bridgeResultToResponsesBody({ + responseId: 'resp_provider', + output: [ + { type: 'reasoning', id: 'thinking_1', summary: ['thought'], encryptedContent: 'vscode-reasoning-metadata:{"signature":"sig"}' }, + { type: 'message', content: [{ type: 'text', text: 'answer' }] }, + ], + usage: { inputTokens: 3, outputTokens: 2, reasoningTokens: 1 }, + }, 'gpt-5')) as { + id: string; + created_at: number; + status: string; + output: Array<{ id: string; type: string; encrypted_content?: string | null }>; + output_text: string; + usage: unknown; + }; + + assert.deepStrictEqual(body, { + id: 'resp_provider', + object: 'response', + created_at: body['created_at'], + status: 'completed', + error: null, + incomplete_details: null, + instructions: null, + model: 'gpt-5', + output: body.output, + output_text: 'answer', + parallel_tool_calls: true, + temperature: 1, + tool_choice: 'auto', + tools: [], + top_p: 1, + usage: { + input_tokens: 3, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 2, + output_tokens_details: { reasoning_tokens: 1 }, + total_tokens: 5, + }, + }); + assert.deepStrictEqual(body.output.map(item => item.type), ['reasoning', 'message']); + assert.match(body.output[0].id, /^rs_byok_/); + assert.strictEqual(body.output[0].encrypted_content, 'vscode-reasoning-metadata:{"signature":"sig"}'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index ef3f0c37060..f9163b92101 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -2273,6 +2273,38 @@ suite('ClaudeAgent', () => { }); }); + test('multi-root session discovers and retains customizations from an additional directory', async () => { + const { agent, sdk, fileService } = createTestContext(disposables, { rootConfig: { [AgentHostClaudeMultiRootEnabledConfigKey]: true } }); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const repoA = URI.file('/repo-a'); + const repoB = URI.file('/repo-b'); + const skillUri = URI.joinPath(repoB, '.claude', 'skills', 'from-b', 'SKILL.md'); + await fileService.writeFile(skillUri, VSBuffer.fromString('---\nname: from-b\ndescription: Skill from B\n---\nbody')); + const created = await agent.createSession({ workingDirectories: [repoA, repoB] }); + const before = await agent.getSessionCustomizations(created.session); + const sessionId = AgentSession.id(created.session); + sdk.supportedAgentsResult = []; + sdk.supportedCommandsResult = [{ name: 'from-b', description: 'Skill from B', argumentHint: '' }]; + sdk.mcpServerStatusResult = []; + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', [repoA, repoB], undefined, 'turn-1'); + const after = await agent.getSessionCustomizations(created.session); + const skillContainerUri = URI.joinPath(repoB, '.claude', 'skills').toString(); + const names = (customizations: readonly Customization[]) => { + const container = customizations.find(customization => customization.uri === skillContainerUri); + return container?.type === CustomizationType.Directory ? container.children?.map(skill => skill.name) : undefined; + }; + + assert.deepStrictEqual({ + before: names(before), + after: names(after), + }, { + before: ['from-b'], + after: ['from-b'], + }); + }); + test('cold resume recovers the additional directories from the persisted overlay', async () => { const database = new TestSessionDatabase(); const repoA = URI.file('/repo-a'); diff --git a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts index 85991b81bb2..9700340e948 100644 --- a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts @@ -4,12 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import * as sinon from 'sinon'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import type { AgentSignal } from '../../common/agentService.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { ResponsePartKind, ToolResultContentType } from '../../common/state/sessionState.js'; +import { STREAMING_TOOL_DISPLAY_INTERVAL_MS } from '../../common/streamingToolCallDisplay.js'; import { ToolCallConfirmationReason, ToolCallContributorKind } from '../../common/state/protocol/state.js'; import { ClaudeMapperState, mapSDKMessageToAgentSignals } from '../../node/claude/claudeMapSessionEvents.js'; import { CLAUDE_USER_DECLINED_MESSAGE } from '../../node/claude/claudeToolDenial.js'; @@ -48,6 +50,12 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { const SESSION_STR = SESSION.toString(); const SESSION_ID = 'sid-1'; const TURN_ID = 'turn-1'; + let clock: sinon.SinonFakeTimers | undefined; + + teardown(() => { + clock?.restore(); + clock = undefined; + }); /** * Captures `warn` calls so defense-in-depth tests can assert the @@ -329,6 +337,209 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { }]); }); + test('file-edit input deltas emit compact rich invocation messages', () => { + clock = sinon.useFakeTimers({ toFake: ['performance'] }); + const log = new NullLogService(); + const state = new ClaudeMapperState(); + const resolver = r(); + mapSDKMessageToAgentSignals(makeStreamEvent(SESSION_ID, makeContentBlockStartToolUse(0, 'tu_write', 'Write')), SESSION, TURN_ID, state, log, resolver); + + const first = mapSDKMessageToAgentSignals( + makeStreamEvent(SESSION_ID, makeInputJsonDelta(0, '{"file_path":"/src/new.ts","content":"one\\ntwo')), + SESSION, + TURN_ID, + state, + log, + resolver, + ); + clock.tick(STREAMING_TOOL_DISPLAY_INTERVAL_MS); + const second = mapSDKMessageToAgentSignals( + makeStreamEvent(SESSION_ID, makeInputJsonDelta(0, '\\nthree\\nfour\\nfive"')), + SESSION, + TURN_ID, + state, + log, + resolver, + ); + + assert.deepStrictEqual([...first, ...second], [ + { + kind: 'action', + resource: SESSION, + action: { + type: ActionType.ChatToolCallDelta, + turnId: TURN_ID, + toolCallId: 'tu_write', + content: '', + invocationMessage: { markdown: 'Creating [new.ts](file:///src/new.ts) (2 lines)' }, + }, + }, + { + kind: 'action', + resource: SESSION, + action: { + type: ActionType.ChatToolCallDelta, + turnId: TURN_ID, + toolCallId: 'tu_write', + content: '', + invocationMessage: { markdown: 'Creating [new.ts](file:///src/new.ts) (5 lines)' }, + }, + }, + ]); + }); + + test('content_block_stop flushes the final rich file-edit message held back by the throttle', () => { + clock = sinon.useFakeTimers({ toFake: ['performance'] }); + const log = new NullLogService(); + const state = new ClaudeMapperState(); + const resolver = r(); + mapSDKMessageToAgentSignals(makeStreamEvent(SESSION_ID, makeContentBlockStartToolUse(0, 'tu_write', 'Write')), SESSION, TURN_ID, state, log, resolver); + + const first = mapSDKMessageToAgentSignals( + makeStreamEvent(SESSION_ID, makeInputJsonDelta(0, '{"file_path":"/src/new.ts","content":"one')), + SESSION, + TURN_ID, + state, + log, + resolver, + ); + const withinInterval = mapSDKMessageToAgentSignals( + makeStreamEvent(SESSION_ID, makeInputJsonDelta(0, '\\ntwo"}')), + SESSION, + TURN_ID, + state, + log, + resolver, + ); + const stopped = mapSDKMessageToAgentSignals( + makeStreamEvent(SESSION_ID, makeContentBlockStop(0)), + SESSION, + TURN_ID, + state, + log, + resolver, + ); + + assert.deepStrictEqual({ + first: first.map(signal => signal.kind === 'action' ? signal.action : undefined), + withinInterval, + stopped: stopped.map(signal => signal.kind === 'action' ? signal.action : undefined), + }, { + first: [{ + type: ActionType.ChatToolCallDelta, + turnId: TURN_ID, + toolCallId: 'tu_write', + content: '', + invocationMessage: { markdown: 'Creating [new.ts](file:///src/new.ts) (1 line)' }, + }], + withinInterval: [], + stopped: [{ + type: ActionType.ChatToolCallDelta, + turnId: TURN_ID, + toolCallId: 'tu_write', + content: '', + invocationMessage: { markdown: 'Creating [new.ts](file:///src/new.ts) (2 lines)' }, + }, { + type: ActionType.ChatToolCallReady, + turnId: TURN_ID, + toolCallId: 'tu_write', + invocationMessage: { markdown: 'Editing [new.ts](file:///src/new.ts)' }, + toolInput: '{\n "file_path": "/src/new.ts",\n "content": "one\\ntwo"\n}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }], + }); + }); + + test('client tools with Claude built-in names preserve client semantics throughout the lifecycle', () => { + const state = new ClaudeMapperState(); + const resolver = r(); + const start = mapSDKMessageToAgentSignals( + makeStreamEvent(SESSION_ID, makeContentBlockStartToolUse(0, 'tu_client_write', 'mcp__client__Write')), + SESSION, + TURN_ID, + state, + new NullLogService(), + resolver, + () => 'client-1', + ); + + const delta = mapSDKMessageToAgentSignals( + makeStreamEvent(SESSION_ID, makeInputJsonDelta(0, '{"value":"client input"}')), + SESSION, + TURN_ID, + state, + new NullLogService(), + resolver, + ); + const ready = mapSDKMessageToAgentSignals( + makeStreamEvent(SESSION_ID, makeContentBlockStop(0)), + SESSION, + TURN_ID, + state, + new NullLogService(), + resolver, + ); + const complete = mapSDKMessageToAgentSignals( + makeUserToolResultMessage(SESSION_ID, 'tu_client_write', 'done'), + SESSION, + 'turn-2-irrelevant', + state, + new NullLogService(), + resolver, + ); + + assert.deepStrictEqual([...start, ...delta, ...ready, ...complete], [ + { + kind: 'action', + resource: SESSION, + action: { + type: ActionType.ChatToolCallStart, + turnId: TURN_ID, + toolCallId: 'tu_client_write', + toolName: 'Write', + displayName: 'Write', + contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-1' }, + }, + }, + { + kind: 'action', + resource: SESSION, + action: { + type: ActionType.ChatToolCallDelta, + turnId: TURN_ID, + toolCallId: 'tu_client_write', + content: '{"value":"client input"}', + }, + }, + { + kind: 'action', + resource: SESSION, + action: { + type: ActionType.ChatToolCallReady, + turnId: TURN_ID, + toolCallId: 'tu_client_write', + invocationMessage: 'Write', + toolInput: '{\n "value": "client input"\n}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }, + }, + { + kind: 'action', + resource: SESSION, + action: { + type: ActionType.ChatToolCallComplete, + turnId: TURN_ID, + toolCallId: 'tu_client_write', + result: { + success: true, + pastTenseMessage: 'Write', + content: [{ type: ToolResultContentType.Text, text: 'done' }], + }, + }, + }, + ]); + }); + test('Test 9.5 — content_block_stop emits ChatToolCallReady so auto-allowed tools leave Streaming', () => { const log = new CapturingLogService(); const state = new ClaudeMapperState(); diff --git a/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts b/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts index f373bec6996..3cb068acebf 100644 --- a/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts @@ -166,6 +166,51 @@ suite('claudeReplayMapper', () => { } }); + test('replay preserves generic semantics for client tools that collide with built-in names', () => { + const messages: SessionMessage[] = [ + makeUser('u1', 'run client tools'), + makeAssistantToolUse('a1', 'tu_bash', 'mcp__client__Bash', { command: 'echo client' }), + makeUserToolResult('r1', 'tu_bash', 'done'), + makeAssistantToolUse('a2', 'tu_task', 'mcp__client__Task', { description: 'client task' }), + makeUserToolResult('r2', 'tu_task', 'done'), + ]; + + const turns = mapSessionMessagesToTurns(messages, session, logService); + const tools = turns[0].responseParts.filter(part => part.kind === ResponsePartKind.ToolCall).map(part => { + assert.strictEqual(part.kind, ResponsePartKind.ToolCall); + return { + toolName: part.toolCall.toolName, + displayName: part.toolCall.displayName, + meta: part.toolCall._meta, + invocationMessage: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.invocationMessage : undefined, + toolInput: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.toolInput : undefined, + pastTenseMessage: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.pastTenseMessage : undefined, + hasSubagentContent: part.toolCall.status === ToolCallStatus.Completed + && part.toolCall.content?.some(content => content.type === ToolResultContentType.Subagent), + }; + }); + assert.deepStrictEqual(tools, [ + { + toolName: 'Bash', + displayName: 'Bash', + meta: undefined, + invocationMessage: 'Bash', + toolInput: '{\n "command": "echo client"\n}', + pastTenseMessage: 'Bash', + hasSubagentContent: false, + }, + { + toolName: 'Task', + displayName: 'Task', + meta: undefined, + invocationMessage: 'Task', + toolInput: '{\n "description": "client task"\n}', + pastTenseMessage: 'Task', + hasSubagentContent: false, + }, + ]); + }); + test('Fixture 3: multi-turn produces ordered Turns', () => { const messages: SessionMessage[] = [ makeUser('u1', 'first'), diff --git a/src/vs/platform/agentHost/test/node/claudeSubagentSignals.test.ts b/src/vs/platform/agentHost/test/node/claudeSubagentSignals.test.ts index 833a83a30bf..617bd1e39cd 100644 --- a/src/vs/platform/agentHost/test/node/claudeSubagentSignals.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSubagentSignals.test.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ToolCallConfirmationReason } from '../../common/state/sessionState.js'; +import { ToolCallConfirmationReason, ToolCallContributorKind } from '../../common/state/sessionState.js'; import { ClaudeMapperState, mapSDKMessageToAgentSignals } from '../../node/claude/claudeMapSessionEvents.js'; import { SubagentRegistry } from '../../node/claude/claudeSubagentRegistry.js'; import { buildTopLevelSubagentReadyAction, mapSubagentSystemMessage } from '../../node/claude/claudeSubagentSignals.js'; @@ -253,6 +253,70 @@ suite('claudeSubagentSignals — Phase 12 emission', () => { }); }); + test('inner client tools preserve client ownership and generic input across the lifecycle', () => { + const state = new ClaudeMapperState(); + const log = new NullLogService(); + const registry = r(); + const parentToolCallId = 'toolu_parent_client'; + mapSDKMessageToAgentSignals( + makeStreamEvent(SESSION_ID, makeContentBlockStartToolUse(0, parentToolCallId, 'Task')), + SESSION, TURN_ID, state, log, registry, + ); + + const innerAssistant = makeAssistantMessage(SESSION_ID, [ + { type: 'tool_use', id: 'toolu_inner_client', name: 'mcp__client__Bash', input: { command: 'echo client' } }, + ]); + innerAssistant.parent_tool_use_id = parentToolCallId; + const fromAssistant = mapSDKMessageToAgentSignals(innerAssistant, SESSION, TURN_ID, state, log, registry, () => 'client-1'); + const innerToolResult = makeUserToolResultMessage(SESSION_ID, 'toolu_inner_client', 'done'); + innerToolResult.parent_tool_use_id = parentToolCallId; + const fromResult = mapSDKMessageToAgentSignals(innerToolResult, SESSION, TURN_ID, state, log, registry); + + const actions = [...fromAssistant, ...fromResult].filter(signal => signal.kind === 'action').map(signal => signal.kind === 'action' ? signal.action : undefined); + assert.deepStrictEqual(actions.map(action => { + switch (action?.type) { + case ActionType.ChatToolCallStart: + return { + type: action.type, + toolName: action.toolName, + displayName: action.displayName, + contributor: action.contributor, + meta: action._meta, + }; + case ActionType.ChatToolCallReady: + return { + type: action.type, + invocationMessage: action.invocationMessage, + toolInput: action.toolInput, + }; + case ActionType.ChatToolCallComplete: + return { + type: action.type, + pastTenseMessage: action.result.pastTenseMessage, + }; + default: + return undefined; + } + }).filter(item => item !== undefined), [ + { + type: ActionType.ChatToolCallStart, + toolName: 'Bash', + displayName: 'Bash', + contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-1' }, + meta: undefined, + }, + { + type: ActionType.ChatToolCallReady, + invocationMessage: 'Bash', + toolInput: '{\n "command": "echo client"\n}', + }, + { + type: ActionType.ChatToolCallComplete, + pastTenseMessage: 'Bash', + }, + ]); + }); + test('foreground subagent completion: tool_result for a Task spawn emits ChatToolCallComplete AND IAgentSubagentCompletedSignal, then clears the spawn from the registry', () => { const state = new ClaudeMapperState(); const log = new NullLogService(); diff --git a/src/vs/platform/agentHost/test/node/claudeToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/claudeToolDisplay.test.ts index ad5ae745810..717c0149f7e 100644 --- a/src/vs/platform/agentHost/test/node/claudeToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeToolDisplay.test.ts @@ -10,6 +10,7 @@ import { getClaudeInvocationMessage, getClaudePastTenseMessage, getClaudePermissionKind, + getClaudeStreamingInvocationMessage, getClaudeToolDisplayName, getClaudeToolInputString, getClaudeToolKind, @@ -187,6 +188,38 @@ suite('claudeToolDisplay — §4 mapping table', () => { ); }); + test('streams rich file and line-count messages for Claude edit tools', () => { + assert.deepStrictEqual({ + write: getClaudeStreamingInvocationMessage('Write', { + file_path: '/src/new.ts', + content: 'one\r\ntwo\r\nthree', + }), + edit: getClaudeStreamingInvocationMessage('Edit', { + file_path: '/src/foo.ts', + old_string: 'one', + new_string: 'one\ntwo', + }), + multiEdit: getClaudeStreamingInvocationMessage('MultiEdit', { + file_path: '/src/foo.ts', + edits: [ + { old_string: 'one', new_string: 'one\ntwo' }, + { old_string: 'three\nfour', new_string: 'updated' }, + ], + }), + notebookEdit: getClaudeStreamingInvocationMessage('NotebookEdit', { + notebook_path: '/src/notebook.ipynb', + new_source: 'one\ntwo', + }), + read: getClaudeStreamingInvocationMessage('Read', { file_path: '/src/foo.ts' }), + }, { + write: { markdown: 'Creating [new.ts](file:///src/new.ts) (3 lines)' }, + edit: { markdown: 'Replacing 1 line with 2 lines in [foo.ts](file:///src/foo.ts)' }, + multiEdit: { markdown: 'Replacing 3 lines with 3 lines in [foo.ts](file:///src/foo.ts)' }, + notebookEdit: { markdown: 'Editing 2 lines in [notebook.ipynb](file:///src/notebook.ipynb)' }, + read: undefined, + }); + }); + test('Phase 8.5 — rich rendering snapshot covers every tool row', () => { const SAMPLE_INPUT: Record = { Bash: { command: 'git status' }, diff --git a/src/vs/platform/agentHost/test/node/codex/codexLaunchConfig.test.ts b/src/vs/platform/agentHost/test/node/codex/codexLaunchConfig.test.ts index a2f144d954a..d5969c3bf13 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexLaunchConfig.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexLaunchConfig.test.ts @@ -54,5 +54,11 @@ suite('CodexLaunchConfig', () => { modelProvider: 'vscode-proxy', config: { mcp_servers: { GitHub: { url: 'https://api.githubcopilot.com/mcp/' } } }, }); + assert.deepStrictEqual(buildCodexResumeParams('openai', 'thread-c', {}, ['/repo-a', '/repo-b']), { + threadId: 'thread-c', + modelProvider: 'openai', + cwd: '/repo-a', + runtimeWorkspaceRoots: ['/repo-a', '/repo-b'], + }); }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts index 94f13e9cc44..f75c2479af7 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts @@ -5,7 +5,6 @@ import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; -import { Event } from '../../../../../base/common/event.js'; import type { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -14,29 +13,31 @@ import { TestInstantiationService } from '../../../../../platform/instantiation/ import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IAgentHostGitHubEndpointService } from '../../../node/agentHostGitHubEndpointService.js'; -import { IAgentConfigurationService } from '../../../node/agentConfigurationService.js'; +import { AgentConfigurationService, IAgentConfigurationService } from '../../../node/agentConfigurationService.js'; +import { AgentHostStateManager } from '../../../node/agentHostStateManager.js'; import { IAgentSdkDownloader } from '../../../node/agentSdkDownloader.js'; import { CodexAgent } from '../../../node/codex/codexAgent.js'; import { ICodexProxyService } from '../../../node/codex/codexProxyService.js'; import { ICopilotApiService } from '../../../node/shared/copilotApiService.js'; import { ISessionDataService } from '../../../common/sessionDataService.js'; import { createTestGitHubEndpointService } from '../testGitHubEndpointService.js'; +import { AgentHostCodexMultiRootEnabledConfigKey } from '../../../common/agentHostSchema.js'; -function createAgent(disposables: Pick, models: () => Promise): CodexAgent { +function createAgent(disposables: Pick, models: () => Promise, rootConfig: Record = {}): CodexAgent { const instantiationService = new TestInstantiationService(); + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configurationService = disposables.add(new AgentConfigurationService(stateManager, logService)); + configurationService.updateRootConfig(rootConfig); instantiationService.stub(ISessionDataService, { _serviceBrand: undefined }); instantiationService.stub(ICopilotApiService, { _serviceBrand: undefined, models }); instantiationService.stub(ICodexProxyService, { _serviceBrand: undefined }); - instantiationService.stub(IAgentConfigurationService, { - _serviceBrand: undefined, - onDidRootConfigChange: Event.None, - getRootValue: () => undefined, - }); + instantiationService.stub(IAgentConfigurationService, configurationService); instantiationService.stub(IAgentHostGitHubEndpointService, createTestGitHubEndpointService()); instantiationService.stub(IAgentSdkDownloader, { _serviceBrand: undefined }); instantiationService.stub(IProductService, { _serviceBrand: undefined, version: '1.0.0-test' } as IProductService); instantiationService.stub(INativeEnvironmentService, { userHome: URI.file('/tmp') }); - instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ILogService, logService); return disposables.add(instantiationService.createInstance(CodexAgent)); } @@ -62,4 +63,19 @@ suite('CodexAgent model refresh', () => { assert.deepStrictEqual(agent.models.get().map(model => model.id), ['gpt-5.5']); }); + + test('advertises multiple working directories only while enabled', () => { + const agent = createAgent(disposables, async () => []); + const disabledByDefault = agent.getDescriptor().capabilities?.multipleWorkingDirectories; + agent['_configurationService'].updateRootConfig({ [AgentHostCodexMultiRootEnabledConfigKey]: true }); + const whenEnabled = agent.getDescriptor().capabilities?.multipleWorkingDirectories; + agent['_configurationService'].updateRootConfig({ [AgentHostCodexMultiRootEnabledConfigKey]: false }); + const afterDisabling = agent.getDescriptor().capabilities?.multipleWorkingDirectories; + + assert.deepStrictEqual({ disabledByDefault, whenEnabled, afterDisabling }, { + disabledByDefault: undefined, + whenEnabled: { immutablePrimary: true }, + afterDisabling: undefined, + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts index 91a48a964ed..50d840ea13c 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexPrewarmEviction.test.ts @@ -6,9 +6,11 @@ import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; import { PassThrough } from 'stream'; -import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Emitter } from '../../../../../base/common/event.js'; import type { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; +import { sep } from '../../../../../base/common/path.js'; +import { isWindows } from '../../../../../base/common/platform.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { INativeEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -17,7 +19,8 @@ import { IProductService } from '../../../../../platform/product/common/productS import { AgentSession } from '../../../common/agentService.js'; import { buildDefaultChatUri } from '../../../common/state/sessionState.js'; import { ISessionDataService } from '../../../common/sessionDataService.js'; -import { IAgentConfigurationService } from '../../../node/agentConfigurationService.js'; +import { AgentConfigurationService, IAgentConfigurationService } from '../../../node/agentConfigurationService.js'; +import { AgentHostStateManager } from '../../../node/agentHostStateManager.js'; import { IAgentHostGitHubEndpointService } from '../../../node/agentHostGitHubEndpointService.js'; import { IAgentSdkDownloader } from '../../../node/agentSdkDownloader.js'; import { CodexAgent } from '../../../node/codex/codexAgent.js'; @@ -25,6 +28,10 @@ import { CodexAppServerClient, type ICodexAppServerTransport } from '../../../no import { ICodexProxyService } from '../../../node/codex/codexProxyService.js'; import { ICopilotApiService } from '../../../node/shared/copilotApiService.js'; import { createTestGitHubEndpointService } from '../testGitHubEndpointService.js'; +import { AgentHostCodexMultiRootEnabledConfigKey } from '../../../common/agentHostSchema.js'; +import { CodexSessionConfigKey } from '../../../common/codexSessionConfigKeys.js'; +import type { SandboxPolicy } from '../../../node/codex/protocol/generated/v2/SandboxPolicy.js'; +import { createSessionDataService, TestSessionDatabase } from '../../common/sessionTestHelpers.js'; interface ITestWireRequest { readonly id: number; @@ -32,6 +39,8 @@ interface ITestWireRequest { readonly params: { readonly cwd?: string; readonly threadId?: string; + readonly runtimeWorkspaceRoots?: readonly string[]; + readonly sandboxPolicy?: SandboxPolicy; }; } @@ -98,25 +107,46 @@ function readNextRequest(stream: PassThrough): Promise { }); } -async function createAgent(disposables: Pick): Promise { +interface ICreateAgentOptions { + readonly multiRootEnabled?: boolean; + readonly sessionConfig?: Readonly>; + readonly database?: TestSessionDatabase; +} + +class TestCodexConfigurationService extends AgentConfigurationService { + constructor( + stateManager: AgentHostStateManager, + logService: NullLogService, + private sessionConfig: Readonly> | undefined, + ) { + super(stateManager, logService); + } + + setSessionConfig(sessionConfig: Readonly>): void { + this.sessionConfig = sessionConfig; + } + + override getSessionConfigValues(): Record | undefined { + return this.sessionConfig ? { ...this.sessionConfig } : undefined; + } +} + +async function createAgent(disposables: Pick, options: ICreateAgentOptions = {}): Promise { const models = [{ id: 'gpt-test', name: 'GPT Test', supported_endpoints: ['/responses'] }] as CCAModel[]; const instantiationService = new TestInstantiationService(); - instantiationService.stub(ISessionDataService, { _serviceBrand: undefined }); + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configurationService = disposables.add(new TestCodexConfigurationService(stateManager, logService, options.sessionConfig)); + configurationService.updateRootConfig({ [AgentHostCodexMultiRootEnabledConfigKey]: options.multiRootEnabled }); + instantiationService.stub(ISessionDataService, createSessionDataService(options.database)); instantiationService.stub(ICopilotApiService, { _serviceBrand: undefined, models: async () => models }); instantiationService.stub(ICodexProxyService, { _serviceBrand: undefined }); - instantiationService.stub(IAgentConfigurationService, { - _serviceBrand: undefined, - onDidRootConfigChange: Event.None, - getRootValue: () => undefined, - getSessionConfigValues: () => undefined, - isWorkingDirectoryPending: () => false, - updateRootConfig: () => { }, - }); + instantiationService.stub(IAgentConfigurationService, configurationService); instantiationService.stub(IAgentHostGitHubEndpointService, createTestGitHubEndpointService()); instantiationService.stub(IAgentSdkDownloader, { _serviceBrand: undefined, isSdkResolvableWithoutDownload: async () => true }); instantiationService.stub(IProductService, { _serviceBrand: undefined, version: '1.0.0-test' } as IProductService); instantiationService.stub(INativeEnvironmentService, { userHome: URI.file('/tmp') }); - instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ILogService, logService); const agent = disposables.add(instantiationService.createInstance(CodexAgent)); await agent.authenticate(agent.getProtectedResources()[0].resource, 'test-token'); await agent.refreshModels(); @@ -206,4 +236,376 @@ suite('CodexAgent prewarm eviction', () => { test('waits for and evicts an in-flight folder prewarm when the first send resolves to a worktree', async () => { await assertPrewarmEvictedOnSend(disposables, false); }); + + test('multi-root start and turn separate workspace roots from additional writable directories', async () => { + const additionalDirectory = URI.file('/manual-write').fsPath; + const sessionUri = AgentSession.uri('codex', 'multi-root'); + const agent = await createAgent(disposables, { + multiRootEnabled: true, + sessionConfig: { [CodexSessionConfigKey.AdditionalDirectories]: [additionalDirectory, `${additionalDirectory}${sep}`] }, + }); + const peer = disposables.add(createTestPeer()); + const client = new CodexAppServerClient(peer.transport); + agent['_connection'] = { + kind: 'ready', + client, + usageSource: 'github', + child: { kill: () => true }, + } as never; + agent['_refreshSkillHookCustomizations'] = async () => { }; + agent['_refreshSkillExtraRoots'] = async () => { }; + const repoA = URI.file('/repo-a'); + const repoB = URI.file('/repo-b'); + const duplicateRepoA = URI.file(`${repoA.fsPath}${sep}`); + const caseVariantRepoA = URI.file(repoA.fsPath.toUpperCase()); + + try { + const workingDirectories = [repoA, duplicateRepoA, ...(isWindows ? [caseVariantRepoA] : []), repoB]; + const { session } = await agent.createSession({ session: sessionUri, workingDirectories, model: { id: 'gpt-test' } }); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + const start = await readNextRequest(peer.outbound); + peer.push({ id: start.id, result: { thread: { id: 'thread' }, runtimeWorkspaceRoots: [repoA.fsPath, repoB.fsPath] } }); + await entry.materializePromise; + + const send = agent.chats.sendMessage(URI.parse(buildDefaultChatUri(session)), 'hello', workingDirectories, undefined, 'turn-1'); + const turn = await readNextRequest(peer.outbound); + peer.push({ id: turn.id, result: {} }); + await send; + const configurationService = agent['_configurationService']; + assert.ok(configurationService instanceof TestCodexConfigurationService); + configurationService.setSessionConfig({ [CodexSessionConfigKey.PermissionsPreset]: 'full-access' }); + const fullAccess = agent['_turnStartOptions'](entry, 'gpt-test'); + configurationService.setSessionConfig({ [CodexSessionConfigKey.SandboxMode]: 'read-only' }); + const readOnly = agent['_turnStartOptions'](entry, 'gpt-test'); + + assert.deepStrictEqual({ + start: { cwd: start.params.cwd, runtimeWorkspaceRoots: start.params.runtimeWorkspaceRoots }, + turn: { + runtimeWorkspaceRoots: turn.params.runtimeWorkspaceRoots, + sandboxPolicy: turn.params.sandboxPolicy, + }, + fullAccess: { + runtimeWorkspaceRoots: fullAccess.runtimeWorkspaceRoots, + sandboxPolicy: fullAccess.sandboxPolicy, + }, + readOnly: { + runtimeWorkspaceRoots: readOnly.runtimeWorkspaceRoots, + sandboxPolicy: readOnly.sandboxPolicy, + }, + }, { + start: { cwd: repoA.fsPath, runtimeWorkspaceRoots: [repoA.fsPath, repoB.fsPath] }, + turn: { + runtimeWorkspaceRoots: [repoA.fsPath, repoB.fsPath], + sandboxPolicy: { + type: 'workspaceWrite', + writableRoots: [repoA.fsPath, repoB.fsPath, additionalDirectory], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }, + }, + fullAccess: { + runtimeWorkspaceRoots: [repoA.fsPath, repoB.fsPath], + sandboxPolicy: { type: 'dangerFullAccess' }, + }, + readOnly: { + runtimeWorkspaceRoots: [repoA.fsPath, repoB.fsPath], + sandboxPolicy: { type: 'readOnly', networkAccess: false }, + }, + }); + } finally { + peer.exit(); + } + }); + + test('disabled multi-root preserves the existing additional-directory payload', async () => { + const additionalDirectory = URI.file('/manual-write').fsPath; + const sessionUri = AgentSession.uri('codex', 'single-root'); + const agent = await createAgent(disposables, { + sessionConfig: { [CodexSessionConfigKey.AdditionalDirectories]: [additionalDirectory] }, + }); + const peer = disposables.add(createTestPeer()); + const client = new CodexAppServerClient(peer.transport); + agent['_connection'] = { + kind: 'ready', + client, + usageSource: 'github', + child: { kill: () => true }, + } as never; + agent['_refreshSkillHookCustomizations'] = async () => { }; + agent['_refreshSkillExtraRoots'] = async () => { }; + const repoA = URI.file('/repo-a'); + const repoB = URI.file('/repo-b'); + + try { + const { session } = await agent.createSession({ session: sessionUri, workingDirectories: [repoA, repoB], model: { id: 'gpt-test' } }); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + const start = await readNextRequest(peer.outbound); + peer.push({ id: start.id, result: { thread: { id: 'thread' } } }); + await entry.materializePromise; + + const send = agent.chats.sendMessage(URI.parse(buildDefaultChatUri(session)), 'hello', [repoA], undefined, 'turn-1'); + const turn = await readNextRequest(peer.outbound); + peer.push({ id: turn.id, result: {} }); + await send; + + assert.deepStrictEqual({ + startRuntimeWorkspaceRoots: start.params.runtimeWorkspaceRoots, + turnRuntimeWorkspaceRoots: turn.params.runtimeWorkspaceRoots, + writableRoots: turn.params.sandboxPolicy?.type === 'workspaceWrite' ? turn.params.sandboxPolicy.writableRoots : undefined, + }, { + startRuntimeWorkspaceRoots: undefined, + turnRuntimeWorkspaceRoots: [repoA.fsPath, additionalDirectory], + writableRoots: [repoA.fsPath, additionalDirectory], + }); + } finally { + peer.exit(); + } + }); + + test('enabled multi-root preserves single-folder protocol and sandbox behavior', async () => { + const additionalDirectory = `${URI.file('/manual-write').fsPath}${sep}`; + const sessionUri = AgentSession.uri('codex', 'enabled-single-root'); + const agent = await createAgent(disposables, { + multiRootEnabled: true, + sessionConfig: { [CodexSessionConfigKey.AdditionalDirectories]: [additionalDirectory] }, + }); + const peer = disposables.add(createTestPeer()); + const client = new CodexAppServerClient(peer.transport); + agent['_connection'] = { + kind: 'ready', + client, + usageSource: 'github', + child: { kill: () => true }, + } as never; + agent['_refreshSkillHookCustomizations'] = async () => { }; + agent['_refreshSkillExtraRoots'] = async () => { }; + const repo = URI.file('/repo'); + + try { + const { session } = await agent.createSession({ session: sessionUri, workingDirectories: [repo], model: { id: 'gpt-test' } }); + const entry = agent['_sessions'].get(AgentSession.id(session))!; + const start = await readNextRequest(peer.outbound); + peer.push({ id: start.id, result: { thread: { id: 'thread' } } }); + await entry.materializePromise; + + const send = agent.chats.sendMessage(URI.parse(buildDefaultChatUri(session)), 'hello', [repo], undefined, 'turn-1'); + const turn = await readNextRequest(peer.outbound); + peer.push({ id: turn.id, result: {} }); + await send; + const configurationService = agent['_configurationService']; + assert.ok(configurationService instanceof TestCodexConfigurationService); + configurationService.setSessionConfig({ [CodexSessionConfigKey.PermissionsPreset]: 'full-access' }); + const fullAccess = agent['_turnStartOptions'](entry, 'gpt-test'); + configurationService.setSessionConfig({ [CodexSessionConfigKey.SandboxMode]: 'read-only' }); + const readOnly = agent['_turnStartOptions'](entry, 'gpt-test'); + + assert.deepStrictEqual({ + start: { + cwd: start.params.cwd, + runtimeWorkspaceRoots: start.params.runtimeWorkspaceRoots, + }, + turn: { + runtimeWorkspaceRoots: turn.params.runtimeWorkspaceRoots, + sandboxPolicy: turn.params.sandboxPolicy, + }, + fullAccess: { + runtimeWorkspaceRoots: fullAccess.runtimeWorkspaceRoots, + sandboxPolicy: fullAccess.sandboxPolicy, + }, + readOnly: { + runtimeWorkspaceRoots: readOnly.runtimeWorkspaceRoots, + sandboxPolicy: readOnly.sandboxPolicy, + }, + }, { + start: { + cwd: repo.fsPath, + runtimeWorkspaceRoots: undefined, + }, + turn: { + runtimeWorkspaceRoots: [repo.fsPath, additionalDirectory], + sandboxPolicy: { + type: 'workspaceWrite', + writableRoots: [repo.fsPath, additionalDirectory], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }, + }, + fullAccess: { + runtimeWorkspaceRoots: undefined, + sandboxPolicy: { type: 'dangerFullAccess' }, + }, + readOnly: { + runtimeWorkspaceRoots: undefined, + sandboxPolicy: { type: 'readOnly', networkAccess: false }, + }, + }); + } finally { + peer.exit(); + } + }); + + test('fork inherits the source workspace roots instead of requested replacements', async () => { + const agent = await createAgent(disposables, { multiRootEnabled: true }); + const peer = disposables.add(createTestPeer()); + const client = new CodexAppServerClient(peer.transport); + agent['_connection'] = { + kind: 'ready', + client, + usageSource: 'github', + child: { kill: () => true }, + } as never; + agent['_refreshSkillHookCustomizations'] = async () => { }; + agent['_refreshSkillExtraRoots'] = async () => { }; + const repoA = URI.file('/repo-a'); + const repoB = URI.file('/repo-b'); + const requestedA = URI.file('/requested-a'); + const requestedB = URI.file('/requested-b'); + + try { + const source = await agent.createSession({ workingDirectories: [repoA, repoB], model: { id: 'gpt-test' } }); + const sourceEntry = agent['_sessions'].get(AgentSession.id(source.session))!; + const start = await readNextRequest(peer.outbound); + peer.push({ id: start.id, result: { thread: { id: 'source-thread' }, cwd: repoA.fsPath, runtimeWorkspaceRoots: [repoA.fsPath, repoB.fsPath] } }); + await sourceEntry.materializePromise; + + const forkPromise = agent.createSession({ + workingDirectories: [requestedA, requestedB], + fork: { session: source.session, turnId: 'turn-1', turnIndex: 0 }, + }); + const read = await readNextRequest(peer.outbound); + peer.push({ + id: read.id, + result: { + thread: { + id: 'source-thread', + cwd: repoA.fsPath, + turns: [{ id: 'turn-1' }], + }, + }, + }); + const fork = await readNextRequest(peer.outbound); + peer.push({ + id: fork.id, + result: { + thread: { id: 'fork-thread', cwd: repoA.fsPath }, + cwd: repoA.fsPath, + runtimeWorkspaceRoots: [repoA.fsPath, repoB.fsPath], + }, + }); + const forked = await forkPromise; + const forkedEntry = agent['_sessions'].get(AgentSession.id(forked.session))!; + + assert.deepStrictEqual({ + request: { + method: fork.method, + cwd: fork.params.cwd, + runtimeWorkspaceRoots: fork.params.runtimeWorkspaceRoots, + }, + workingDirectories: forkedEntry.workingDirectories?.map(directory => directory.fsPath), + }, { + request: { + method: 'thread/fork', + cwd: repoA.fsPath, + runtimeWorkspaceRoots: [repoA.fsPath, repoB.fsPath], + }, + workingDirectories: [repoA.fsPath, repoB.fsPath], + }); + } finally { + peer.exit(); + } + }); + + test('cold resume restores persisted workspace roots', async () => { + const database = new TestSessionDatabase(); + const repoA = URI.file('/repo-a'); + const repoB = URI.file('/repo-b'); + const agentA = await createAgent(disposables, { multiRootEnabled: true, database }); + const peerA = disposables.add(createTestPeer()); + agentA['_connection'] = { + kind: 'ready', + client: new CodexAppServerClient(peerA.transport), + usageSource: 'github', + child: { kill: () => true }, + } as never; + agentA['_refreshSkillHookCustomizations'] = async () => { }; + agentA['_refreshSkillExtraRoots'] = async () => { }; + let peerB: ITestPeer | undefined; + + try { + const created = await agentA.createSession({ workingDirectories: [repoA, repoB], model: { id: 'gpt-test' } }); + const entry = agentA['_sessions'].get(AgentSession.id(created.session))!; + const start = await readNextRequest(peerA.outbound); + peerA.push({ id: start.id, result: { thread: { id: 'thread' }, cwd: repoA.fsPath, runtimeWorkspaceRoots: [repoA.fsPath, repoB.fsPath] } }); + await entry.materializePromise; + const firstSend = agentA.chats.sendMessage(URI.parse(buildDefaultChatUri(created.session)), 'hello', [repoA, repoB], undefined, 'turn-1'); + const firstTurn = await readNextRequest(peerA.outbound); + peerA.push({ id: firstTurn.id, result: {} }); + await firstSend; + await new Promise(resolve => setImmediate(resolve)); + const canonicalOverlay = await agentA['_metadataStore'].read(AgentSession.uri('codex', 'thread')); + + const agentB = await createAgent(disposables, { multiRootEnabled: true, database }); + peerB = disposables.add(createTestPeer()); + agentB['_connection'] = { + kind: 'ready', + client: new CodexAppServerClient(peerB.transport), + usageSource: 'github', + child: { kill: () => true }, + } as never; + agentB['_refreshSkillHookCustomizations'] = async () => { }; + agentB['_refreshSkillExtraRoots'] = async () => { }; + + const metadataPromise = agentB.getSessionMetadata(created.session); + const read = await readNextRequest(peerB.outbound); + peerB.push({ + id: read.id, + result: { + thread: { + id: 'thread', + cwd: repoA.fsPath, + modelProvider: 'vscode-proxy', + turns: [], + }, + }, + }); + const metadata = await metadataPromise; + + const resumedSend = agentB.chats.sendMessage(URI.parse(buildDefaultChatUri(created.session)), 'again', undefined, undefined, 'turn-2'); + const resume = await readNextRequest(peerB.outbound); + peerB.push({ + id: resume.id, + result: { + thread: { id: 'thread', cwd: repoA.fsPath }, + cwd: repoA.fsPath, + runtimeWorkspaceRoots: [repoA.fsPath, repoB.fsPath], + }, + }); + const resumedTurn = await readNextRequest(peerB.outbound); + peerB.push({ id: resumedTurn.id, result: {} }); + await resumedSend; + + assert.deepStrictEqual({ + canonicalOverlay: canonicalOverlay.workingDirectories?.map(directory => directory.fsPath), + metadata: metadata?.workingDirectories?.map(directory => directory.fsPath), + resume: { + cwd: resume.params.cwd, + runtimeWorkspaceRoots: resume.params.runtimeWorkspaceRoots, + }, + turnRuntimeWorkspaceRoots: resumedTurn.params.runtimeWorkspaceRoots, + }, { + canonicalOverlay: [repoA.fsPath, repoB.fsPath], + metadata: [repoA.fsPath, repoB.fsPath], + resume: { + cwd: repoA.fsPath, + runtimeWorkspaceRoots: [repoA.fsPath, repoB.fsPath], + }, + turnRuntimeWorkspaceRoots: [repoA.fsPath, repoB.fsPath], + }); + } finally { + peerB?.exit(); + peerA.exit(); + } + }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexSessionMetadataStore.test.ts b/src/vs/platform/agentHost/test/node/codex/codexSessionMetadataStore.test.ts new file mode 100644 index 00000000000..ca716da1542 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/codex/codexSessionMetadataStore.test.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { CodexSessionMetadataStore } from '../../../node/codex/codexSessionMetadataStore.js'; +import { createSessionDataService, TestSessionDatabase } from '../../common/sessionTestHelpers.js'; + +suite('CodexSessionMetadataStore', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('round trips working directories', async () => { + const store = new CodexSessionMetadataStore(createSessionDataService(), new NullLogService()); + const session = URI.parse('codex:/session'); + const workingDirectories = [URI.file('/repo-a'), URI.file('/repo-b')]; + + await store.write(session, { threadId: 'thread', cwd: workingDirectories[0], workingDirectories }); + + const overlay = await store.read(session); + assert.deepStrictEqual({ + threadId: overlay.threadId, + cwd: overlay.cwd?.toString(), + workingDirectories: overlay.workingDirectories?.map(directory => directory.toString()), + }, { + threadId: 'thread', + cwd: workingDirectories[0].toString(), + workingDirectories: workingDirectories.map(directory => directory.toString()), + }); + }); + + test('ignores malformed working directory metadata', async () => { + const database = new TestSessionDatabase(); + await database.setMetadata('codex.cwd', '{"cwd":'); + const store = new CodexSessionMetadataStore(createSessionDataService(database), new NullLogService()); + + const overlay = await store.read(URI.parse('codex:/session')); + + assert.deepStrictEqual({ cwd: overlay.cwd, workingDirectories: overlay.workingDirectories }, { + cwd: undefined, + workingDirectories: undefined, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 2bc24b1f0ba..1446cb072c5 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -13,7 +13,7 @@ import { VSBuffer } from '../../../../base/common/buffer.js'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { CancellationError, isCancellationError } from '../../../../base/common/errors.js'; import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; -import { Event } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { Schemas } from '../../../../base/common/network.js'; import { waitForState } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; @@ -28,6 +28,7 @@ import { ServiceCollection } from '../../../instantiation/common/serviceCollecti import { ILogService, LogLevel, NullLogService } from '../../../log/common/log.js'; import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; import type { IAgentHostClientProxyConnection } from '../../common/agentHostClientProxyChannel.js'; +import type { IByokLmBridgeConnection, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; @@ -596,7 +597,7 @@ function getCreatedClientOptions(agent: CopilotAgent): readonly CopilotClientOpt return agent.createdClientOptions; } -function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; fileService: FileService; stateManager: AgentHostStateManager } { +function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver; byokBridgeRegistry?: IByokLmBridgeRegistry }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; fileService: FileService; stateManager: AgentHostStateManager } { const services = new ServiceCollection(); const logService = options?.logService ?? new NullLogService(); const fileService = options?.fileService ?? disposables.add(new FileService(logService)); @@ -621,7 +622,7 @@ function createTestAgentContext(disposables: Pick, optio }); services.set(IAgentHostCompletions, disposables.add(new AgentHostCompletions(logService))); services.set(IAgentHostProxyResolver, options?.proxyResolver ?? new TestProxyResolver()); - services.set(IByokLmBridgeRegistry, new ByokLmBridgeRegistry()); + services.set(IByokLmBridgeRegistry, options?.byokBridgeRegistry ?? new ByokLmBridgeRegistry()); const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService(); services.set(ICopilotApiService, copilotApiService); services.set(ITelemetryService, options?.telemetryService ?? NullTelemetryService); @@ -641,7 +642,7 @@ function createTestAgentContext(disposables: Pick, optio return { agent, instantiationService, configurationService: configService, fileService, stateManager }; } -function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService }): CopilotAgent { +function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService; byokBridgeRegistry?: IByokLmBridgeRegistry }): CopilotAgent { return createTestAgentContext(disposables, options).agent; } @@ -2137,6 +2138,58 @@ suite('CopilotAgent', () => { } }); + test('BYOK model configSchema exposes only Copilot-supported reasoning efforts', async () => { + const byokBridgeRegistry = new ByokLmBridgeRegistry(); + const agent = createTestAgent(disposables, { byokBridgeRegistry }); + const modelSnapshots = disposables.add(new Emitter()); + const connection: IByokLmBridgeConnection = { + chat: async () => ({ output: [] }), + onDidChangeModels: modelSnapshots.event, + }; + disposables.add(byokBridgeRegistry.register('renderer', connection)); + + try { + modelSnapshots.fire([ + { + vendor: 'acme', + id: 'fallback-default', + name: 'Fallback Default', + supportedReasoningEfforts: ['minimal', 'low', 'high'], + defaultReasoningEffort: 'minimal', + }, + { + vendor: 'acme', + id: 'valid-default', + name: 'Valid Default', + supportedReasoningEfforts: ['low', 'medium', 'high'], + defaultReasoningEffort: 'medium', + }, + { + vendor: 'acme', + id: 'unsupported-only', + name: 'Unsupported Only', + supportedReasoningEfforts: ['minimal'], + defaultReasoningEffort: 'minimal', + }, + ]); + const models = await waitForState(agent.models, models => models.length === 3); + + assert.deepStrictEqual(models.map(model => ({ + id: model.id, + thinkingLevel: model.configSchema?.properties.thinkingLevel && { + enum: model.configSchema.properties.thinkingLevel.enum, + default: model.configSchema.properties.thinkingLevel.default, + }, + })), [ + { id: 'acme/fallback-default', thinkingLevel: { enum: ['low', 'high'], default: 'low' } }, + { id: 'acme/valid-default', thinkingLevel: { enum: ['low', 'medium', 'high'], default: 'medium' } }, + { id: 'acme/unsupported-only', thinkingLevel: undefined }, + ]); + } finally { + await disposeAgent(agent); + } + }); + test('configSchema emits a numeric contextSize property when long_context tier exceeds default', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [{ @@ -2365,8 +2418,8 @@ suite('CopilotAgent', () => { environmentServiceRegistration: 'native', sessionDataService, }); - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - delete process.env['XDG_STATE_HOME']; + const previousCopilotHome = process.env['COPILOT_HOME']; + delete process.env['COPILOT_HOME']; try { const createdSession = createAgentSessionThroughAgent(agent, instantiationService); const agentSession = disposables.add(createdSession.session); @@ -2383,10 +2436,10 @@ suite('CopilotAgent', () => { assert.strictEqual(result.kind, 'approve-once'); } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; + if (previousCopilotHome === undefined) { + delete process.env['COPILOT_HOME']; } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; + process.env['COPILOT_HOME'] = previousCopilotHome; } await disposeAgent(agent); } diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index b968dd140f5..a9ac6b9c166 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -29,9 +29,10 @@ import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedb import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService, type ISessionDatabase } from '../../common/sessionDataService.js'; -import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction, type SessionAction, type StateAction } from '../../common/state/sessionActions.js'; -import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, createSessionState, mergeSessionWithDefaultChat, readSessionPromptCacheState, readUsageInfoMeta, SessionStatus, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type ToolResultTerminalContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction, type SessionAction, type StateAction } from '../../common/state/sessionActions.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, createSessionState, mergeSessionWithDefaultChat, readSessionPromptCacheState, readUsageInfoMeta, SessionStatus, withSessionPromptCacheState, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type ToolResultTerminalContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; import { TerminalClaimKind } from '../../common/state/protocol/state.js'; +import { STREAMING_TOOL_DISPLAY_INTERVAL_MS } from '../../common/streamingToolCallDisplay.js'; import { CustomizationType, McpAuthRequiredReason, McpServerStatus, type Customization } from '../../common/state/protocol/channels-session/state.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { buildNonPtyShellTerminalUri } from '../../node/copilot/copilotNonPtyShellTerminals.js'; @@ -100,13 +101,24 @@ class MockCopilotSession { totalPremiumRequestCost: 0, totalUserRequests: 0, totalApiDurationMs: 0, + totalNanoAiu: 0, sessionStartTime: new Date().toISOString(), codeChanges: { linesAdded: 0, linesRemoved: 0, filesModifiedCount: 0, filesModified: [] }, - modelMetrics: {}, + modelMetrics: {} as Record, currentModel: undefined as string | undefined, lastCallInputTokens: 0, lastCallOutputTokens: 0, }; + /** Rejects the next `usage.getMetrics` call, then clears itself. */ + usageMetricsError: unknown = undefined; + usageMetricsCalls = 0; + /** Awaited inside `usage.getMetrics` so tests can hold a refresh in flight. */ + usageMetricsGate: Promise | undefined; + /** + * Per-call gates, consumed in call order, for holding individual reads in flight. + * Lets a test make an earlier-issued read resolve after a later one. + */ + readonly usageMetricsGates: Array> = []; private readonly _handlers = new Map void>>(); private readonly _allHandlers = new Set(); @@ -139,6 +151,7 @@ class MockCopilotSession { /** Push an event through to all registered handlers of the given type. */ fire(type: K, data: SessionEventPayload['data'], overrides?: Partial, 'type' | 'data'>>): void { const event = { type, data, id: 'evt-1', timestamp: new Date().toISOString(), parentId: null, ...overrides } as SessionEventPayload; + this._accumulateUsageMetrics(type, data); const set = this._handlers.get(type); if (set) { for (const handler of set) { @@ -150,6 +163,38 @@ class MockCopilotSession { } } + /** + * Mirrors the SDK's own usage tracker, which folds the `copilotUsage` billed on + * `assistant.usage` (including sub-agent calls) and `session.compaction_complete` + * into the session-wide total that `usage.getMetrics` reports. + */ + private _accumulateUsageMetrics(type: SessionEventType, data: unknown): void { + if (type === 'session.model_change') { + const modelChange = data as { newModel?: string }; + if (modelChange.newModel) { + this.usageMetricsResult.currentModel = modelChange.newModel; + } + } + if (type === 'assistant.usage') { + const usage = data as { model?: string; cacheExpiresAt?: string; parentToolCallId?: string }; + if (!usage.parentToolCallId && usage.model) { + this.usageMetricsResult.currentModel = usage.model; + if (usage.cacheExpiresAt) { + this.usageMetricsResult.modelMetrics[usage.model] = { cacheExpiresAt: usage.cacheExpiresAt }; + } + } + } + const billed = type === 'assistant.usage' + ? data + : type === 'session.compaction_complete' + ? (data as { compactionTokensUsed?: unknown } | undefined)?.compactionTokensUsed + : undefined; + const totalNanoAiu = (billed as { copilotUsage?: { totalNanoAiu?: number } } | undefined)?.copilotUsage?.totalNanoAiu; + if (typeof totalNanoAiu === 'number') { + this.usageMetricsResult.totalNanoAiu += totalNanoAiu; + } + } + // Stubs for methods the wrapper / session class calls async send(request: unknown) { this.sendRequests.push(request); @@ -252,7 +297,19 @@ class MockCopilotSession { }, }, usage: { - getMetrics: async () => this.usageMetricsResult, + getMetrics: async () => { + this.usageMetricsCalls++; + if (this.usageMetricsError !== undefined) { + const err = this.usageMetricsError; + this.usageMetricsError = undefined; + throw err; + } + // Snapshot at call time, like a real RPC whose result reflects the state + // when the request was served rather than when the caller observes it. + const snapshot = { ...this.usageMetricsResult }; + await (this.usageMetricsGates.shift() ?? this.usageMetricsGate); + return snapshot; + }, }, }; @@ -436,6 +493,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { /** Configure the mock session before {@link CopilotAgentSession.initializeSession} runs. */ configureMockSession?: (session: MockCopilotSession) => void; sessionCustomizations?: () => readonly Customization[]; + initialSessionMeta?: Record; sessionUri?: URI; chatChannelUri?: URI; resolveMcpChildId?: (serverName: string) => string | undefined; @@ -452,6 +510,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { isLaunchTokenCurrent?: () => boolean; onTurnEnded?: () => void; modelId?: string; + resume?: boolean; }): Promise<{ session: CopilotAgentSession; runtime: ICopilotSessionRuntime; @@ -497,8 +556,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { const mockSession = new MockCopilotSession(); options?.configureMockSession?.(mockSession); - const launchPlan: CopilotSessionLaunchPlan = { - kind: 'create', + const launchPlanBase = { client: { createSession: async () => mockSession as unknown as CopilotSession, resumeSession: async () => mockSession as unknown as CopilotSession, @@ -510,8 +568,20 @@ async function createAgentSession(disposables: DisposableStore, options?: { snapshot: options?.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }, shellManager: undefined, githubToken: options?.githubToken, - model: options?.modelId ? { id: options.modelId } : undefined, }; + const model = options?.modelId ? { id: options.modelId } : undefined; + const launchPlan: CopilotSessionLaunchPlan = options?.resume + ? { + ...launchPlanBase, + kind: 'resume', + workingDirectory: options.workingDirectory ?? URI.file('/workspace'), + fallback: { model }, + } + : { + ...launchPlanBase, + kind: 'create', + model, + }; let launchedRuntime: ICopilotSessionRuntime | undefined; const sessionLauncher: ICopilotSessionLauncher = { launch: async (_plan, runtime) => { @@ -589,7 +659,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { super.dispatchServerAction(channel, action); } override getSessionState(session: string) { - if (!options?.sessionCustomizations || session !== sessionUri.toString()) { + if ((!options?.sessionCustomizations && !options?.initialSessionMeta) || session !== sessionUri.toString()) { return undefined; } const state = createSessionState({ @@ -600,7 +670,26 @@ async function createAgentSession(disposables: DisposableStore, options?: { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), }); - return mergeSessionWithDefaultChat({ ...state, customizations: [...options.sessionCustomizations()] }, undefined); + return mergeSessionWithDefaultChat({ + ...state, + ...(options.initialSessionMeta ? { _meta: options.initialSessionMeta } : {}), + ...(options.sessionCustomizations ? { customizations: [...options.sessionCustomizations()] } : {}), + }, undefined); + } + override getSessionSummary(session: string) { + if (options?.initialSessionMeta && session === sessionUri.toString()) { + const now = new Date().toISOString(); + return { + resource: session, + provider: 'copilot', + title: 'Test session', + status: SessionStatus.Idle, + createdAt: now, + modifiedAt: now, + _meta: options.initialSessionMeta, + }; + } + return super.getSessionSummary(session); } }(new NullLogService())); services.set(IAgentHostStateManager, stateManager); @@ -1163,6 +1252,175 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(usage?.usage, { inputTokens: 4500, outputTokens: 0, model: 'claude-sonnet-4.6' }); }); + test('a resumed session does not bill its restored history to the first new turn', async () => { + // The SDK re-folds usage from its durable event log on resume, so `getMetrics` + // opens at the accumulated total of everything already billed. + const { session, mockSession, signals } = await createAgentSession(disposables, { + configureMockSession: mock => { mock.usageMetricsResult.totalNanoAiu = 40_000_000_000; }, + }); + + session.resetTurnState('turn-after-resume'); + mockSession.fire('assistant.usage', { + model: 'claude-opus-4.6', + inputTokens: 10, + outputTokens: 20, + copilotUsage: { totalNanoAiu: 500_000_000 }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + await timeout(0); + + const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; + // The new turn bills only its own call, while the session total carries the history. + assert.deepStrictEqual(usageActions.at(-1)?.usage._meta?.copilotUsage, { + totalNanoAiu: 500_000_000, + sessionTotalNanoAiu: 40_500_000_000, + }); + }); + + test('a failed usage read leaves the turn cost intact', async () => { + // The turn's own cost comes from the events, so a metrics outage costs only + // the session total's freshness rather than the turn's reported cost. + const { session, mockSession, signals } = await createAgentSession(disposables, { + configureMockSession: mock => { + mock.usageMetricsResult.totalNanoAiu = 40_000_000_000; + mock.usageMetricsError = new Error('rpc unavailable'); + }, + }); + + session.resetTurnState('turn-with-failed-read'); + mockSession.fire('assistant.usage', { + model: 'claude-opus-4.6', + inputTokens: 10, + outputTokens: 20, + copilotUsage: { totalNanoAiu: 500_000_000 }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + await timeout(0); + mockSession.fire('assistant.usage', { + model: 'claude-opus-4.6', + inputTokens: 10, + outputTokens: 20, + copilotUsage: { totalNanoAiu: 250_000_000 }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + await timeout(0); + + const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; + // Both calls counted toward the turn even though the first metrics read failed. + assert.deepStrictEqual(usageActions.at(-1)?.usage._meta?.copilotUsage, { + totalNanoAiu: 750_000_000, + sessionTotalNanoAiu: 40_750_000_000, + }); + }); + + test('a session total that drops after truncation is adopted rather than treated as stale', async () => { + // `history.truncate` (checkpoint restore, editing an earlier message) makes the + // SDK re-fold usage from the surviving events, so its authoritative total + // legitimately decreases. Treating that as a stale read would freeze the + // reported cost until billing climbed back past the pre-truncation figure. + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-before-truncate'); + mockSession.fire('assistant.usage', { + model: 'claude-opus-4.6', + inputTokens: 10, + outputTokens: 20, + copilotUsage: { totalNanoAiu: 10_000_000_000 }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + await timeout(0); + + // Truncation rewinds the SDK's total from 10 down to 3; the next call brings it + // to 4. A high-water guard would reject everything below 10 and freeze the + // reported cost, so the drop must be adopted. + mockSession.usageMetricsResult.totalNanoAiu = 3_000_000_000; + session.resetTurnState('turn-after-truncate'); + mockSession.fire('assistant.usage', { + model: 'claude-opus-4.6', + inputTokens: 10, + outputTokens: 20, + copilotUsage: { totalNanoAiu: 1_000_000_000 }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + await timeout(0); + + const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; + assert.deepStrictEqual(usageActions.at(-1)?.usage._meta?.copilotUsage, { + totalNanoAiu: 1_000_000_000, + sessionTotalNanoAiu: 4_000_000_000, + }); + }); + + test('overlapping usage events issue one metrics read at a time and converge on the newest', async () => { + // `getMetrics` is a real RPC round trip. Letting several overlap means an older + // one can resolve last and publish a stale session cost, and a high-water guard + // can't reject it because the total legitimately drops after a truncation. + // Serializing the reads removes the interleaving entirely and coalesces the + // redundant reads a burst of usage events would otherwise issue. + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-overlapping'); + const slowFirstRead = new DeferredPromise(); + mockSession.usageMetricsGates.push(slowFirstRead.p); + + const fireUsage = (totalNanoAiu: number) => mockSession.fire('assistant.usage', { + model: 'claude-opus-4.6', + inputTokens: 10, + outputTokens: 20, + copilotUsage: { totalNanoAiu }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + + fireUsage(500_000_000); + await timeout(0); + fireUsage(1_000_000_000); + await timeout(0); + fireUsage(500_000_000); + await timeout(0); + // The first read is still in flight, so the two later events have not started + // their own — they are waiting behind it and collapse into a single follow-up. + assert.strictEqual(mockSession.usageMetricsCalls, 1); + + slowFirstRead.complete(); + for (let i = 0; i < 5; i++) { + await timeout(0); + } + + // One follow-up read for the three events that queued behind the first, and it + // observed the newest total rather than any earlier snapshot. + assert.strictEqual(mockSession.usageMetricsCalls, 2); + session.resetTurnState('turn-after-overlap'); + fireUsage(250_000_000); + for (let i = 0; i < 5; i++) { + await timeout(0); + } + const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; + assert.strictEqual( + (usageActions.at(-1)?.usage._meta as UsageInfoMeta | undefined)?.copilotUsage?.sessionTotalNanoAiu, + 2_250_000_000, + ); + }); + + test('a turn ending while its usage refresh is in flight still bills that turn', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-racing-idle'); + const gate = new DeferredPromise(); + mockSession.usageMetricsGate = gate.p; + mockSession.fire('assistant.usage', { + model: 'claude-opus-4.6', + inputTokens: 10, + outputTokens: 20, + copilotUsage: { totalNanoAiu: 500_000_000 }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + // The SDK's terminal `session.idle` lands before the metrics RPC resolves — the + // common case, since idle follows a turn's last usage event almost immediately. + mockSession.fire('session.idle', { aborted: false } as SessionEventPayload<'session.idle'>['data']); + gate.complete(); + await timeout(0); + + const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; + // The cost belongs to the turn that incurred it, so it must survive the turn + // ending mid-refresh. The session total may lag here — the re-emit carrying it + // is dropped once the turn is no longer active — which `ChatModel.sessionCost` + // absorbs by taking the larger of the reported total and the summed turns. + assert.strictEqual((usageActions.at(-1)?.usage._meta as UsageInfoMeta | undefined)?.copilotUsage?.totalNanoAiu, 500_000_000); + }); + test('`/compact` reports the compaction call credits on the post-compaction usage', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); mockSession.compactResult = { success: true, tokensRemoved: 1200, messagesRemoved: 3, contextWindow: { currentTokens: 4500, tokenLimit: 128000, messagesLength: 7 } }; @@ -1176,18 +1434,19 @@ suite('CopilotAgentSession', () => { } as unknown as SessionEventPayload<'session.compaction_complete'>['data']); await session.send('/compact', undefined, 'turn-compact'); + await timeout(0); const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; - assert.deepStrictEqual(usageActions.map(a => ({ turnId: a.turnId, usage: a.usage })), [ - { - turnId: 'turn-compact', - usage: { model: undefined, _meta: { copilotUsage: { totalNanoAiu: 250_000_000 } } }, + assert.deepStrictEqual(usageActions.at(-1), { + type: ActionType.ChatUsage, + turnId: 'turn-compact', + usage: { + inputTokens: 4500, + outputTokens: 0, + model: undefined, + _meta: { copilotUsage: { totalNanoAiu: 250_000_000, sessionTotalNanoAiu: 250_000_000 } }, }, - { - turnId: 'turn-compact', - usage: { inputTokens: 4500, outputTokens: 0, model: undefined, _meta: { copilotUsage: { totalNanoAiu: 250_000_000 } } }, - }, - ]); + }); }); test('automatic compaction folds its credits into the turn running total', async () => { @@ -1205,6 +1464,7 @@ suite('CopilotAgentSession', () => { tokensRemoved: 1200, compactionTokensUsed: { model: 'claude-sonnet-4.6', copilotUsage: { totalNanoAiu: 250_000_000 } }, } as unknown as SessionEventPayload<'session.compaction_complete'>['data']); + await timeout(0); const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; // The compaction credits add to the turn total while the parent turn's own model and @@ -1214,7 +1474,7 @@ suite('CopilotAgentSession', () => { outputTokens: 20, model: 'claude-sonnet-4.6', cacheReadTokens: undefined, - _meta: { copilotUsage: { totalNanoAiu: 750_000_000 } }, + _meta: { copilotUsage: { totalNanoAiu: 750_000_000, sessionTotalNanoAiu: 750_000_000 } }, }); }); @@ -1227,19 +1487,23 @@ suite('CopilotAgentSession', () => { error: 'boom', compactionTokensUsed: { copilotUsage: { totalNanoAiu: 250_000_000 } }, } as unknown as SessionEventPayload<'session.compaction_complete'>['data']); + await timeout(0); assert.deepStrictEqual(getActions(signals).filter(a => a.type === ActionType.ChatUsage), []); }); - test('compaction billed outside a turn is carried onto the next turn', async () => { + test('compaction billed outside a turn shows in the session total, not on the next turn', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); - // Automatic compaction can run with no turn active (e.g. after an abort). The reducer only - // applies usage to the active turn, so the credits must be banked rather than dropped. + // Automatic compaction can run with no turn active (e.g. after an abort). It is + // nobody's turn cost — and an out-of-turn compaction usually finds a cold prompt + // cache and pays the ~12x cache-write rate, so billing it to an unrelated next + // turn would dominate that turn's footer. mockSession.fire('session.compaction_complete', { success: true, compactionTokensUsed: { model: 'claude-opus-4.6', copilotUsage: { totalNanoAiu: 133_468_375_000 } }, } as unknown as SessionEventPayload<'session.compaction_complete'>['data']); + await timeout(0); assert.deepStrictEqual(getActions(signals).filter(a => a.type === ActionType.ChatUsage), []); session.resetTurnState('turn-after-compact'); @@ -1249,6 +1513,7 @@ suite('CopilotAgentSession', () => { outputTokens: 20, copilotUsage: { totalNanoAiu: 500_000_000 }, } as unknown as SessionEventPayload<'assistant.usage'>['data']); + await timeout(0); const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; assert.deepStrictEqual(usageActions.at(-1)?.usage, { @@ -1256,21 +1521,20 @@ suite('CopilotAgentSession', () => { outputTokens: 20, model: 'claude-opus-4.6', cacheReadTokens: undefined, - _meta: { copilotUsage: { totalNanoAiu: 133_968_375_000 } }, + // The turn bills only its own call; the compaction is visible in the session total. + _meta: { copilotUsage: { totalNanoAiu: 500_000_000, sessionTotalNanoAiu: 133_968_375_000 } }, }); }); - test('carried compaction credits survive a turn that never reports usage', async () => { + test('a turn that never reports usage does not inherit out-of-turn compaction cost', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); mockSession.fire('session.compaction_complete', { success: true, compactionTokensUsed: { copilotUsage: { totalNanoAiu: 2_000_000_000 } }, } as unknown as SessionEventPayload<'session.compaction_complete'>['data']); + await timeout(0); - // `turn-1` is started and then replaced without ever reporting usage — the same shape as a - // turn that fails before its first SDK usage event or runs as a purely local slash command. - // The credits must roll forward rather than dying with it. session.resetTurnState('turn-1'); session.resetTurnState('turn-2'); mockSession.fire('assistant.usage', { @@ -1279,18 +1543,22 @@ suite('CopilotAgentSession', () => { outputTokens: 1, copilotUsage: { totalNanoAiu: 1_000_000_000 }, } as unknown as SessionEventPayload<'assistant.usage'>['data']); + await timeout(0); const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; - assert.deepStrictEqual(usageActions.at(-1)?.usage._meta, { copilotUsage: { totalNanoAiu: 3_000_000_000 } }); + assert.deepStrictEqual(usageActions.at(-1)?.usage._meta, { + copilotUsage: { totalNanoAiu: 1_000_000_000, sessionTotalNanoAiu: 3_000_000_000 }, + }); }); - test('carried compaction credits are billed to only one turn once reported', async () => { + test('each turn bills only its own calls while the session total accumulates', async () => { const { session, mockSession, signals } = await createAgentSession(disposables); mockSession.fire('session.compaction_complete', { success: true, compactionTokensUsed: { copilotUsage: { totalNanoAiu: 2_000_000_000 } }, } as unknown as SessionEventPayload<'session.compaction_complete'>['data']); + await timeout(0); session.resetTurnState('turn-1'); mockSession.fire('assistant.usage', { @@ -1299,6 +1567,7 @@ suite('CopilotAgentSession', () => { outputTokens: 1, copilotUsage: { totalNanoAiu: 1_000_000_000 }, } as unknown as SessionEventPayload<'assistant.usage'>['data']); + await timeout(0); session.resetTurnState('turn-2'); mockSession.fire('assistant.usage', { model: 'claude-opus-4.6', @@ -1306,10 +1575,17 @@ suite('CopilotAgentSession', () => { outputTokens: 1, copilotUsage: { totalNanoAiu: 1_000_000_000 }, } as unknown as SessionEventPayload<'assistant.usage'>['data']); + await timeout(0); const usageActions = getActions(signals).filter(a => a.type === ActionType.ChatUsage) as ChatUsageAction[]; - // `turn-1` reported the carry, so `turn-2` bills only its own model call. - assert.deepStrictEqual(usageActions.map(a => a.usage._meta?.copilotUsage), [{ totalNanoAiu: 3_000_000_000 }, { totalNanoAiu: 1_000_000_000 }]); + // Each turn reports its own single call; the session total carries the + // out-of-turn compaction plus both turns. + assert.deepStrictEqual(usageActions.map(a => ({ turnId: a.turnId, copilotUsage: a.usage._meta?.copilotUsage })), [ + { turnId: 'turn-1', copilotUsage: { totalNanoAiu: 1_000_000_000, sessionTotalNanoAiu: 2_000_000_000 } }, + { turnId: 'turn-1', copilotUsage: { totalNanoAiu: 1_000_000_000, sessionTotalNanoAiu: 3_000_000_000 } }, + { turnId: 'turn-2', copilotUsage: { totalNanoAiu: 1_000_000_000, sessionTotalNanoAiu: 3_000_000_000 } }, + { turnId: 'turn-2', copilotUsage: { totalNanoAiu: 1_000_000_000, sessionTotalNanoAiu: 4_000_000_000 } }, + ]); }); test('`/compact` completes the turn even when compaction reports failure', async () => { @@ -1632,6 +1908,7 @@ suite('CopilotAgentSession', () => { // `copilotUsage` is marked `asInternal` in the SDK schema so it is not on the public type, but is present at runtime. copilotUsage: { totalNanoAiu: 500_000_000, tokenDetails: [] }, } as unknown as SessionEventPayload<'assistant.usage'>['data']); + await timeout(0); mockSession.fire('assistant.usage', { model: 'claude-sonnet-4.6', inputTokens: 30, @@ -1639,34 +1916,45 @@ suite('CopilotAgentSession', () => { cost: 2, copilotUsage: { totalNanoAiu: 750_000_000, tokenDetails: [] }, } as unknown as SessionEventPayload<'assistant.usage'>['data']); + await timeout(0); const usageActions = signals .filter((s): s is IAgentActionSignal => s.kind === 'action') .map(s => s.action) .filter(a => a.type === ActionType.ChatUsage); - assert.deepStrictEqual(usageActions.map(a => a.usage), [ - { - inputTokens: 10, - outputTokens: 20, - model: 'claude-sonnet-4.6', - cacheReadTokens: 5, - _meta: { - cost: 2, - copilotUsage: { totalNanoAiu: 500_000_000, tokenDetails: [] }, - }, + // The turn's running total and the session total both come from the SDK's usage + // metrics, so they are reported on the enrichment re-emit that follows each event. + assert.deepStrictEqual(usageActions.at(-1)?.usage, { + inputTokens: 30, + outputTokens: 40, + model: 'claude-sonnet-4.6', + cacheReadTokens: undefined, + _meta: { + cost: 2, + copilotUsage: { totalNanoAiu: 1_250_000_000, sessionTotalNanoAiu: 1_250_000_000 }, }, - { - inputTokens: 30, - outputTokens: 40, - model: 'claude-sonnet-4.6', - cacheReadTokens: undefined, - _meta: { - cost: 2, - copilotUsage: { totalNanoAiu: 1_250_000_000, tokenDetails: [] }, - }, + }); + }); + + test('restores non-Opus prompt cache expiration from usage metrics on initialize', async () => { + const cacheExpiresAt = '2026-07-24T12:00:00.000Z'; + const { dispatchedActions } = await createAgentSession(disposables, { + resume: true, + configureMockSession: session => { + session.usageMetricsResult.currentModel = 'gpt-5.4'; + session.usageMetricsResult.modelMetrics['gpt-5.4'] = { cacheExpiresAt }; }, - ]); + }); + + const promptCaches = dispatchedActions + .filter(action => action.type === ActionType.SessionMetaChanged) + .map(action => readSessionPromptCacheState(action._meta)) + .filter(cache => cache !== undefined); + assert.deepStrictEqual(promptCaches, [{ + modelId: 'gpt-5.4', + cacheExpiresAt, + }]); }); test('updates prompt cache expiration from main-agent usage only', async () => { @@ -1684,6 +1972,7 @@ suite('CopilotAgentSession', () => { cacheExpiresAt: '2026-07-24T12:10:00.000Z', parentToolCallId: 'subagent-tool-call', }); + await timeout(0); const promptCaches = dispatchedActions .filter(action => action.type === ActionType.SessionMetaChanged) @@ -1695,7 +1984,56 @@ suite('CopilotAgentSession', () => { }]); }); - test('clears prompt cache expiration when the main agent model does not report one', async () => { + test('preserves prompt cache expiration when later usage omits a cache update', async () => { + const { mockSession, dispatchedActions } = await createAgentSession(disposables); + mockSession.fire('assistant.usage', { + model: 'claude-sonnet-4.6', + inputTokens: 100, + outputTokens: 10, + cacheExpiresAt: '2026-07-24T12:00:00.000Z', + }); + await timeout(0); + mockSession.fire('assistant.usage', { + model: 'claude-sonnet-4.6', + inputTokens: 50, + outputTokens: 5, + }); + await timeout(0); + + const promptCaches = dispatchedActions + .filter(action => action.type === ActionType.SessionMetaChanged) + .map(action => readSessionPromptCacheState(action._meta)) + .filter(cache => cache !== undefined); + assert.deepStrictEqual(promptCaches, [{ + modelId: 'claude-sonnet-4.6', + cacheExpiresAt: '2026-07-24T12:00:00.000Z', + }]); + }); + + test('preserves restored prompt cache metadata after a resume metrics failure', async () => { + const cacheExpiresAt = '2026-07-24T12:00:00.000Z'; + const initialSessionMeta = withSessionPromptCacheState(undefined, { modelId: 'claude-sonnet-4.6', cacheExpiresAt }); + assert.ok(initialSessionMeta); + const { mockSession, dispatchedActions } = await createAgentSession(disposables, { + resume: true, + initialSessionMeta, + configureMockSession: session => { + session.usageMetricsResult.currentModel = 'claude-sonnet-4.6'; + session.usageMetricsResult.modelMetrics['claude-sonnet-4.6'] = { cacheExpiresAt }; + session.usageMetricsError = new Error('rpc unavailable'); + }, + }); + mockSession.fire('assistant.usage', { + model: 'claude-sonnet-4.6', + inputTokens: 50, + outputTokens: 5, + }); + await timeout(0); + + assert.deepStrictEqual(dispatchedActions.filter(action => action.type === ActionType.SessionMetaChanged), []); + }); + + test('clears prompt cache expiration when switching to a model without cached state', async () => { const { mockSession, dispatchedActions } = await createAgentSession(disposables); mockSession.fire('assistant.usage', { model: 'claude-opus-4.8', @@ -1703,11 +2041,62 @@ suite('CopilotAgentSession', () => { outputTokens: 10, cacheExpiresAt: '2026-07-24T12:00:00.000Z', }); + mockSession.fire('session.model_change', { + previousModel: 'claude-opus-4.8', + newModel: 'gpt-5.4', + }); + await timeout(0); + + const promptCaches = dispatchedActions + .filter(action => action.type === ActionType.SessionMetaChanged) + .map(action => readSessionPromptCacheState(action._meta)); + assert.deepStrictEqual(promptCaches, [{ + modelId: 'claude-opus-4.8', + cacheExpiresAt: '2026-07-24T12:00:00.000Z', + }, undefined]); + }); + + test('preserves prompt cache expiration for same-model configuration changes', async () => { + const { mockSession, dispatchedActions } = await createAgentSession(disposables); + mockSession.fire('assistant.usage', { + model: 'claude-opus-4.8', + inputTokens: 100, + outputTokens: 10, + cacheExpiresAt: '2026-07-24T12:00:00.000Z', + }); + await timeout(0); + mockSession.fire('session.model_change', { + previousModel: 'claude-opus-4.8', + newModel: 'claude-opus-4.8', + reasoningEffort: 'high', + }); + await timeout(0); + + const promptCaches = dispatchedActions + .filter(action => action.type === ActionType.SessionMetaChanged) + .map(action => readSessionPromptCacheState(action._meta)); + assert.deepStrictEqual(promptCaches, [{ + modelId: 'claude-opus-4.8', + cacheExpiresAt: '2026-07-24T12:00:00.000Z', + }]); + }); + + test('clears another model cache when a usage metrics refresh fails', async () => { + const { mockSession, dispatchedActions } = await createAgentSession(disposables); + mockSession.fire('assistant.usage', { + model: 'claude-opus-4.8', + inputTokens: 100, + outputTokens: 10, + cacheExpiresAt: '2026-07-24T12:00:00.000Z', + }); + await timeout(0); + mockSession.usageMetricsError = new Error('rpc unavailable'); mockSession.fire('assistant.usage', { model: 'gpt-5.4', inputTokens: 50, outputTokens: 5, }); + await timeout(0); const promptCaches = dispatchedActions .filter(action => action.type === ActionType.SessionMetaChanged) @@ -1791,15 +2180,17 @@ suite('CopilotAgentSession', () => { outputTokens: 20, copilotUsage: { totalNanoAiu: 500_000_000, tokenDetails: [] }, } as unknown as SessionEventPayload<'assistant.usage'>['data']); + await timeout(0); - // Subagent usage (its agentId) is reported twice: folded into the parent - // aggregate AND emitted to the subagent's child session as its component. + // Subagent usage (its agentId) is emitted to the subagent's child session as + // its own component; the parent aggregate grows via the SDK's session metrics. mockSession.fire('assistant.usage', { model: 'gpt-5.5', inputTokens: 5, outputTokens: 7, copilotUsage: { totalNanoAiu: 200_000_000, tokenDetails: [] }, } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-1' }); + await timeout(0); mockSession.fire('assistant.usage', { model: 'gpt-5.5', @@ -1807,6 +2198,7 @@ suite('CopilotAgentSession', () => { outputTokens: 8, copilotUsage: { totalNanoAiu: 300_000_000, tokenDetails: [] }, } as unknown as SessionEventPayload<'assistant.usage'>['data'], { agentId: 'agent-1' }); + await timeout(0); const usageSignals = signals.flatMap(signal => { if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatUsage) { @@ -1821,17 +2213,18 @@ suite('CopilotAgentSession', () => { }]; }); + // The parent aggregate always keeps the parent's own model/context tokens, and + // its credits cover every call the turn caused (its own plus every subagent's). + // They land on the synchronous emit, so a turn ending mid-refresh cannot lose them. assert.deepStrictEqual(usageSignals, [ - // Parent-only call → parent aggregate. { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 500_000_000 }, - // First subagent call → parent aggregate grows but keeps the parent - // model/context, plus the subagent component carries the child model. + { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 500_000_000 }, { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 700_000_000 }, { parentToolCallId: 'tc-subagent', model: 'gpt-5.5', inputTokens: 5, outputTokens: 7, totalNanoAiu: 200_000_000 }, - // Second subagent call → parent aggregate grows but keeps the parent - // model/context, plus the subagent component. + { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 700_000_000 }, { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 1_000_000_000 }, { parentToolCallId: 'tc-subagent', model: 'gpt-5.5', inputTokens: 6, outputTokens: 8, totalNanoAiu: 500_000_000 }, + { parentToolCallId: undefined, model: 'claude-opus-4.8', inputTokens: 10, outputTokens: 20, totalNanoAiu: 1_000_000_000 }, ]); }); @@ -1996,8 +2389,8 @@ suite('CopilotAgentSession', () => { }); test('auto-approves read permission for session-state plan files', async () => { - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - process.env['XDG_STATE_HOME'] = '/mock-state-home'; + const previousCopilotHome = process.env['COPILOT_HOME']; + process.env['COPILOT_HOME'] = '/mock-state-home/.copilot'; try { const { runtime, signals } = await createAgentSession(disposables); const result = await runtime.handlePermissionRequest({ @@ -2009,17 +2402,17 @@ suite('CopilotAgentSession', () => { assert.strictEqual(result.kind, 'approve-once'); assert.strictEqual(signals.length, 0); } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; + if (previousCopilotHome === undefined) { + delete process.env['COPILOT_HOME']; } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; + process.env['COPILOT_HOME'] = previousCopilotHome; } } }); test('resolves native environment through INativeEnvironmentService registration', async () => { - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - delete process.env['XDG_STATE_HOME']; + const previousCopilotHome = process.env['COPILOT_HOME']; + delete process.env['COPILOT_HOME']; try { const { runtime, signals } = await createAgentSession(disposables, { environmentServiceRegistration: 'native' }); const result = await runtime.handlePermissionRequest({ @@ -2031,17 +2424,17 @@ suite('CopilotAgentSession', () => { assert.strictEqual(result.kind, 'approve-once'); assert.strictEqual(signals.length, 0); } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; + if (previousCopilotHome === undefined) { + delete process.env['COPILOT_HOME']; } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; + process.env['COPILOT_HOME'] = previousCopilotHome; } } }); test('logs and rethrows permission failures', async () => { - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - delete process.env['XDG_STATE_HOME']; + const previousCopilotHome = process.env['COPILOT_HOME']; + delete process.env['COPILOT_HOME']; const logService = new CapturingLogService(); try { const { runtime } = await createAgentSession(disposables, { @@ -2062,10 +2455,10 @@ suite('CopilotAgentSession', () => { assert.ok(entry.first instanceof TypeError); assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed to handle permission request: kind=read, toolCallId=tc-read-plan-missing-env'); } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; + if (previousCopilotHome === undefined) { + delete process.env['COPILOT_HOME']; } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; + process.env['COPILOT_HOME'] = previousCopilotHome; } } }); @@ -2120,8 +2513,8 @@ suite('CopilotAgentSession', () => { }); test('auto-approves write permission for session-state plan files', async () => { - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - process.env['XDG_STATE_HOME'] = '/mock-state-home'; + const previousCopilotHome = process.env['COPILOT_HOME']; + process.env['COPILOT_HOME'] = '/mock-state-home/.copilot'; try { const { runtime, signals } = await createAgentSession(disposables); const result = await runtime.handlePermissionRequest({ @@ -2133,17 +2526,17 @@ suite('CopilotAgentSession', () => { assert.strictEqual(result.kind, 'approve-once'); assert.strictEqual(signals.length, 0); } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; + if (previousCopilotHome === undefined) { + delete process.env['COPILOT_HOME']; } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; + process.env['COPILOT_HOME'] = previousCopilotHome; } } }); test('does not auto-approve session-state files from another session', async () => { - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - process.env['XDG_STATE_HOME'] = '/mock-state-home'; + const previousCopilotHome = process.env['COPILOT_HOME']; + process.env['COPILOT_HOME'] = '/mock-state-home/.copilot'; try { const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); const resultPromise = runtime.handlePermissionRequest({ @@ -2159,17 +2552,17 @@ suite('CopilotAgentSession', () => { const result = await resultPromise; assert.strictEqual(result.kind, 'approve-once'); } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; + if (previousCopilotHome === undefined) { + delete process.env['COPILOT_HOME']; } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; + process.env['COPILOT_HOME'] = previousCopilotHome; } } }); test('does not auto-approve traversal paths that escape the session-state directory', async () => { - const previousXdgStateHome = process.env['XDG_STATE_HOME']; - process.env['XDG_STATE_HOME'] = '/mock-state-home'; + const previousCopilotHome = process.env['COPILOT_HOME']; + process.env['COPILOT_HOME'] = '/mock-state-home/.copilot'; try { const { session, runtime, signals, waitForSignal } = await createAgentSession(disposables); const sessionDir = join('/mock-state-home', '.copilot', 'session-state', 'test-session-1'); @@ -2186,10 +2579,10 @@ suite('CopilotAgentSession', () => { const result = await resultPromise; assert.strictEqual(result.kind, 'approve-once'); } finally { - if (previousXdgStateHome === undefined) { - delete process.env['XDG_STATE_HOME']; + if (previousCopilotHome === undefined) { + delete process.env['COPILOT_HOME']; } else { - process.env['XDG_STATE_HOME'] = previousXdgStateHome; + process.env['COPILOT_HOME'] = previousCopilotHome; } } }); @@ -3466,6 +3859,17 @@ suite('CopilotAgentSession', () => { startsTurn: false, }); + assert.deepStrictEqual(buildCopilotSystemNotification({ + ...base, + data: { + content: '\nExternal host ping\n', + kind: { type: 'unclassified', metadata: { source: 'host' } }, + }, + }), { + messageText: 'External host ping', + startsTurn: true, + }); + assert.strictEqual(buildCopilotSystemNotification({ ...base, data: { @@ -3625,6 +4029,190 @@ suite('CopilotAgentSession', () => { } }); + test('tool call deltas start once, accumulate buffered input, and finalize at tool start', async () => { + const { mockSession, signals } = await createAgentSession(disposables); + mockSession.fire('assistant.tool_call_delta', { + toolCallId: 'tc-stream', + inputDelta: '{"command":"npm ', + }); + assert.strictEqual(signals.length, 0); + + mockSession.fire('assistant.tool_call_delta', { + toolCallId: 'tc-stream', + toolName: 'bash', + inputDelta: 'test","description":"Run', + }); + await timeout(STREAMING_TOOL_DISPLAY_INTERVAL_MS + 10); + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-stream', + toolName: 'bash', + arguments: { command: 'npm test', description: 'Run all tests' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + + const actions = getActions(signals); + const starts = actions.filter(action => action.type === ActionType.ChatToolCallStart) as ChatToolCallStartAction[]; + const deltas = actions.filter(action => action.type === ActionType.ChatToolCallDelta) as ChatToolCallDeltaAction[]; + const ready = actions.find(action => action.type === ActionType.ChatToolCallReady) as ChatToolCallReadyAction | undefined; + assert.deepStrictEqual({ + starts: starts.map(action => ({ toolCallId: action.toolCallId, toolName: action.toolName })), + deltas: deltas.map(action => ({ + content: action.content, + hasInvocationMessage: action.invocationMessage !== undefined, + })), + ready: ready && { toolCallId: ready.toolCallId, toolInput: ready.toolInput, intention: ready.intention }, + }, { + starts: [{ toolCallId: 'tc-stream', toolName: 'bash' }], + deltas: [{ content: '', hasInvocationMessage: true }], + ready: { toolCallId: 'tc-stream', toolInput: 'npm test', intention: 'Run all tests' }, + }); + }); + + test('edit tool deltas progressively refine file and line-count details', async () => { + const { mockSession, signals } = await createAgentSession(disposables); + mockSession.fire('assistant.tool_call_delta', { + toolCallId: 'tc-edit-stream', + toolName: 'edit', + inputDelta: '{"path":"/repo/file.ts","old_str":"one\\ntwo"', + }); + mockSession.fire('assistant.tool_call_delta', { + toolCallId: 'tc-edit-stream', + toolName: 'edit', + inputDelta: ',"new_str":"one\\nupdated\\nthree"', + }); + await timeout(STREAMING_TOOL_DISPLAY_INTERVAL_MS + 10); + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-edit-stream', + toolName: 'edit', + arguments: { + path: '/repo/file.ts', + old_str: 'one\ntwo', + new_str: 'one\nupdated\nthree', + }, + } as SessionEventPayload<'tool.execution_start'>['data']); + + const actions = getActions(signals); + const deltas = actions.filter(action => action.type === ActionType.ChatToolCallDelta) as ChatToolCallDeltaAction[]; + const ready = actions.find(action => action.type === ActionType.ChatToolCallReady) as ChatToolCallReadyAction | undefined; + assert.deepStrictEqual({ + deltas: deltas.flatMap(action => { + const message = action.invocationMessage; + const text = typeof message === 'string' ? message : message?.markdown; + return text ? [text] : []; + }), + ready: typeof ready?.invocationMessage === 'string' ? ready.invocationMessage : ready?.invocationMessage.markdown, + }, { + deltas: [ + 'Replacing 2 lines in [file.ts](file:///repo/file.ts)', + 'Replacing 2 lines with 3 lines in [file.ts](file:///repo/file.ts)', + ], + ready: 'Editing [file.ts](file:///repo/file.ts)', + }); + }); + + test('raw apply_patch deltas stream line counts and resolved files', async () => { + const { mockSession, signals } = await createAgentSession(disposables, { + workingDirectory: URI.file('/workspace'), + }); + mockSession.fire('assistant.tool_call_delta', { + toolCallId: 'tc-patch-stream', + toolName: 'apply_patch', + inputDelta: [ + '*** Begin Patch', + '*** Update File: src/file.ts', + '@@', + '-old', + '+new', + '*** End Patch', + ].join('\n'), + }); + await timeout(STREAMING_TOOL_DISPLAY_INTERVAL_MS + 10); + + const delta = getActions(signals).find(action => action.type === ActionType.ChatToolCallDelta) as ChatToolCallDeltaAction | undefined; + const message = delta?.invocationMessage; + assert.strictEqual( + typeof message === 'string' ? message : message?.markdown, + 'Generating patch (6 lines) in [file.ts](file:///workspace/src/file.ts)', + ); + }); + + test('MCP tool deltas stream before final contributor metadata arrives', async () => { + const { mockSession, signals } = await createAgentSession(disposables, { + configureMockSession: mock => { + mock.mcpListResult = { servers: [{ name: 'docs', status: 'connected' }] }; + }, + }); + mockSession.fire('session.mcp_server_status_changed', { + serverName: 'docs', + status: 'connected', + } as SessionEventPayload<'session.mcp_server_status_changed'>['data']); + mockSession.fire('assistant.tool_call_delta', { + toolCallId: 'tc-stream-mcp', + toolName: 'mcp_tool', + inputDelta: '{"topic":"metadata"}', + }); + await timeout(STREAMING_TOOL_DISPLAY_INTERVAL_MS + 10); + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-stream-mcp', + toolName: 'mcp_tool', + mcpServerName: 'docs', + arguments: { topic: 'metadata' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + + const actions = getActions(signals); + const starts = actions.filter(action => action.type === ActionType.ChatToolCallStart) as ChatToolCallStartAction[]; + const deltas = actions.filter(action => action.type === ActionType.ChatToolCallDelta) as ChatToolCallDeltaAction[]; + const ready = actions.find(action => action.type === ActionType.ChatToolCallReady) as ChatToolCallReadyAction | undefined; + assert.deepStrictEqual({ + startCount: starts.length, + startContributor: starts[0]?.contributor, + deltas: deltas.map(action => ({ + content: action.content, + hasInvocationMessage: action.invocationMessage !== undefined, + })), + readyContributor: ready?.contributor, + }, { + startCount: 1, + startContributor: undefined, + deltas: [{ content: '', hasInvocationMessage: true }], + readyContributor: { + kind: ToolCallContributorKind.MCP, + customizationId: 'mcp-top-level:copilot:test-session-1:docs', + }, + }); + }); + + test('full assistant message does not duplicate markdown emitted before a tool delta', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-stream-dedup'); + mockSession.fire('assistant.message_delta', { + deltaContent: 'I will inspect the file.', + } as SessionEventPayload<'assistant.message_delta'>['data']); + mockSession.fire('assistant.tool_call_delta', { + toolCallId: 'tc-dedup', + toolName: 'view', + inputDelta: '{"path":"/workspace/file.ts"}', + }); + mockSession.fire('assistant.message', { + messageId: 'msg-dedup', + content: 'I will inspect the file.', + toolRequests: [{ + toolCallId: 'tc-dedup', + name: 'view', + arguments: { path: '/workspace/file.ts' }, + type: 'function', + }], + } as SessionEventPayload<'assistant.message'>['data']); + + const markdownParts = getActions(signals).flatMap(action => + action.type === ActionType.ChatResponsePart && action.part.kind === ResponsePartKind.Markdown + ? [{ kind: action.part.kind, content: action.part.content }] + : []); + assert.deepStrictEqual(markdownParts, [{ + kind: ResponsePartKind.Markdown, + content: 'I will inspect the file.', + }]); + }); + test('tool_start carries MCP App UI metadata from the SDK', async () => { const { mockSession, signals } = await createAgentSession(disposables); mockSession.fire('tool.execution_start', { @@ -3846,8 +4434,9 @@ suite('CopilotAgentSession', () => { ]); }); - test('tool partial results reset the channel when the runtime rewrites its snapshot', async () => { - const { mockSession, terminalManager } = await createAgentSession(disposables); + test('truncated shell output streams through marker, rolling-tail, and completion transitions', async () => { + const { session, mockSession, signals, waitForSignal, terminalManager } = await createAgentSession(disposables); + session.resetTurnState('turn-truncated-stream'); const terminalUri = 'agenthost-terminal://shell/test-session-1/tc-rewrite'; mockSession.fire('tool.execution_start', { @@ -3857,27 +4446,59 @@ suite('CopilotAgentSession', () => { } as SessionEventPayload<'tool.execution_start'>['data']); mockSession.fire('tool.execution_partial_result', { toolCallId: 'tc-rewrite', - partialOutput: 'tick 1\n', - } as SessionEventPayload<'tool.execution_partial_result'>['data']); - // Once output is truncated the runtime rewrites its snapshot (a - // truncation marker under the emit cap, a rolling tail past the - // large-output threshold), so it stops being prefix-stable. - mockSession.fire('tool.execution_partial_result', { - toolCallId: 'tc-rewrite', - partialOutput: 'tick 1\n[...truncated 42 lines...]\n', + partialOutput: 'line 1\nline 498\nline 499\n', } as SessionEventPayload<'tool.execution_partial_result'>['data']); mockSession.fire('tool.execution_partial_result', { toolCallId: 'tc-rewrite', - partialOutput: 'tick 1\n[...truncated 99 lines...]\n', + partialOutput: 'line 1\nline 498\nline 499\n\n', } as SessionEventPayload<'tool.execution_partial_result'>['data']); + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-rewrite', + partialOutput: 'line 1\nline 498\nline 499\n\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-rewrite', + partialOutput: 'line 498\nline 499\nline 500\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + mockSession.fire('tool.execution_partial_result', { + toolCallId: 'tc-rewrite', + partialOutput: 'line 499\nline 500\nline 501\n', + } as SessionEventPayload<'tool.execution_partial_result'>['data']); + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-rewrite', + success: true, + result: { + content: 'Output too large', + contents: [{ + type: 'shell_exit', + shellId: '0', + exitCode: 0, + outputPreview: 'line 1\nline 2\n', + outputTruncated: true, + }], + }, + } as SessionEventPayload<'tool.execution_complete'>['data']); + await waitForSignal(signal => isAction(signal, ActionType.ChatToolCallComplete)); - assert.deepStrictEqual({ data: terminalManager.outputTerminalData, resets: terminalManager.outputTerminalResets }, { + const completed = getActions(signals).find(action => action.type === ActionType.ChatToolCallComplete) as ChatToolCallCompleteAction; + const terminalResult = completed.result.content?.find(content => content.type === ToolResultContentType.Terminal) as ToolResultTerminalContent | undefined; + assert.deepStrictEqual({ + data: terminalManager.outputTerminalData, + resets: terminalManager.outputTerminalResets, + finalized: terminalManager.outputTerminalsFinalized, + disposed: terminalManager.disposedTerminals, + result: terminalResult?.result, + }, { data: [ - { uri: terminalUri, data: 'tick 1\n' }, - { uri: terminalUri, data: '[...truncated 42 lines...]\n' }, - { uri: terminalUri, data: 'tick 1\n[...truncated 99 lines...]\n' }, + { uri: terminalUri, data: 'line 1\nline 498\nline 499\n' }, + { uri: terminalUri, data: '\n' }, + { uri: terminalUri, data: 'line 500\n' }, + { uri: terminalUri, data: 'line 501\n' }, ], - resets: [terminalUri], + resets: [], + finalized: [{ uri: terminalUri, exitCode: 0 }], + disposed: [terminalUri], + result: { exitCode: 0, preview: 'line 1\nline 2\n', truncated: true }, }); }); @@ -5345,7 +5966,7 @@ suite('CopilotAgentSession', () => { }); }); - test('autopilot auto-answers a free-form question without firing a progress event', async () => { + test('autopilot auto-answers a free-form question and records it in history', async () => { const { runtime, signals } = await createAgentSession(disposables, { configValues: { [SessionConfigKey.Mode]: 'autopilot' }, }); @@ -5360,7 +5981,18 @@ suite('CopilotAgentSession', () => { // the user typed something custom. assert.strictEqual(result.answer, 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.'); assert.strictEqual(result.wasFreeform, true); - assert.strictEqual(signals.length, 0); + assert.deepStrictEqual(getActions(signals).map(action => action.type), [ + ActionType.ChatInputRequested, + ActionType.ChatInputCompleted, + ]); + const completed = getActions(signals)[1]; + assert.deepStrictEqual(completed.type === ActionType.ChatInputCompleted ? Object.values(completed.answers ?? {}) : [], [{ + state: ChatInputAnswerState.Submitted, + value: { + kind: ChatInputAnswerValueKind.Text, + value: result.answer, + }, + }]); }); test('autopilot does not auto-answer when mode is not "autopilot"', async () => { @@ -5383,7 +6015,7 @@ suite('CopilotAgentSession', () => { assert.ok(isAction(signals[0], ActionType.ChatInputRequested)); }); - test('auto-reply auto-answers a question without firing a progress event', async () => { + test('auto-reply auto-answers a question and records it in history', async () => { // `chat.autoReply` is forwarded as the autoReplyEnabled root config. // Even in interactive mode it must short-circuit like autopilot. const { runtime, signals } = await createAgentSession(disposables, { @@ -5398,7 +6030,18 @@ suite('CopilotAgentSession', () => { assert.strictEqual(result.answer, 'The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request.'); assert.strictEqual(result.wasFreeform, true); - assert.strictEqual(signals.length, 0); + assert.deepStrictEqual(getActions(signals).map(action => action.type), [ + ActionType.ChatInputRequested, + ActionType.ChatInputCompleted, + ]); + const completed = getActions(signals)[1]; + assert.deepStrictEqual(completed.type === ActionType.ChatInputCompleted ? Object.values(completed.answers ?? {}) : [], [{ + state: ChatInputAnswerState.Submitted, + value: { + kind: ChatInputAnswerValueKind.Text, + value: result.answer, + }, + }]); }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index d319a570f34..d0d85dd7526 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import type { IAgentHostGitService, IBranch, IDefaultBranch } from '../../common/agentHostGitService.js'; +import { tryResolvePrimaryWorktreeRoot, type IAgentHostGitService, type IBranch, type IDefaultBranch } from '../../common/agentHostGitService.js'; import { projectFromCopilotContext, projectFromRepository, resolveGitProject } from '../../node/copilot/copilotGitProject.js'; class TestAgentHostGitService implements IAgentHostGitService { @@ -14,6 +14,7 @@ class TestAgentHostGitService implements IAgentHostGitService { repositoryRoot: URI | undefined; worktreeRoots: URI[] = []; + worktreeRootCalls = 0; async getCurrentBranch(): Promise { return undefined; } async getDefaultBranch(): Promise { return undefined; } @@ -21,7 +22,10 @@ class TestAgentHostGitService implements IAgentHostGitService { async getRefs(): Promise { return []; } async getBranches(): Promise { return []; } async getRepositoryRoot(): Promise { return this.repositoryRoot; } - async getWorktreeRoots(): Promise { return this.worktreeRoots; } + async getWorktreeRoots(): Promise { + this.worktreeRootCalls++; + return this.worktreeRoots; + } async addWorktree(): Promise { } async copyWorktreeIncludeFiles(): Promise { } async addExistingWorktree(): Promise { } @@ -89,6 +93,26 @@ suite('Copilot Git Project', () => { }); }); + test('deduplicates concurrent resolution across linked worktrees', async () => { + const primaryRoot = URI.file('/workspace/source-repo'); + const checkoutA = URI.file('/workspace/source-repo.worktrees/a'); + const checkoutB = URI.file('/workspace/source-repo.worktrees/b'); + gitService.worktreeRoots = [primaryRoot, checkoutA, checkoutB]; + + const roots = await Promise.all([ + tryResolvePrimaryWorktreeRoot(gitService, checkoutA), + tryResolvePrimaryWorktreeRoot(gitService, checkoutB), + ]); + + assert.deepStrictEqual({ + worktreeRootCalls: gitService.worktreeRootCalls, + roots: roots.map(root => root?.toString()), + }, { + worktreeRootCalls: 1, + roots: [primaryRoot.toString(), primaryRoot.toString()], + }); + }); + test('returns undefined outside a git working tree', async () => { assert.strictEqual(await resolveGitProject(URI.file('/workspace/plain-folder'), gitService), undefined); }); diff --git a/src/vs/platform/agentHost/test/node/copilotNonPtyShellTerminals.test.ts b/src/vs/platform/agentHost/test/node/copilotNonPtyShellTerminals.test.ts new file mode 100644 index 00000000000..37f1723c3b7 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotNonPtyShellTerminals.test.ts @@ -0,0 +1,265 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { deepStrictEqual, ok, strictEqual } from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NonPtyShellTerminalStreams } from '../../node/copilot/copilotNonPtyShellTerminals.js'; +import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; + +suite('NonPtyShellTerminalStreams', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let manager: TestAgentHostTerminalManager; + let streams: NonPtyShellTerminalStreams; + + setup(() => { + manager = store.add(new TestAgentHostTerminalManager()); + streams = store.add(new NonPtyShellTerminalStreams(URI.parse('agenthost-session://test/session-1'), manager)); + }); + + function channelContent(): string { + return manager.outputTerminalData.map(d => d.data).join(''); + } + + suite('rolling-tail snapshot stitching', () => { + test('appends only the unseen suffix when the snapshot is a rolling tail, without resetting', () => { + streams.track('call-1', 'shell'); + streams.append('call-1', 'line 1\r\nline 2\r\nline 3\r\n'); + streams.append('call-1', 'line 2\r\nline 3\r\nline 4\r\n'); + streams.append('call-1', 'line 4\r\nline 5\r\nline 6\r\n'); + + deepStrictEqual(manager.outputTerminalResets, [], 'rolling tails must not reset the channel'); + strictEqual(channelContent(), 'line 1\r\nline 2\r\nline 3\r\nline 4\r\nline 5\r\nline 6\r\n'); + }); + + test('truncated completion preview does not discard the streamed transcript', () => { + streams.track('call-2', 'shell'); + streams.append('call-2', 'line 1\r\nline 2\r\nline 3\r\n'); + streams.append('call-2', 'line 3\r\nline 4\r\nline 5\r\n'); + + const completion = streams.completeToolCall('call-2', undefined, { + shellId: 'shell-1', + result: { exitCode: 0, preview: 'line 4\r\nline 5\r\n', truncated: true } + }); + + ok(completion); + deepStrictEqual(manager.outputTerminalResets, []); + strictEqual(channelContent(), 'line 1\r\nline 2\r\nline 3\r\nline 4\r\nline 5\r\n'); + deepStrictEqual(manager.outputTerminalsFinalized, [{ uri: completion.uri, exitCode: 0 }]); + }); + + test('preserves the transcript across truncation marker rewrites and disjoint rolling tails', () => { + streams.track('call-3', 'shell'); + streams.append('call-3', 'line 1\r\nline 498\r\nline 499\r\n'); + streams.append('call-3', 'line 1\r\nline 498\r\nline 499\r\n\n'); + streams.append('call-3', 'line 1\r\nline 498\r\nline 499\r\n\n'); + streams.append('call-3', 'line 498\r\nline 499\r\nline 500\r\n'); + streams.append('call-3', 'line 499\r\nline 500\r\nline 501\r\n'); + streams.append('call-3', 'line 700\r\nline 701\r\nline 702\r\n'); + + deepStrictEqual({ + resets: manager.outputTerminalResets, + content: channelContent(), + }, { + resets: [], + content: [ + 'line 1\r\nline 498\r\nline 499\r\n\n', + 'line 500\r\n', + 'line 501\r\n', + 'line 700\r\nline 701\r\nline 702\r\n', + ].join(''), + }); + }); + + test('recognizes the single-line character truncation marker', () => { + streams.track('call-4', 'shell'); + streams.append('call-4', 'abcdefghij'); + streams.append('call-4', 'abcdefghij'); + streams.append('call-4', 'abcdefghij'); + + deepStrictEqual({ + resets: manager.outputTerminalResets, + content: channelContent(), + }, { + resets: [], + content: 'abcdefghij', + }); + }); + + test('preserves a direct transition to disjoint shorter tails', () => { + streams.track('call-5', 'shell'); + streams.append('call-5', 'alpha beta gamma\r\n'); + streams.append('call-5', 'tail one\r\n'); + streams.append('call-5', 'tail two\r\n'); + + deepStrictEqual({ + resets: manager.outputTerminalResets, + content: channelContent(), + }, { + resets: [], + content: 'alpha beta gamma\r\ntail one\r\ntail two\r\n', + }); + }); + + test('does not append a truncated completion preview after streamed output', () => { + streams.track('call-6', 'shell'); + streams.append('call-6', 'line 1\r\nline 2\r\n\n'); + streams.append('call-6', 'line 498\r\nline 499\r\nline 500\r\n'); + + streams.completeToolCall('call-6', undefined, { + shellId: 'shell-1', + result: { exitCode: 0, preview: 'line 1\r\nline 2\r\n', truncated: true } + }); + + strictEqual(channelContent(), [ + 'line 1\r\nline 2\r\n\n', + 'line 498\r\nline 499\r\nline 500\r\n', + ].join('')); + }); + + test('seeds a zero-partial terminal from its truncated completion preview', () => { + streams.track('call-7', 'shell'); + + streams.completeToolCall('call-7', undefined, { + shellId: 'shell-1', + result: { exitCode: 0, preview: 'line 1\r\nline 2\r\n', truncated: true } + }); + + strictEqual(channelContent(), 'line 1\r\nline 2\r\n'); + }); + + test('replaces a truncated stream with an authoritative non-truncated completion preview', () => { + streams.track('call-8', 'shell'); + const appended = streams.append('call-8', 'head\r\n\n'); + ok(appended); + + streams.completeToolCall('call-8', undefined, { + shellId: 'shell-1', + result: { exitCode: 0, preview: 'complete output\r\n', truncated: false } + }); + + deepStrictEqual({ + resets: manager.outputTerminalResets, + data: manager.outputTerminalData, + }, { + resets: [appended.uri], + data: [ + { uri: appended.uri, data: 'head\r\n\n' }, + { uri: appended.uri, data: 'complete output\r\n' }, + ], + }); + }); + + test('clears stale streamed output when the authoritative completion preview is empty', () => { + streams.track('call-9', 'shell'); + const appended = streams.append('call-9', 'stale output\r\n'); + ok(appended); + + streams.completeToolCall('call-9', undefined, { + shellId: 'shell-1', + result: { exitCode: 0, preview: '', truncated: false } + }); + + deepStrictEqual({ + resets: manager.outputTerminalResets, + data: manager.outputTerminalData, + }, { + resets: [appended.uri], + data: [{ uri: appended.uri, data: 'stale output\r\n' }], + }); + }); + + test('appends a prefix-stable authoritative completion preview', () => { + streams.track('call-10', 'shell'); + const appended = streams.append('call-10', 'line 1\r\n'); + ok(appended); + + streams.completeToolCall('call-10', undefined, { + shellId: 'shell-1', + result: { exitCode: 0, preview: 'line 1\r\nline 2\r\n', truncated: false } + }); + + deepStrictEqual({ + resets: manager.outputTerminalResets, + data: manager.outputTerminalData, + }, { + resets: [], + data: [ + { uri: appended.uri, data: 'line 1\r\n' }, + { uri: appended.uri, data: 'line 2\r\n' }, + ], + }); + }); + + test('an unrelated rewrite still resets the channel', () => { + streams.track('call-11', 'shell'); + streams.append('call-11', 'alpha beta gamma\r\n'); + streams.append('call-11', 'completely different content\r\n'); + + strictEqual(manager.outputTerminalResets.length, 1); + deepStrictEqual(manager.outputTerminalData.map(d => d.data), ['alpha beta gamma\r\n', 'completely different content\r\n']); + }); + }); + + suite('completion and lifecycle', () => { + test('parses fallback completion, finalizes once, and ignores later output', () => { + streams.track('call-12', 'shell'); + + const completion = streams.completeToolCall('call-12', 'fallback output\r\n', undefined); + streams.completeToolCall('call-12', 'different output\r\n', undefined); + streams.append('call-12', 'late output\r\n'); + + deepStrictEqual({ + completion, + content: channelContent(), + finalized: manager.outputTerminalsFinalized, + }, { + completion: { + uri: 'agenthost-terminal://shell/session-1/call-12', + result: { exitCode: -1, preview: 'fallback output\r\n' }, + shouldRetire: true, + }, + content: 'fallback output\r\n', + finalized: [{ uri: 'agenthost-terminal://shell/session-1/call-12', exitCode: -1 }], + }); + }); + + test('drops an unstarted stream without completion data', () => { + streams.track('call-13', 'shell'); + + strictEqual(streams.completeToolCall('call-13', undefined, undefined), undefined); + strictEqual(streams.append('call-13', 'late output'), undefined); + }); + + test('keeps a started stream alive without completion data', () => { + streams.track('call-14', 'shell'); + const appended = streams.append('call-14', 'partial output'); + ok(appended); + + deepStrictEqual(streams.completeToolCall('call-14', undefined, undefined), { + uri: appended.uri, + shouldRetire: false, + }); + }); + + test('retires a stream exactly once', () => { + streams.track('call-15', 'shell'); + const appended = streams.append('call-15', 'partial output'); + ok(appended); + + streams.retire('call-15'); + streams.retire('call-15'); + + deepStrictEqual(manager.disposedTerminals, [appended.uri]); + strictEqual(streams.append('call-15', 'late output'), undefined); + }); + + test('ignores append and completion for an untracked tool call', () => { + strictEqual(streams.append('missing', 'output'), undefined); + strictEqual(streams.completeToolCall('missing', undefined, undefined), undefined); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 680f9952c48..4a77ace093e 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -74,7 +74,7 @@ suite('resolveByokSessionConfig', () => { * A bridge connection that pushes `models` as its snapshot synchronously when * the registry subscribes; `chat` is scripted (unused by most tests). */ - function connectionOf(models: IByokLmModelInfo[], chat: IByokLmBridgeConnection['chat'] = async () => ({ content: '' })): IByokLmBridgeConnection { + function connectionOf(models: IByokLmModelInfo[], chat: IByokLmBridgeConnection['chat'] = async () => ({ output: [] })): IByokLmBridgeConnection { const emitter = store.add(new Emitter({ onDidAddFirstListener: () => emitter.fire(models), })); @@ -122,7 +122,7 @@ suite('resolveByokSessionConfig', () => { const registry = new ByokLmBridgeRegistry(); // A window connected without a BYOK handler never pushes, so it stays // non-serving and contributes no models. - const registration = registry.register('client-1', { chat: async (): Promise => ({ content: '' }), onDidChangeModels: Event.None }); + const registration = registry.register('client-1', { chat: async (): Promise => ({ output: [] }), onDidChangeModels: Event.None }); const proxy = countingProxy(); const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); @@ -147,8 +147,8 @@ suite('resolveByokSessionConfig', () => { assert.strictEqual(proxy.starts, 1); assert.deepStrictEqual(config, { providers: [ - { name: 'acme', type: 'openai', wireApi: 'completions', baseUrl: 'http://127.0.0.1:1/v/acme', bearerToken: 'NONCE.sess-1' }, - { name: 'globex', type: 'openai', wireApi: 'completions', baseUrl: 'http://127.0.0.1:1/v/globex', bearerToken: 'NONCE.sess-1' }, + { name: 'acme', type: 'openai', wireApi: 'responses', baseUrl: 'http://127.0.0.1:1/v/acme', bearerToken: 'NONCE.sess-1' }, + { name: 'globex', type: 'openai', wireApi: 'responses', baseUrl: 'http://127.0.0.1:1/v/globex', bearerToken: 'NONCE.sess-1' }, ], models: [ { id: 'claude', provider: 'acme', name: 'Acme Claude', maxContextWindowTokens: 200000 }, @@ -163,7 +163,10 @@ suite('resolveByokSessionConfig', () => { let captured: IByokLmChatRequest | undefined; const registration = registry.register('client-1', connectionOf( [{ vendor: 'acme', id: 'claude' }], - async (request) => { captured = request; return { content: 'hello from byok' }; }, + async (request) => { + captured = request; + return { output: [{ type: 'message', content: [{ type: 'text', text: 'hello from byok' }] }] }; + }, )); const service = new ByokLmProxyService(log, registry); let handle: IByokLmProxyHandle | undefined; @@ -172,10 +175,10 @@ suite('resolveByokSessionConfig', () => { const provider = config.providers![0]; const model = config.models![0]; try { - const response = await fetch(`${provider.baseUrl}/chat/completions`, { + const response = await fetch(`${provider.baseUrl}/responses`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${provider.bearerToken}` }, - body: JSON.stringify({ model: model.id, messages: [{ role: 'user', content: 'hi' }] }), + body: JSON.stringify({ model: model.id, input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hi' }] }] }), }); assert.strictEqual(response.status, 200); const text = await response.text(); @@ -193,7 +196,7 @@ suite('resolveByokSessionConfig', () => { const registry = new ByokLmBridgeRegistry(); const emitter = store.add(new Emitter()); const registration = registry.register('client-1', { - chat: async (): Promise => ({ content: '' }), + chat: async (): Promise => ({ output: [] }), onDidChangeModels: emitter.event, }); const proxy = countingProxy(); @@ -231,7 +234,7 @@ suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { const emitter = store.add(new Emitter({ onDidAddFirstListener: () => emitter.fire(models), })); - return { chat: async (): Promise => ({ content: '' }), onDidChangeModels: emitter.event }; + return { chat: async (): Promise => ({ output: [] }), onDidChangeModels: emitter.event }; } /** A fake proxy service whose handles carry a unique nonce per `start()`. */ diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts index a2857050e6d..9ee86e2b160 100644 --- a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { getEditFilePath, getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, getToolMarkdownContent, isEditTool, isHiddenTool, isMarkdownRenderedTool, synthesizeSkillToolCall, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; +import { getEditFilePath, getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getStreamingInvocationMessage, getToolDisplayName, getToolInputString, getToolKind, getToolMarkdownContent, isEditTool, isHiddenTool, isMarkdownRenderedTool, synthesizeSkillToolCall, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; suite('copilotToolDisplay — friendly tool names', () => { @@ -385,6 +385,102 @@ suite('copilotToolDisplay — built-in tool invocation/past-tense messages', () }); }); +suite('copilotToolDisplay — streaming edit messages', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function streaming(toolName: string, parameters: unknown, resolvePath?: (path: string) => string): string { + const result = getStreamingInvocationMessage(toolName, getToolDisplayName(toolName), parameters, resolvePath); + return typeof result === 'string' ? result : result.markdown; + } + + function invocation(toolName: string, parameters: Record): string { + const result = getInvocationMessage(toolName, getToolDisplayName(toolName), parameters); + return typeof result === 'string' ? result : result.markdown; + } + + function completed(toolName: string, parameters: Record): string { + const result = getPastTenseMessage(toolName, getToolDisplayName(toolName), parameters, true); + return typeof result === 'string' ? result : result.markdown; + } + + test('streams replacement line counts and the target file', () => { + assert.deepStrictEqual([ + streaming('edit', { path: '/repo/file.ts' }), + streaming('edit', { path: '/repo/file.ts', old_str: 'one\ntwo' }), + streaming('edit', { path: '/repo/file.ts', old_str: 'one\ntwo', new_str: 'one\nupdated\nthree' }), + ], [ + 'Editing [file.ts](file:///repo/file.ts)', + 'Replacing 2 lines in [file.ts](file:///repo/file.ts)', + 'Replacing 2 lines with 3 lines in [file.ts](file:///repo/file.ts)', + ]); + }); + + test('streams create and insert line counts', () => { + assert.deepStrictEqual([ + streaming('create', { path: '/repo/new.ts', file_text: 'one\r\ntwo\r\nthree' }), + streaming('insert', { path: '/repo/file.ts', new_str: 'one\rtwo' }), + ], [ + 'Creating [new.ts](file:///repo/new.ts) (3 lines)', + 'Inserting 2 lines in [file.ts](file:///repo/file.ts)', + ]); + }); + + test('uses the str_replace_editor command shape', () => { + assert.deepStrictEqual([ + streaming('str_replace_editor', { command: 'create', path: '/repo/new.ts', file_text: 'one\ntwo' }), + streaming('str_replace_editor', { command: 'str_replace', path: '/repo/file.ts', old_str: 'old', new_str: 'new\nvalue' }), + streaming('str_replace_editor', { command: 'view', path: '/repo/file.ts' }), + ], [ + 'Creating [new.ts](file:///repo/new.ts) (2 lines)', + 'Replacing 1 line with 2 lines in [file.ts](file:///repo/file.ts)', + 'Reading [file.ts](file:///repo/file.ts)', + ]); + }); + + test('preserves file context after streaming aliases become ready and complete', () => { + const cases: Array<[toolName: string, parameters: Record, ready: string, complete: string]> = [ + ['str_replace', { path: '/repo/file.ts' }, 'Editing [file.ts](file:///repo/file.ts)', 'Edited [file.ts](file:///repo/file.ts)'], + ['insert', { path: '/repo/file.ts' }, 'Inserting text in [file.ts](file:///repo/file.ts)', 'Inserted text in [file.ts](file:///repo/file.ts)'], + ['str_replace_editor', { command: 'create', path: '/repo/new.ts' }, 'Creating [new.ts](file:///repo/new.ts)', 'Created [new.ts](file:///repo/new.ts)'], + ['str_replace_editor', { command: 'str_replace', path: '/repo/file.ts' }, 'Editing [file.ts](file:///repo/file.ts)', 'Edited [file.ts](file:///repo/file.ts)'], + ]; + assert.deepStrictEqual(cases.map(([toolName, parameters]) => ({ + ready: invocation(toolName, parameters), + complete: completed(toolName, parameters), + })), cases.map(([, , ready, complete]) => ({ ready, complete }))); + }); + + test('streams raw patch line counts and resolves discovered file paths', () => { + const patch = [ + '*** Begin Patch', + '*** Update File: src/file.ts', + '@@', + '-old', + '+new', + '*** End Patch', + ].join('\n'); + assert.strictEqual( + streaming('apply_patch', patch, path => `/workspace/${path}`), + 'Generating patch (6 lines) in [file.ts](file:///workspace/src/file.ts)', + ); + }); + + test('ignores malformed partial paths', () => { + assert.strictEqual( + streaming('edit', { path: 42, old_str: 'one' }), + 'Replacing 1 line', + ); + }); + + test('falls back to the normal invocation formatter for non-edit tools', () => { + assert.strictEqual( + streaming('bash', { command: 'npm test' }), + 'Running `npm test`', + ); + }); +}); + // ---- write_/read_ shell tool display --------------------------------------- // // Coverage for the secondary shell helpers (write_bash, read_bash, and their diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeMultiRootCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeMultiRootCustomizationDiscovery.test.ts new file mode 100644 index 00000000000..965a0c09aeb --- /dev/null +++ b/src/vs/platform/agentHost/test/node/customizations/claudeMultiRootCustomizationDiscovery.test.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IFileService } from '../../../../files/common/files.js'; +import { NullLogService } from '../../../../log/common/log.js'; +import { discoverClaudeMultiRootCustomizations } from '../../../node/claude/customizations/claudeMultiRootCustomizationDiscovery.js'; +import { scanClaudeDiskCustomizations } from '../../../node/claude/customizations/scan/claudeAgentSkillScan.js'; +import { scanClaudeNativePlugins } from '../../../node/claude/customizations/scan/claudeNativePluginScan.js'; +import { createInMemoryFileService, seedFile } from './claudeCustomizationTestUtils.js'; + +suite('claudeMultiRootCustomizationDiscovery', () => { + const disposables = new DisposableStore(); + const rootA = URI.from({ scheme: Schemas.inMemory, path: '/a' }); + const rootB = URI.from({ scheme: Schemas.inMemory, path: '/b' }); + const userHome = URI.from({ scheme: Schemas.inMemory, path: '/home' }); + let fileService: IFileService; + const seed = (path: string, content = '') => seedFile(fileService, path, content); + + setup(() => { + fileService = createInMemoryFileService(disposables); + }); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses the existing single-root discovery path without changing output order', async () => { + await Promise.all([ + seed('/a/.claude/agents/project.md', '---\nname: project\ndescription: project agent\n---'), + seed('/home/.claude/agents/user.md', '---\nname: user\ndescription: user agent\n---'), + seed('/a/.claude/skills/project-skill/SKILL.md', '---\nname: project-skill\ndescription: project skill\n---'), + seed('/home/.claude/skills/user-skill/SKILL.md', '---\nname: user-skill\ndescription: user skill\n---'), + seed('/home/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'user-plugin@m': true } })), + seed('/a/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'project-plugin@m': true } })), + seed('/home/.claude/plugins/cache/m/user-plugin/1.0.0/.claude-plugin/plugin.json', JSON.stringify({ name: 'user-plugin' })), + seed('/home/.claude/plugins/cache/m/project-plugin/1.0.0/.claude-plugin/plugin.json', JSON.stringify({ name: 'project-plugin' })), + ]); + const logService = new NullLogService(); + const [expectedDiscovered, expectedPlugins, actual] = await Promise.all([ + scanClaudeDiskCustomizations(rootA, userHome, fileService), + scanClaudeNativePlugins(rootA, userHome, fileService, logService), + discoverClaudeMultiRootCustomizations([rootA], userHome, fileService, logService), + ]); + + assert.deepStrictEqual({ + discovered: actual.discovered, + plugins: actual.nativePlugins, + }, { + discovered: expectedDiscovered, + plugins: expectedPlugins, + }); + }); + + test('combines roots in order and applies first-name-wins precedence', async () => { + await Promise.all([ + seed('/a/.claude/agents/shared.md', '---\nname: shared\ndescription: from a\n---'), + seed('/b/.claude/agents/shared.md', '---\nname: shared\ndescription: from b\n---'), + seed('/b/.claude/agents/b-only.md', '---\nname: b-only\ndescription: from b\n---'), + seed('/b/.claude/skills/shared-skill/SKILL.md', '---\nname: shared-skill\ndescription: from b\n---'), + seed('/home/.claude/skills/shared-skill/SKILL.md', '---\nname: shared-skill\ndescription: from user\n---'), + seed('/home/.claude/skills/user-only/SKILL.md', '---\nname: user-only\ndescription: from user\n---'), + seed('/b/.claude/commands/not-loaded.md', '---\nname: not-loaded\ndescription: added-directory command\n---'), + ]); + + const result = await discoverClaudeMultiRootCustomizations([rootA, rootB], userHome, fileService, new NullLogService()); + + assert.deepStrictEqual({ + roots: result.workingDirectories.map(root => root.path), + items: result.discovered.map(item => ({ name: item.name, description: item.description, path: item.uri.path })), + }, { + roots: ['/a', '/b'], + items: [ + { name: 'shared', description: 'from a', path: '/a/.claude/agents/shared.md' }, + { name: 'b-only', description: 'from b', path: '/b/.claude/agents/b-only.md' }, + { name: 'shared-skill', description: 'from b', path: '/b/.claude/skills/shared-skill/SKILL.md' }, + { name: 'user-only', description: 'from user', path: '/home/.claude/skills/user-only/SKILL.md' }, + ], + }); + }); + + test('deduplicates equivalent roots without changing precedence', async () => { + await seed('/a/.claude/agents/a.md', '---\nname: a\ndescription: A\n---'); + + const result = await discoverClaudeMultiRootCustomizations([rootA, rootA], userHome, fileService, new NullLogService()); + + assert.deepStrictEqual({ + roots: result.workingDirectories.map(root => root.path), + items: result.discovered.map(item => item.name), + }, { + roots: ['/a'], + items: ['a'], + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts index d4a90a663d8..7b1c9f0a6f0 100644 --- a/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts +++ b/src/vs/platform/agentHost/test/node/customizations/claudeSessionCustomizationDiscovery.test.ts @@ -53,6 +53,57 @@ suite('claudeSessionCustomizationDiscovery', () => { ensureNoDisposablesAreLeakedInTestSuite(); suite('mapDiscoveredCustomizations', () => { + test('maps agents and skills into separate ordered workspace-root containers', () => { + const workspaceB = URI.from({ scheme: Schemas.inMemory, path: '/workspace/packages/b' }); + const rootAgent = URI.joinPath(workspace, '.claude', 'agents', 'root.md'); + const nestedAgent = URI.joinPath(workspaceB, '.claude', 'agents', 'nested.md'); + const result = mapDiscoveredCustomizations([ + toParsedAgent({ uri: rootAgent, name: 'root' }), + toParsedAgent({ uri: nestedAgent, name: 'nested' }), + ], [], [], [], [workspace, workspaceB], userHome); + + assert.deepStrictEqual( + (result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]) + .map(directory => ({ uri: directory.uri, children: directory.children?.map(child => child.name) })), + [ + { uri: URI.joinPath(workspace, '.claude', 'agents').toString(), children: ['root'] }, + { uri: URI.joinPath(workspaceB, '.claude', 'agents').toString(), children: ['nested'] }, + ], + ); + }); + + test('keeps user customizations in the user bucket when an additional root contains userHome', () => { + const broadRoot = URI.from({ scheme: Schemas.inMemory, path: '/home' }); + const userSkill = URI.joinPath(userHome, '.claude', 'skills', 'user-skill', 'SKILL.md'); + const result = mapDiscoveredCustomizations([ + toParsedSkill({ uri: userSkill, name: 'user-skill' }), + ], [], [], [], [workspace, broadRoot], userHome); + + assert.deepStrictEqual( + (result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]) + .map(directory => ({ uri: directory.uri, children: directory.children?.map(child => child.name) })), + [ + { uri: URI.joinPath(userHome, '.claude', 'skills').toString(), children: ['user-skill'] }, + ], + ); + }); + + test('preserves single-root workspace attribution when the workspace contains userHome', () => { + const broadRoot = URI.from({ scheme: Schemas.inMemory, path: '/home' }); + const userSkill = URI.joinPath(userHome, '.claude', 'skills', 'user-skill', 'SKILL.md'); + const result = mapDiscoveredCustomizations([ + toParsedSkill({ uri: userSkill, name: 'user-skill' }), + ], [], [], [], broadRoot, userHome); + + assert.deepStrictEqual( + (result.filter(c => c.type === CustomizationType.Directory) as DirectoryCustomization[]) + .map(directory => ({ uri: directory.uri, children: directory.children?.map(child => child.name) })), + [ + { uri: URI.joinPath(broadRoot, '.claude', 'skills').toString(), children: ['user-skill'] }, + ], + ); + }); + test('maps discovered entries into per-scope Directory containers with real child URIs + top-level MCP', () => { const wsAgentUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/.claude/agents/wa.md' }); const wsSkillUri = URI.from({ scheme: Schemas.inMemory, path: '/workspace/.claude/skills/ws/SKILL.md' }); @@ -395,6 +446,25 @@ suite('claudeSessionCustomizationDiscovery', () => { assert.strictEqual(fires, 1); }); + test('watches agents, skills, and plugin settings under additional roots', async () => { + const workspaceB = URI.from({ scheme: Schemas.inMemory, path: '/workspace-b' }); + const watcher = disposables.add(new ClaudeCustomizationWatcher([workspace, workspaceB], userHome, fileService, new NullLogService(), debounceMs)); + let fires = 0; + disposables.add(watcher.onDidChange(() => { fires++; })); + + await seed('/workspace-b/unrelated.txt', 'x'); + await settle(); + assert.strictEqual(fires, 0); + + await Promise.all([ + seed('/workspace-b/.claude/agents/a.md', 'a'), + seed('/workspace-b/.claude/skills/s/SKILL.md', 's'), + seed('/workspace-b/.claude/settings.json', '{}'), + ]); + await settle(); + assert.strictEqual(fires, 1); + }); + test('fires for a root-level CLAUDE.md / CLAUDE.local.md edit', async () => { const watcher = disposables.add(new ClaudeCustomizationWatcher(workspace, userHome, fileService, new NullLogService(), debounceMs)); let fires = 0; diff --git a/src/vs/platform/agentHost/test/node/customizations/scan/claudeNativePluginScan.test.ts b/src/vs/platform/agentHost/test/node/customizations/scan/claudeNativePluginScan.test.ts index 35f84367266..e75ac5278a6 100644 --- a/src/vs/platform/agentHost/test/node/customizations/scan/claudeNativePluginScan.test.ts +++ b/src/vs/platform/agentHost/test/node/customizations/scan/claudeNativePluginScan.test.ts @@ -8,7 +8,7 @@ import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../log/common/log.js'; import { IFileService } from '../../../../../files/common/files.js'; -import { scanClaudeNativePlugins } from '../../../../node/claude/customizations/scan/claudeNativePluginScan.js'; +import { scanClaudeNativePlugins, scanClaudeNativePluginsForRoots } from '../../../../node/claude/customizations/scan/claudeNativePluginScan.js'; import { claudeTestUserHome as userHome, claudeTestWorkspace as workspace, createInMemoryFileService, seedFile } from '../claudeCustomizationTestUtils.js'; suite('claudeNativePluginScan', () => { @@ -128,6 +128,67 @@ suite('claudeNativePluginScan', () => { assert.deepStrictEqual(plugins.map(p => p.root.path), ['/workspace/.claude/skills/mine']); }); + test('discovers an in-place plugin enabled only by an additional workspace root', async () => { + const workspaceB = workspace.with({ path: '/workspace-b' }); + await seed('/workspace-b/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'mine@skills-dir': true } })); + await seed('/workspace-b/.claude/skills/mine/.claude-plugin/plugin.json', manifest('mine')); + + const plugins = await scanClaudeNativePluginsForRoots([workspace, workspaceB], userHome, fileService, logService); + + assert.deepStrictEqual(plugins.map(p => ({ id: p.id, root: p.root.path })), [ + { id: 'mine@skills-dir', root: '/workspace-b/.claude/skills/mine' }, + ]); + }); + + test('uses the most-specific nested workspace root when parsing plugin hooks', async () => { + const workspaceB = workspace.with({ path: '/workspace/packages/b' }); + await seed('/workspace/packages/b/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'mine@skills-dir': true } })); + await seed('/workspace/packages/b/.claude/skills/mine/.claude-plugin/plugin.json', manifest('mine')); + await seed('/workspace/packages/b/.claude/skills/mine/hooks/hooks.json', JSON.stringify({ + hooks: { + PreToolUse: [{ + hooks: [{ type: 'command', command: 'echo nested', cwd: 'scripts' }], + }], + }, + })); + + const plugins = await scanClaudeNativePluginsForRoots([workspace, workspaceB], userHome, fileService, logService); + + assert.deepStrictEqual(plugins[0].parsed.hooks.flatMap(group => group.commands).map(hook => hook.cwd?.path), [ + '/workspace/packages/b/scripts', + ]); + }); + + test('uses the primary root for cached plugin hooks when an additional root contains userHome', async () => { + const broadRoot = workspace.with({ path: '/home' }); + await seed('/home/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'cached@m': true } })); + await seed('/home/.claude/plugins/cache/m/cached/1.0.0/.claude-plugin/plugin.json', manifest('cached')); + await seed('/home/.claude/plugins/cache/m/cached/1.0.0/hooks/hooks.json', JSON.stringify({ + hooks: { + PreToolUse: [{ + hooks: [{ type: 'command', command: 'echo cached', cwd: 'scripts' }], + }], + }, + })); + + const plugins = await scanClaudeNativePluginsForRoots([workspace, broadRoot], userHome, fileService, logService); + + assert.deepStrictEqual(plugins[0].parsed.hooks.flatMap(group => group.commands).map(hook => hook.cwd?.path), [ + '/workspace/scripts', + ]); + }); + + test('uses ordered root precedence for conflicting plugin enablement', async () => { + const workspaceB = workspace.with({ path: '/workspace-b' }); + await seed('/workspace/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'mine@skills-dir': false } })); + await seed('/workspace-b/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'mine@skills-dir': true } })); + await seed('/workspace-b/.claude/skills/mine/.claude-plugin/plugin.json', manifest('mine')); + + const plugins = await scanClaudeNativePluginsForRoots([workspace, workspaceB], userHome, fileService, logService); + + assert.deepStrictEqual(plugins, []); + }); + test('fail-soft: an enabled plugin with no resolvable root is skipped, not thrown', async () => { await seed('/home/.claude/settings.json', JSON.stringify({ enabledPlugins: { 'present@m': true, 'missing@m': true } })); await seed('/home/.claude/plugins/cache/m/present/1.0.0/.claude-plugin/plugin.json', manifest('present')); diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index 2d0d9112aa9..02b3d295477 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -123,7 +123,7 @@ A capture that genuinely cannot be refreshed goes in `STALE_RECORDED_REQUEST_EXC - Test: `side chat receives bounded source context without copied history`. - Scope: Claude. - Expected: re-recording the capture drives a real side chat and stores the request the host now sends. -- Observed: recording fails with `Invalid upToMessageId: turn-source`. The side chat is created against a source turn, so recording exercises the same provider-context fork defect that gates `supportsChatForkE2E`; see [Claude provider-context fork](#claude-provider-context-fork). +- Observed: recording does not reproduce the committed capture, because the side chat is anchored on a source turn and therefore hits the same anchor-resolution defect that gates `supportsChatForkE2E` — the side chat falls back to an injected `` preamble instead of a provider fork. See [Claude provider-context fork](#claude-provider-context-fork). - Consequence: the committed capture predates the host's `` preamble, so its recorded request no longer matches the live one. The test still replays correctly — only the request comparison is disabled, via `STALE_RECORDED_REQUEST_EXCEPTIONS`. - Reproduce: @@ -144,8 +144,33 @@ A capture that genuinely cannot be refreshed goes in `STALE_RECORDED_REQUEST_EXC - `unknown-turn fork does not inherit source provider context` - Scope: Claude. - Expected: Claude advertises multi-chat fork support, and a provider-backed fork can continue from the requested source history. -- Observed: exercising a real provider-context fork rejects the AHP turn id as an invalid `upToMessageId`. The unknown-turn context test currently shares the same provider E2E fork gate. +- Observed: the fork **silently produces a chat with no provider context**. The forked chat's AHP state looks correct — the source turn is seeded into its transcript — but the model request carries no prior history, so the model cannot answer questions about the source conversation. No error reaches the client. + + Verified against the live SDK by enabling the gate and recording. Of the four assertions, only the AHP-level one passes: + + ``` + seededMessages: ok (source turn present in the forked chat) + requestHasPriorUserMessage: FAIL (model request has no source user turn) + requestHasPriorAssistantMessage: FAIL (model request has no source reply) + responseHasCodeWord: FAIL (model cannot recall the source code word) + ``` + + The same test passes for Copilot, whose capture shows the full inherited history, so this is provider-specific rather than a fault in the test or the shared fork contract. + + Root cause: `resolveForkAnchorUuid` (`claudeReplayMapper.ts`) matches the requested turn id against **Claude SDK envelope uuids**, so it only resolves when the AHP turn id happens to *be* an SDK uuid. AHP lets a client choose its own turn id on dispatch — Copilot honors that — and for such an id the anchor never resolves: + + ``` + resolveForkAnchorUuid(messages, 'u1') -> 'a1' (SDK uuid, resolves) + resolveForkAnchorUuid(messages, 'fork-source') -> undefined (client turn id, never resolves) + ``` + + `_forkChat` then logs a warning and returns `undefined`, and `createChat` continues with a fresh chat. The degradation is invisible to the client, which is the part that makes this a defect rather than a limitation: a required contract fails silently instead of surfacing a typed error. + + The earlier description of this entry — that the fork "rejects the AHP turn id as an invalid `upToMessageId`" — was inaccurate. That string comes from a unit-test stub and the SDK; the E2E fork path never reaches `forkSession` at all. + +- Note: `unknown-turn fork does not inherit source provider context` asserts the *correct* behavior for an unresolvable anchor and shares this gate only because both are `forkProviderTest`s. It is expected to pass once the resolvable case works. - Gate: `supportsChatForkE2E: false`. +- Issue: [#328104](https://github.com/microsoft/vscode/issues/328104). - Reproduce: ```bash @@ -301,6 +326,23 @@ These are opt-in live tests, not known failures. - Gate: `supportsPlanMode: false`. - Evaluation goal: make the test prompt provider-neutral or add an equivalent Claude-specific prompt without weakening the plan-mode assertions. +### A test that only asserts its last dispatch cannot see a lost one + +Most state-operation tests dispatch two or three actions and then assert the +result of the **last** one. That shape is blind to an action that is echoed but +never applied, because the final read still shows the expected value. + +The first test written here that asserted a *cumulative* result across two +dispatches immediately exposed behavior nobody had written down: a message +queued onto an idle chat is not parked in `queuedMessages` at all, it is +promoted straight into a turn (`_tryConsumeNextQueuedMessage`), so the queue is +empty again by the time the next action is reduced. The envelope for the +dispatch looked completely normal — correct `serverSeq`, no `rejectionReason` — +so nothing short of asserting the accumulated state would have caught it. + +When adding state-operation tests, prefer at least one assertion over the state +that several actions built up together, not only over the last write. + ## Expected capability skips These pending tests do not currently indicate bugs. They are listed by capability rather than by test title: the titles change often, and the gate is what matters. diff --git a/src/vs/platform/agentHost/test/node/e2e/README.md b/src/vs/platform/agentHost/test/node/e2e/README.md index 7ffab58eceb..bf9df5f7088 100644 --- a/src/vs/platform/agentHost/test/node/e2e/README.md +++ b/src/vs/platform/agentHost/test/node/e2e/README.md @@ -467,18 +467,26 @@ Existing tests there stay and keep running — they are cheap and they work. The A one-off union measurement (protocol + E2E vs. E2E alone) put the protocol suite's unique contribution at **1673 statements (+1.8pp)** across 30 files. Cross-referencing that with the protocol-surface `uncovered` list gives a concrete list of contracts that exist *only* in the frozen suite and should be re-expressed here as conformance tests, highest value first: -| Area | Only tested in | Uncovered protocol symbols | +The **Protocol symbols** column lists what each row is responsible for; check `coverage/protocol-surface.json` for the authoritative covered/uncovered split rather than reading it out of this table. + +| Area | Status | Protocol symbols | |---|---|---| -| Client-hosted filesystem (reverse requests) | *migrated* — `suites/clientFilesystemSuite.ts` | `resourceWatch/changed` | -| Turn history paging | `turnExecution` | `fetchTurns`, `chat/turnsLoaded` | -| Reconnect and multi-client fan-out | `multiClient` | `reconnect` | -| Changeset lifecycle | `sessionDiffs` | all 8 `changeset/*` actions | -| OTLP export | `otlpLogs` | `otlp/exportLogs`, `otlp/exportMetrics`, `otlp/exportTraces` | -| Liveness | `handshake`, several others | `ping` | +| Client-hosted filesystem (reverse requests) | migrated — `suites/clientFilesystemSuite.ts` | `resourceWatch/changed` still uncovered | +| Turn history paging | migrated — `suites/protocolContractsSuite.ts` | `fetchTurns`, `chat/turnsLoaded` — covered | +| Reconnect and multi-client fan-out | partly migrated — `suites/protocolContractsSuite.ts` covers `reconnect`; fan-out across several live clients is still only in `multiClient` | `reconnect` — covered | +| Changeset lifecycle | migrated — `suites/changesetSuite.ts` | 5 of 8 `changeset/*` covered; `fileSet`, `fileRemoved`, `operationStatusChanged` still uncovered | +| OTLP export | still only in `otlpLogs` | `otlp/exportLogs`, `otlp/exportMetrics`, `otlp/exportTraces` uncovered | +| Liveness | migrated — `suites/protocolContractsSuite.ts` | `ping` — covered | + +Changeset lifecycle followed. `suites/changesetSuite.ts` covers status, content, review state, the operations a changeset advertises, and the catalog in the conformance tier, driving real git-backed edits through host-executed bang commands so no scenario crosses the model boundary. The frozen suite's version could not be copied: it drives a mock agent with the magic prompt `terminal-edit:`, which no other AHP implementation would understand. + +The three remaining `changeset/*` actions need scenarios this suite does not yet reach: `fileSet` / `fileRemoved` are the incremental per-file updates (the bulk `contentChanged` path is what a fresh session emits), and `operationStatusChanged` needs an invoked operation — `commit` and `discard-changes` are the two that run without network access. The filesystem family was the largest of these and is now covered by `suites/clientFilesystemSuite.ts` in the conformance tier — both the `resource*` command surface the host executes against its own filesystem, and the reverse direction where the host asks the *client* for a file it cannot otherwise reach. See [The filesystem, in both directions](#the-filesystem-in-both-directions). -Some contracts are covered by **neither** suite and need new tests outright: the entire `annotations/*` channel (5 actions), `invokeChangesetOperation`, `auth/required`, `root/progress`, and `chat/toolCallAuthRequired` / `chat/toolCallAuthResolved`. +Some contracts are covered by **neither** suite and need new tests outright: `auth/required`, `root/progress`, and `chat/toolCallAuthRequired` / `chat/toolCallAuthResolved`. The `annotations/*` channel is now covered by `suites/annotationsSuite.ts`. + +`reconnect` is only answerable on a transport that has **not** completed the handshake — it is the alternative to `initialize`, not a command an established connection can issue. Testing it therefore needs a second connection that can be dropped and re-established, which is what `IAgentHostE2ETestContext.connectClient` exists for; the shared per-test client cannot express it. --- diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-streams-rich-file-creation-progress-without-exposing-partial-input.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-streams-rich-file-creation-progress-without-exposing-partial-input.yaml new file mode 100644 index 00000000000..30ced22d7cc --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-streams-rich-file-creation-progress-without-exposing-partial-input.yaml @@ -0,0 +1,59 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: |- + Create streaming.txt containing exactly these three lines, with no other content: + STREAM_ALPHA + STREAM_BETA + STREAM_GAMMA + Use your file creation tool; do not run a shell command. Then reply exactly "done". + response: + content: + - type: text + text: I'll create the file. + - type: tool_use + id: toolcall_0 + name: Write + input: + file_path: ${workdir}/streaming.txt + content: | + STREAM_ALPHA + STREAM_BETA + STREAM_GAMMA + stopReason: tool_use + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: |- + Create streaming.txt containing exactly these three lines, with no other content: + STREAM_ALPHA + STREAM_BETA + STREAM_GAMMA + Use your file creation tool; do not run a shell command. Then reply exactly "done". + - role: assistant + content: + - type: text + text: I'll create the file. + - type: tool_use + name: Write + input: + file_path: ${workdir}/streaming.txt + content: | + STREAM_ALPHA + STREAM_BETA + STREAM_GAMMA + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'File created successfully at: ${workdir}/streaming.txt (file state is current in your context — no need to Read it back)' + response: + content: done + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-streams-rich-file-creation-progress-without-exposing-partial-input.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-streams-rich-file-creation-progress-without-exposing-partial-input.yaml new file mode 100644 index 00000000000..394bd041c8e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-streams-rich-file-creation-progress-without-exposing-partial-input.yaml @@ -0,0 +1,55 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: |- + Create streaming.txt containing exactly these three lines, with no other content: + STREAM_ALPHA + STREAM_BETA + STREAM_GAMMA + Use your file creation tool; do not run a shell command. Then reply exactly "done". + response: + content: + - type: tool_use + id: toolcall_0 + name: create + input: + path: ${workdir}/streaming.txt + file_text: | + STREAM_ALPHA + STREAM_BETA + STREAM_GAMMA + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: |- + Create streaming.txt containing exactly these three lines, with no other content: + STREAM_ALPHA + STREAM_BETA + STREAM_GAMMA + Use your file creation tool; do not run a shell command. Then reply exactly "done". + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/streaming.txt + file_text: | + STREAM_ALPHA + STREAM_BETA + STREAM_GAMMA + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/streaming.txt with 38 characters + response: + content: done + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json b/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json index 32a9c684d17..c42f10067fc 100644 --- a/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json +++ b/src/vs/platform/agentHost/test/node/e2e/coverage/protocol-surface.json @@ -5,14 +5,11 @@ "note": "A symbol is \"covered\" when an E2E test sends or receives it; this does not measure how deeply its semantics are asserted." }, "commands": { - "covered": 25, + "covered": 28, "total": 29, - "percentage": 86.2, + "percentage": 96.55, "uncovered": [ - "fetchTurns", - "invokeChangesetOperation", - "ping", - "reconnect" + "invokeChangesetOperation" ] }, "notifications": { @@ -28,38 +25,22 @@ ] }, "actions": { - "covered": 44, + "covered": 64, "total": 85, - "percentage": 51.76, + "percentage": 75.29, "uncovered": [ - "annotations/entryRemoved", - "annotations/entrySet", - "annotations/removed", - "annotations/set", - "annotations/updated", - "changeset/cleared", - "changeset/contentChanged", "changeset/fileRemoved", "changeset/fileSet", - "changeset/filesReviewChanged", "changeset/operationStatusChanged", - "changeset/operationsChanged", - "changeset/statusChanged", - "chat/activityChanged", "chat/error", "chat/inputAnswerChanged", - "chat/pendingMessageSet", "chat/reasoning", "chat/toolCallAuthRequired", "chat/toolCallAuthResolved", "chat/toolCallResultConfirmed", - "chat/turnsLoaded", - "chat/workingDirectoryRemoved", - "chat/workingDirectorySet", "resourceWatch/changed", "root/activeSessionsChanged", "root/agentsChanged", - "root/terminalsChanged", "session/activityChanged", "session/creationFailed", "session/customizationRemoved", @@ -68,11 +49,7 @@ "session/mcpServerStartRequested", "session/mcpServerStateChanged", "session/mcpServerStopRequested", - "session/workingDirectoryRemoved", - "session/workingDirectorySet", - "terminal/cleared", - "terminal/commandDetectionAvailable", - "terminal/exited" + "terminal/commandDetectionAvailable" ] } } diff --git a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json index f88c7ef874f..ec87420ea55 100644 --- a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json +++ b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json @@ -15,24 +15,24 @@ }, "total": { "statements": { - "covered": 64437, - "total": 91960, - "percentage": 70.07 + "covered": 66445, + "total": 94575, + "percentage": 70.25 }, "branches": { - "covered": 5476, - "total": 8687, - "percentage": 63.03 + "covered": 5740, + "total": 9095, + "percentage": 63.11 }, "functions": { - "covered": 2103, - "total": 3482, - "percentage": 60.39 + "covered": 2175, + "total": 3583, + "percentage": 60.7 }, "lines": { - "covered": 64437, - "total": 91960, - "percentage": 70.07 + "covered": 66445, + "total": 94575, + "percentage": 70.25 } }, "files": { @@ -258,14 +258,14 @@ }, "src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts": { "statements": { - "covered": 350, + "covered": 353, "total": 649, - "percentage": 53.92 + "percentage": 54.39 }, "branches": { - "covered": 16, - "total": 30, - "percentage": 53.33 + "covered": 20, + "total": 33, + "percentage": 60.6 }, "functions": { "covered": 10, @@ -273,16 +273,16 @@ "percentage": 41.66 }, "lines": { - "covered": 350, + "covered": 353, "total": 649, - "percentage": 53.92 + "percentage": 54.39 } }, "src/vs/platform/agentHost/common/agentHostFileSystemService.ts": { "statements": { - "covered": 57, - "total": 74, - "percentage": 77.02 + "covered": 58, + "total": 79, + "percentage": 73.41 }, "branches": { "covered": 0, @@ -295,16 +295,16 @@ "percentage": 0 }, "lines": { - "covered": 57, - "total": 74, - "percentage": 77.02 + "covered": 58, + "total": 79, + "percentage": 73.41 } }, "src/vs/platform/agentHost/common/agentHostGitService.ts": { "statements": { - "covered": 351, - "total": 361, - "percentage": 97.22 + "covered": 371, + "total": 381, + "percentage": 97.37 }, "branches": { "covered": 5, @@ -317,9 +317,9 @@ "percentage": 66.66 }, "lines": { - "covered": 351, - "total": 361, - "percentage": 97.22 + "covered": 371, + "total": 381, + "percentage": 97.37 } }, "src/vs/platform/agentHost/common/agentHostGitStateService.ts": { @@ -368,9 +368,9 @@ }, "src/vs/platform/agentHost/common/agentHostSchema.ts": { "statements": { - "covered": 659, - "total": 758, - "percentage": 86.93 + "covered": 684, + "total": 783, + "percentage": 87.35 }, "branches": { "covered": 46, @@ -383,9 +383,9 @@ "percentage": 68.18 }, "lines": { - "covered": 659, - "total": 758, - "percentage": 86.93 + "covered": 684, + "total": 783, + "percentage": 87.35 } }, "src/vs/platform/agentHost/common/agentHostSlashCommand.ts": { @@ -434,9 +434,9 @@ }, "src/vs/platform/agentHost/common/agentHostUri.ts": { "statements": { - "covered": 102, - "total": 163, - "percentage": 62.57 + "covered": 131, + "total": 206, + "percentage": 63.59 }, "branches": { "covered": 0, @@ -445,13 +445,13 @@ }, "functions": { "covered": 0, - "total": 4, + "total": 5, "percentage": 0 }, "lines": { - "covered": 102, - "total": 163, - "percentage": 62.57 + "covered": 131, + "total": 206, + "percentage": 63.59 } }, "src/vs/platform/agentHost/common/agentModelByokMeta.ts": { @@ -522,9 +522,9 @@ }, "src/vs/platform/agentHost/common/agentService.ts": { "statements": { - "covered": 2144, - "total": 2316, - "percentage": 92.57 + "covered": 2175, + "total": 2347, + "percentage": 92.67 }, "branches": { "covered": 24, @@ -537,9 +537,9 @@ "percentage": 42.85 }, "lines": { - "covered": 2144, - "total": 2316, - "percentage": 92.57 + "covered": 2175, + "total": 2347, + "percentage": 92.67 } }, "src/vs/platform/agentHost/common/ahpJsonlLogger.ts": { @@ -566,14 +566,14 @@ }, "src/vs/platform/agentHost/common/annotationsUri.ts": { "statements": { - "covered": 42, + "covered": 46, "total": 48, - "percentage": 87.5 + "percentage": 95.83 }, "branches": { - "covered": 3, - "total": 4, - "percentage": 75 + "covered": 7, + "total": 8, + "percentage": 87.5 }, "functions": { "covered": 3, @@ -581,9 +581,9 @@ "percentage": 100 }, "lines": { - "covered": 42, + "covered": 46, "total": 48, - "percentage": 87.5 + "percentage": 95.83 } }, "src/vs/platform/agentHost/common/changesetUri.ts": { @@ -764,8 +764,8 @@ }, "src/vs/platform/agentHost/common/diffComputeService.ts": { "statements": { - "covered": 40, - "total": 40, + "covered": 53, + "total": 53, "percentage": 100 }, "branches": { @@ -779,8 +779,8 @@ "percentage": 100 }, "lines": { - "covered": 40, - "total": 40, + "covered": 53, + "total": 53, "percentage": 100 } }, @@ -808,9 +808,9 @@ }, "src/vs/platform/agentHost/common/githubEndpoints.ts": { "statements": { - "covered": 95, - "total": 125, - "percentage": 76 + "covered": 100, + "total": 130, + "percentage": 76.92 }, "branches": { "covered": 3, @@ -823,9 +823,9 @@ "percentage": 75 }, "lines": { - "covered": 95, - "total": 125, - "percentage": 76 + "covered": 100, + "total": 130, + "percentage": 76.92 } }, "src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts": { @@ -923,9 +923,9 @@ "percentage": 84.71 }, "branches": { - "covered": 33, - "total": 44, - "percentage": 75 + "covered": 30, + "total": 41, + "percentage": 73.17 }, "functions": { "covered": 5, @@ -1011,9 +1011,9 @@ "percentage": 85.71 }, "branches": { - "covered": 16, - "total": 22, - "percentage": 72.72 + "covered": 17, + "total": 23, + "percentage": 73.91 }, "functions": { "covered": 11, @@ -1116,8 +1116,8 @@ }, "src/vs/platform/agentHost/common/sessionConfigKeys.ts": { "statements": { - "covered": 52, - "total": 52, + "covered": 54, + "total": 54, "percentage": 100 }, "branches": { @@ -1131,15 +1131,15 @@ "percentage": 100 }, "lines": { - "covered": 52, - "total": 52, + "covered": 54, + "total": 54, "percentage": 100 } }, "src/vs/platform/agentHost/common/sessionDataService.ts": { "statements": { - "covered": 421, - "total": 421, + "covered": 439, + "total": 439, "percentage": 100 }, "branches": { @@ -1153,11 +1153,33 @@ "percentage": 100 }, "lines": { - "covered": 421, - "total": 421, + "covered": 439, + "total": 439, "percentage": 100 } }, + "src/vs/platform/agentHost/common/sessionDbUri.ts": { + "statements": { + "covered": 74, + "total": 120, + "percentage": 61.66 + }, + "branches": { + "covered": 3, + "total": 6, + "percentage": 50 + }, + "functions": { + "covered": 3, + "total": 7, + "percentage": 42.85 + }, + "lines": { + "covered": 74, + "total": 120, + "percentage": 61.66 + } + }, "src/vs/platform/agentHost/common/state/agentSubscription.ts": { "statements": { "covered": 582, @@ -1248,36 +1270,14 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-annotations/reducer.ts": { "statements": { - "covered": 29, + "covered": 90, "total": 115, - "percentage": 25.21 + "percentage": 78.26 }, "branches": { - "covered": 0, - "total": 0, - "percentage": 100 - }, - "functions": { - "covered": 0, - "total": 1, - "percentage": 0 - }, - "lines": { - "covered": 29, - "total": 115, - "percentage": 25.21 - } - }, - "src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts": { - "statements": { - "covered": 63, - "total": 121, - "percentage": 52.06 - }, - "branches": { - "covered": 7, - "total": 16, - "percentage": 43.75 + "covered": 12, + "total": 23, + "percentage": 52.17 }, "functions": { "covered": 1, @@ -1285,21 +1285,43 @@ "percentage": 100 }, "lines": { - "covered": 63, + "covered": 90, + "total": 115, + "percentage": 78.26 + } + }, + "src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts": { + "statements": { + "covered": 72, "total": 121, - "percentage": 52.06 + "percentage": 59.5 + }, + "branches": { + "covered": 9, + "total": 19, + "percentage": 47.36 + }, + "functions": { + "covered": 1, + "total": 1, + "percentage": 100 + }, + "lines": { + "covered": 72, + "total": 121, + "percentage": 59.5 } }, "src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts": { "statements": { - "covered": 579, + "covered": 601, "total": 833, - "percentage": 69.5 + "percentage": 72.14 }, "branches": { - "covered": 108, - "total": 175, - "percentage": 61.71 + "covered": 114, + "total": 182, + "percentage": 62.63 }, "functions": { "covered": 14, @@ -1307,9 +1329,9 @@ "percentage": 100 }, "lines": { - "covered": 579, + "covered": 601, "total": 833, - "percentage": 69.5 + "percentage": 72.14 } }, "src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/reducer.ts": { @@ -1776,24 +1798,24 @@ }, "src/vs/platform/agentHost/common/state/sessionState.ts": { "statements": { - "covered": 1076, - "total": 1396, - "percentage": 77.07 + "covered": 1129, + "total": 1458, + "percentage": 77.43 }, "branches": { - "covered": 90, - "total": 131, - "percentage": 68.7 + "covered": 98, + "total": 142, + "percentage": 69.01 }, "functions": { - "covered": 36, - "total": 57, - "percentage": 63.15 + "covered": 39, + "total": 62, + "percentage": 62.9 }, "lines": { - "covered": 1076, - "total": 1396, - "percentage": 77.07 + "covered": 1129, + "total": 1458, + "percentage": 77.43 } }, "src/vs/platform/agentHost/common/toolSearchConstants.ts": { @@ -1842,14 +1864,14 @@ }, "src/vs/platform/agentHost/node/agentConfigurationService.ts": { "statements": { - "covered": 369, + "covered": 366, "total": 402, - "percentage": 91.79 + "percentage": 91.04 }, "branches": { - "covered": 38, - "total": 51, - "percentage": 74.5 + "covered": 34, + "total": 47, + "percentage": 72.34 }, "functions": { "covered": 14, @@ -1857,21 +1879,21 @@ "percentage": 87.5 }, "lines": { - "covered": 369, + "covered": 366, "total": 402, - "percentage": 91.79 + "percentage": 91.04 } }, "src/vs/platform/agentHost/node/agentHostAuthenticationService.ts": { "statements": { - "covered": 93, - "total": 121, - "percentage": 76.85 + "covered": 95, + "total": 127, + "percentage": 74.8 }, "branches": { "covered": 14, - "total": 22, - "percentage": 63.63 + "total": 24, + "percentage": 58.33 }, "functions": { "covered": 5, @@ -1879,9 +1901,9 @@ "percentage": 83.33 }, "lines": { - "covered": 93, - "total": 121, - "percentage": 76.85 + "covered": 95, + "total": 127, + "percentage": 74.8 } }, "src/vs/platform/agentHost/node/agentHostBangCommand.ts": { @@ -1930,14 +1952,14 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts": { "statements": { - "covered": 245, + "covered": 272, "total": 333, - "percentage": 73.57 + "percentage": 81.68 }, "branches": { - "covered": 19, - "total": 39, - "percentage": 48.71 + "covered": 36, + "total": 44, + "percentage": 81.81 }, "functions": { "covered": 11, @@ -1945,31 +1967,31 @@ "percentage": 73.33 }, "lines": { - "covered": 245, + "covered": 272, "total": 333, - "percentage": 73.57 + "percentage": 81.68 } }, "src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts": { "statements": { - "covered": 307, + "covered": 322, "total": 368, - "percentage": 83.42 + "percentage": 87.5 }, "branches": { - "covered": 63, - "total": 75, - "percentage": 84 + "covered": 60, + "total": 76, + "percentage": 78.94 }, "functions": { - "covered": 24, + "covered": 26, "total": 27, - "percentage": 88.88 + "percentage": 96.29 }, "lines": { - "covered": 307, + "covered": 322, "total": 368, - "percentage": 83.42 + "percentage": 87.5 } }, "src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts": { @@ -1979,9 +2001,9 @@ "percentage": 57.69 }, "branches": { - "covered": 25, - "total": 29, - "percentage": 86.2 + "covered": 26, + "total": 30, + "percentage": 86.66 }, "functions": { "covered": 7, @@ -1996,24 +2018,24 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetService.ts": { "statements": { - "covered": 700, + "covered": 750, "total": 1122, - "percentage": 62.38 + "percentage": 66.84 }, "branches": { - "covered": 111, - "total": 144, - "percentage": 77.08 + "covered": 126, + "total": 163, + "percentage": 77.3 }, "functions": { - "covered": 31, + "covered": 34, "total": 52, - "percentage": 59.61 + "percentage": 65.38 }, "lines": { - "covered": 700, + "covered": 750, "total": 1122, - "percentage": 62.38 + "percentage": 66.84 } }, "src/vs/platform/agentHost/node/agentHostChangesetStateCache.ts": { @@ -2023,9 +2045,9 @@ "percentage": 88.88 }, "branches": { - "covered": 16, - "total": 19, - "percentage": 84.21 + "covered": 15, + "total": 18, + "percentage": 83.33 }, "functions": { "covered": 10, @@ -2089,9 +2111,9 @@ "percentage": 79.77 }, "branches": { - "covered": 54, - "total": 69, - "percentage": 78.26 + "covered": 53, + "total": 68, + "percentage": 77.94 }, "functions": { "covered": 11, @@ -2128,14 +2150,14 @@ }, "src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts": { "statements": { - "covered": 46, + "covered": 55, "total": 58, - "percentage": 79.31 + "percentage": 94.82 }, "branches": { - "covered": 10, + "covered": 11, "total": 13, - "percentage": 76.92 + "percentage": 84.61 }, "functions": { "covered": 4, @@ -2143,9 +2165,9 @@ "percentage": 66.66 }, "lines": { - "covered": 46, + "covered": 55, "total": 58, - "percentage": 79.31 + "percentage": 94.82 } }, "src/vs/platform/agentHost/node/agentHostCompletions.ts": { @@ -2194,14 +2216,14 @@ }, "src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts": { "statements": { - "covered": 38, + "covered": 47, "total": 47, - "percentage": 80.85 + "percentage": 100 }, "branches": { - "covered": 3, - "total": 5, - "percentage": 60 + "covered": 6, + "total": 7, + "percentage": 85.71 }, "functions": { "covered": 3, @@ -2209,9 +2231,9 @@ "percentage": 75 }, "lines": { - "covered": 38, + "covered": 47, "total": 47, - "percentage": 80.85 + "percentage": 100 } }, "src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts": { @@ -2238,24 +2260,24 @@ }, "src/vs/platform/agentHost/node/agentHostFileMonitorService.ts": { "statements": { - "covered": 141, + "covered": 165, "total": 185, - "percentage": 76.21 + "percentage": 89.18 }, "branches": { - "covered": 14, - "total": 22, - "percentage": 63.63 + "covered": 22, + "total": 34, + "percentage": 64.7 }, "functions": { - "covered": 10, + "covered": 14, "total": 14, - "percentage": 71.42 + "percentage": 100 }, "lines": { - "covered": 141, + "covered": 165, "total": 185, - "percentage": 76.21 + "percentage": 89.18 } }, "src/vs/platform/agentHost/node/agentHostGitHubEndpointService.ts": { @@ -2304,24 +2326,24 @@ }, "src/vs/platform/agentHost/node/agentHostGitService.ts": { "statements": { - "covered": 755, - "total": 1257, - "percentage": 60.06 + "covered": 840, + "total": 1413, + "percentage": 59.44 }, "branches": { - "covered": 147, - "total": 217, - "percentage": 67.74 + "covered": 152, + "total": 226, + "percentage": 67.25 }, "functions": { - "covered": 37, - "total": 61, - "percentage": 60.65 + "covered": 40, + "total": 67, + "percentage": 59.7 }, "lines": { - "covered": 755, - "total": 1257, - "percentage": 60.06 + "covered": 840, + "total": 1413, + "percentage": 59.44 } }, "src/vs/platform/agentHost/node/agentHostGitStateService.ts": { @@ -2331,9 +2353,9 @@ "percentage": 66.83 }, "branches": { - "covered": 27, - "total": 43, - "percentage": 62.79 + "covered": 28, + "total": 44, + "percentage": 63.63 }, "functions": { "covered": 5, @@ -2348,24 +2370,24 @@ }, "src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts": { "statements": { - "covered": 117, + "covered": 121, "total": 145, - "percentage": 80.68 + "percentage": 83.44 }, "branches": { - "covered": 11, - "total": 15, - "percentage": 73.33 - }, - "functions": { - "covered": 9, - "total": 12, + "covered": 12, + "total": 16, "percentage": 75 }, + "functions": { + "covered": 10, + "total": 12, + "percentage": 83.33 + }, "lines": { - "covered": 117, + "covered": 121, "total": 145, - "percentage": 80.68 + "percentage": 83.44 } }, "src/vs/platform/agentHost/node/agentHostLocalTurns.ts": { @@ -2463,9 +2485,9 @@ "percentage": 55.55 }, "branches": { - "covered": 12, - "total": 17, - "percentage": 70.58 + "covered": 15, + "total": 20, + "percentage": 75 }, "functions": { "covered": 4, @@ -2502,9 +2524,9 @@ }, "src/vs/platform/agentHost/node/agentHostRepoInfoTelemetry.ts": { "statements": { - "covered": 88, - "total": 342, - "percentage": 25.73 + "covered": 89, + "total": 347, + "percentage": 25.64 }, "branches": { "covered": 2, @@ -2517,9 +2539,9 @@ "percentage": 15.38 }, "lines": { - "covered": 88, - "total": 342, - "percentage": 25.73 + "covered": 89, + "total": 347, + "percentage": 25.64 } }, "src/vs/platform/agentHost/node/agentHostRequestService.ts": { @@ -2590,9 +2612,9 @@ }, "src/vs/platform/agentHost/node/agentHostServerMain.ts": { "statements": { - "covered": 425, - "total": 474, - "percentage": 89.66 + "covered": 428, + "total": 477, + "percentage": 89.72 }, "branches": { "covered": 18, @@ -2605,16 +2627,16 @@ "percentage": 83.33 }, "lines": { - "covered": 425, - "total": 474, - "percentage": 89.66 + "covered": 428, + "total": 477, + "percentage": 89.72 } }, "src/vs/platform/agentHost/node/agentHostSessionTitleController.ts": { "statements": { - "covered": 437, - "total": 507, - "percentage": 86.19 + "covered": 446, + "total": 535, + "percentage": 83.36 }, "branches": { "covered": 78, @@ -2623,13 +2645,13 @@ }, "functions": { "covered": 22, - "total": 28, - "percentage": 78.57 + "total": 29, + "percentage": 75.86 }, "lines": { - "covered": 437, - "total": 507, - "percentage": 86.19 + "covered": 446, + "total": 535, + "percentage": 83.36 } }, "src/vs/platform/agentHost/node/agentHostShellUtils.ts": { @@ -2700,24 +2722,24 @@ }, "src/vs/platform/agentHost/node/agentHostStateManager.ts": { "statements": { - "covered": 1325, + "covered": 1358, "total": 1495, - "percentage": 88.62 + "percentage": 90.83 }, "branches": { - "covered": 189, - "total": 232, - "percentage": 81.46 + "covered": 196, + "total": 235, + "percentage": 83.4 }, "functions": { - "covered": 50, + "covered": 51, "total": 62, - "percentage": 80.64 + "percentage": 82.25 }, "lines": { - "covered": 1325, + "covered": 1358, "total": 1495, - "percentage": 88.62 + "percentage": 90.83 } }, "src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts": { @@ -2744,14 +2766,14 @@ }, "src/vs/platform/agentHost/node/agentHostSyncOperationProvider.ts": { "statements": { - "covered": 47, + "covered": 49, "total": 61, - "percentage": 77.04 + "percentage": 80.32 }, "branches": { - "covered": 5, - "total": 9, - "percentage": 55.55 + "covered": 8, + "total": 11, + "percentage": 72.72 }, "functions": { "covered": 4, @@ -2759,31 +2781,31 @@ "percentage": 66.66 }, "lines": { - "covered": 47, + "covered": 49, "total": 61, - "percentage": 77.04 + "percentage": 80.32 } }, "src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts": { "statements": { - "covered": 477, - "total": 608, - "percentage": 78.45 + "covered": 615, + "total": 752, + "percentage": 81.78 }, "branches": { - "covered": 29, - "total": 47, - "percentage": 61.7 + "covered": 31, + "total": 52, + "percentage": 59.61 }, "functions": { - "covered": 9, - "total": 14, - "percentage": 64.28 + "covered": 10, + "total": 15, + "percentage": 66.66 }, "lines": { - "covered": 477, - "total": 608, - "percentage": 78.45 + "covered": 615, + "total": 752, + "percentage": 81.78 } }, "src/vs/platform/agentHost/node/agentHostTelemetryService.ts": { @@ -2810,14 +2832,14 @@ }, "src/vs/platform/agentHost/node/agentHostTerminalManager.ts": { "statements": { - "covered": 875, + "covered": 887, "total": 971, - "percentage": 90.11 + "percentage": 91.34 }, "branches": { - "covered": 116, - "total": 146, - "percentage": 79.45 + "covered": 118, + "total": 147, + "percentage": 80.27 }, "functions": { "covered": 36, @@ -2825,9 +2847,9 @@ "percentage": 87.8 }, "lines": { - "covered": 875, + "covered": 887, "total": 971, - "percentage": 90.11 + "percentage": 91.34 } }, "src/vs/platform/agentHost/node/agentHostToolCallTracker.ts": { @@ -3008,46 +3030,46 @@ }, "src/vs/platform/agentHost/node/agentService.ts": { "statements": { - "covered": 2480, - "total": 4022, - "percentage": 61.66 + "covered": 2523, + "total": 4118, + "percentage": 61.26 }, "branches": { - "covered": 281, - "total": 476, - "percentage": 59.03 + "covered": 285, + "total": 482, + "percentage": 59.12 }, "functions": { - "covered": 91, - "total": 147, - "percentage": 61.9 + "covered": 93, + "total": 149, + "percentage": 62.41 }, "lines": { - "covered": 2480, - "total": 4022, - "percentage": 61.66 + "covered": 2523, + "total": 4118, + "percentage": 61.26 } }, "src/vs/platform/agentHost/node/agentSideEffects.ts": { "statements": { - "covered": 1455, - "total": 1811, - "percentage": 80.34 + "covered": 1537, + "total": 1886, + "percentage": 81.49 }, "branches": { - "covered": 237, - "total": 322, - "percentage": 73.6 + "covered": 262, + "total": 351, + "percentage": 74.64 }, "functions": { - "covered": 47, - "total": 53, - "percentage": 88.67 + "covered": 48, + "total": 54, + "percentage": 88.88 }, "lines": { - "covered": 1455, - "total": 1811, - "percentage": 80.34 + "covered": 1537, + "total": 1886, + "percentage": 81.49 } }, "src/vs/platform/agentHost/node/appNodeModules.ts": { @@ -3140,36 +3162,36 @@ }, "src/vs/platform/agentHost/node/claude/claudeAgent.ts": { "statements": { - "covered": 1616, - "total": 2325, - "percentage": 69.5 + "covered": 1644, + "total": 2400, + "percentage": 68.5 }, "branches": { - "covered": 135, - "total": 234, - "percentage": 57.69 + "covered": 136, + "total": 239, + "percentage": 56.9 }, "functions": { - "covered": 63, - "total": 93, - "percentage": 67.74 + "covered": 64, + "total": 95, + "percentage": 67.36 }, "lines": { - "covered": 1616, - "total": 2325, - "percentage": 69.5 + "covered": 1644, + "total": 2400, + "percentage": 68.5 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts": { "statements": { - "covered": 266, + "covered": 264, "total": 312, - "percentage": 85.25 + "percentage": 84.61 }, "branches": { "covered": 12, - "total": 16, - "percentage": 75 + "total": 17, + "percentage": 70.58 }, "functions": { "covered": 10, @@ -3177,31 +3199,31 @@ "percentage": 66.66 }, "lines": { - "covered": 266, + "covered": 264, "total": 312, - "percentage": 85.25 + "percentage": 84.61 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSession.ts": { "statements": { - "covered": 905, - "total": 1121, - "percentage": 80.73 + "covered": 961, + "total": 1182, + "percentage": 81.3 }, "branches": { - "covered": 47, - "total": 74, - "percentage": 63.51 + "covered": 55, + "total": 87, + "percentage": 63.21 }, "functions": { - "covered": 24, - "total": 51, - "percentage": 47.05 + "covered": 26, + "total": 53, + "percentage": 49.05 }, "lines": { - "covered": 905, - "total": 1121, - "percentage": 80.73 + "covered": 961, + "total": 1182, + "percentage": 81.3 } }, "src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts": { @@ -3470,9 +3492,9 @@ }, "src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts": { "statements": { - "covered": 502, + "covered": 501, "total": 647, - "percentage": 77.58 + "percentage": 77.43 }, "branches": { "covered": 57, @@ -3485,9 +3507,9 @@ "percentage": 78.26 }, "lines": { - "covered": 502, + "covered": 501, "total": 647, - "percentage": 77.58 + "percentage": 77.43 } }, "src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts": { @@ -3514,14 +3536,14 @@ }, "src/vs/platform/agentHost/node/claude/claudeSdkOptions.ts": { "statements": { - "covered": 238, - "total": 255, - "percentage": 93.33 + "covered": 250, + "total": 268, + "percentage": 93.28 }, "branches": { "covered": 10, - "total": 19, - "percentage": 52.63 + "total": 20, + "percentage": 50 }, "functions": { "covered": 3, @@ -3529,9 +3551,9 @@ "percentage": 75 }, "lines": { - "covered": 238, - "total": 255, - "percentage": 93.33 + "covered": 250, + "total": 268, + "percentage": 93.28 } }, "src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts": { @@ -3580,24 +3602,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts": { "statements": { - "covered": 162, - "total": 205, - "percentage": 79.02 + "covered": 182, + "total": 240, + "percentage": 75.83 }, "branches": { - "covered": 14, - "total": 25, - "percentage": 56 + "covered": 15, + "total": 29, + "percentage": 51.72 }, "functions": { - "covered": 5, - "total": 7, - "percentage": 71.42 + "covered": 6, + "total": 8, + "percentage": 75 }, "lines": { - "covered": 162, - "total": 205, - "percentage": 79.02 + "covered": 182, + "total": 240, + "percentage": 75.83 } }, "src/vs/platform/agentHost/node/claude/claudeSessionPermissionMode.ts": { @@ -3864,6 +3886,50 @@ "percentage": 97.8 } }, + "src/vs/platform/agentHost/node/claude/customizations/claudeCustomizationPolicy.ts": { + "statements": { + "covered": 19, + "total": 42, + "percentage": 45.23 + }, + "branches": { + "covered": 1, + "total": 4, + "percentage": 25 + }, + "functions": { + "covered": 1, + "total": 3, + "percentage": 33.33 + }, + "lines": { + "covered": 19, + "total": 42, + "percentage": 45.23 + } + }, + "src/vs/platform/agentHost/node/claude/customizations/claudeMultiRootCustomizationDiscovery.ts": { + "statements": { + "covered": 49, + "total": 72, + "percentage": 68.05 + }, + "branches": { + "covered": 2, + "total": 4, + "percentage": 50 + }, + "functions": { + "covered": 2, + "total": 4, + "percentage": 50 + }, + "lines": { + "covered": 49, + "total": 72, + "percentage": 68.05 + } + }, "src/vs/platform/agentHost/node/claude/customizations/claudeSessionClientCustomizationsModel.ts": { "statements": { "covered": 168, @@ -3888,46 +3954,46 @@ }, "src/vs/platform/agentHost/node/claude/customizations/claudeSessionCustomizationDiscovery.ts": { "statements": { - "covered": 422, - "total": 539, - "percentage": 78.29 + "covered": 424, + "total": 551, + "percentage": 76.95 }, "branches": { - "covered": 41, - "total": 61, - "percentage": 67.21 + "covered": 43, + "total": 68, + "percentage": 63.23 }, "functions": { - "covered": 11, - "total": 13, - "percentage": 84.61 + "covered": 12, + "total": 14, + "percentage": 85.71 }, "lines": { - "covered": 422, - "total": 539, - "percentage": 78.29 + "covered": 424, + "total": 551, + "percentage": 76.95 } }, "src/vs/platform/agentHost/node/claude/customizations/scan/claudeAgentSkillScan.ts": { "statements": { - "covered": 98, - "total": 105, - "percentage": 93.33 + "covered": 107, + "total": 114, + "percentage": 93.85 }, "branches": { - "covered": 5, - "total": 7, - "percentage": 71.42 + "covered": 6, + "total": 9, + "percentage": 66.66 }, "functions": { - "covered": 4, - "total": 4, + "covered": 5, + "total": 5, "percentage": 100 }, "lines": { - "covered": 98, - "total": 105, - "percentage": 93.33 + "covered": 107, + "total": 114, + "percentage": 93.85 } }, "src/vs/platform/agentHost/node/claude/customizations/scan/claudeHookScan.ts": { @@ -3976,24 +4042,24 @@ }, "src/vs/platform/agentHost/node/claude/customizations/scan/claudeNativePluginScan.ts": { "statements": { - "covered": 122, - "total": 220, - "percentage": 55.45 + "covered": 135, + "total": 266, + "percentage": 50.75 }, "branches": { - "covered": 4, - "total": 11, - "percentage": 36.36 + "covered": 5, + "total": 13, + "percentage": 38.46 }, "functions": { - "covered": 3, - "total": 7, - "percentage": 42.85 + "covered": 4, + "total": 10, + "percentage": 40 }, "lines": { - "covered": 122, - "total": 220, - "percentage": 55.45 + "covered": 135, + "total": 266, + "percentage": 50.75 } }, "src/vs/platform/agentHost/node/claude/customizations/scan/claudeRuleScan.ts": { @@ -4042,24 +4108,24 @@ }, "src/vs/platform/agentHost/node/codex/codexAgent.ts": { "statements": { - "covered": 2540, - "total": 4445, - "percentage": 57.14 + "covered": 2580, + "total": 4602, + "percentage": 56.06 }, "branches": { - "covered": 193, - "total": 354, - "percentage": 54.51 + "covered": 196, + "total": 373, + "percentage": 52.54 }, "functions": { - "covered": 85, - "total": 162, - "percentage": 52.46 + "covered": 87, + "total": 169, + "percentage": 51.47 }, "lines": { - "covered": 2540, - "total": 4445, - "percentage": 57.14 + "covered": 2580, + "total": 4602, + "percentage": 56.06 } }, "src/vs/platform/agentHost/node/codex/codexAppServerClient.ts": { @@ -4197,8 +4263,8 @@ "src/vs/platform/agentHost/node/codex/codexLaunchConfig.ts": { "statements": { "covered": 58, - "total": 68, - "percentage": 85.29 + "total": 72, + "percentage": 80.55 }, "branches": { "covered": 2, @@ -4212,8 +4278,8 @@ }, "lines": { "covered": 58, - "total": 68, - "percentage": 85.29 + "total": 72, + "percentage": 80.55 } }, "src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts": { @@ -4328,9 +4394,9 @@ }, "src/vs/platform/agentHost/node/codex/codexReplayMapper.ts": { "statements": { - "covered": 58, - "total": 222, - "percentage": 26.12 + "covered": 60, + "total": 240, + "percentage": 25 }, "branches": { "covered": 0, @@ -4339,13 +4405,13 @@ }, "functions": { "covered": 0, - "total": 6, + "total": 7, "percentage": 0 }, "lines": { - "covered": 58, - "total": 222, - "percentage": 26.12 + "covered": 60, + "total": 240, + "percentage": 25 } }, "src/vs/platform/agentHost/node/codex/codexSessionConfigKeys.ts": { @@ -4355,9 +4421,9 @@ "percentage": 87.81 }, "branches": { - "covered": 20, - "total": 41, - "percentage": 48.78 + "covered": 21, + "total": 42, + "percentage": 50 }, "functions": { "covered": 11, @@ -4372,36 +4438,36 @@ }, "src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts": { "statements": { - "covered": 86, - "total": 112, - "percentage": 76.78 + "covered": 99, + "total": 157, + "percentage": 63.05 }, "branches": { - "covered": 2, - "total": 3, - "percentage": 66.66 + "covered": 3, + "total": 6, + "percentage": 50 }, "functions": { - "covered": 2, - "total": 3, - "percentage": 66.66 + "covered": 3, + "total": 5, + "percentage": 60 }, "lines": { - "covered": 86, - "total": 112, - "percentage": 76.78 + "covered": 99, + "total": 157, + "percentage": 63.05 } }, "src/vs/platform/agentHost/node/codex/codexShellCommand.ts": { "statements": { - "covered": 38, + "covered": 36, "total": 42, - "percentage": 90.47 + "percentage": 85.71 }, "branches": { - "covered": 5, - "total": 8, - "percentage": 62.5 + "covered": 2, + "total": 6, + "percentage": 33.33 }, "functions": { "covered": 2, @@ -4409,9 +4475,9 @@ "percentage": 100 }, "lines": { - "covered": 38, + "covered": 36, "total": 42, - "percentage": 90.47 + "percentage": 85.71 } }, "src/vs/platform/agentHost/node/codex/codexUserInputMapper.ts": { @@ -4438,31 +4504,31 @@ }, "src/vs/platform/agentHost/node/commandAutoApprover.ts": { "statements": { - "covered": 477, - "total": 595, - "percentage": 80.16 + "covered": 565, + "total": 705, + "percentage": 80.14 }, "branches": { - "covered": 33, - "total": 61, - "percentage": 54.09 + "covered": 41, + "total": 83, + "percentage": 49.39 }, "functions": { - "covered": 12, - "total": 14, - "percentage": 85.71 + "covered": 16, + "total": 19, + "percentage": 84.21 }, "lines": { - "covered": 477, - "total": 595, - "percentage": 80.16 + "covered": 565, + "total": 705, + "percentage": 80.14 } }, "src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts": { "statements": { - "covered": 99, - "total": 141, - "percentage": 70.21 + "covered": 97, + "total": 139, + "percentage": 69.78 }, "branches": { "covered": 4, @@ -4475,16 +4541,16 @@ "percentage": 30.76 }, "lines": { - "covered": 99, - "total": 141, - "percentage": 70.21 + "covered": 97, + "total": 139, + "percentage": 69.78 } }, "src/vs/platform/agentHost/node/copilot/buildSessionEvents.ts": { "statements": { - "covered": 90, - "total": 278, - "percentage": 32.37 + "covered": 91, + "total": 279, + "percentage": 32.61 }, "branches": { "covered": 0, @@ -4497,9 +4563,9 @@ "percentage": 0 }, "lines": { - "covered": 90, - "total": 278, - "percentage": 32.37 + "covered": 91, + "total": 279, + "percentage": 32.61 } }, "src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts": { @@ -4553,9 +4619,9 @@ "percentage": 69.75 }, "branches": { - "covered": 354, + "covered": 355, "total": 594, - "percentage": 59.59 + "percentage": 59.76 }, "functions": { "covered": 153, @@ -4570,24 +4636,24 @@ }, "src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts": { "statements": { - "covered": 3063, - "total": 4791, - "percentage": 63.93 + "covered": 3249, + "total": 5086, + "percentage": 63.88 }, "branches": { - "covered": 403, - "total": 640, - "percentage": 62.96 + "covered": 429, + "total": 705, + "percentage": 60.85 }, "functions": { - "covered": 118, - "total": 177, - "percentage": 66.66 + "covered": 125, + "total": 185, + "percentage": 67.56 }, "lines": { - "covered": 3063, - "total": 4791, - "percentage": 63.93 + "covered": 3249, + "total": 5086, + "percentage": 63.88 } }, "src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts": { @@ -4724,23 +4790,23 @@ }, "src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts": { "statements": { - "covered": 285, - "total": 285, + "covered": 290, + "total": 290, "percentage": 100 }, "branches": { - "covered": 56, - "total": 56, + "covered": 57, + "total": 57, "percentage": 100 }, "functions": { - "covered": 52, - "total": 53, - "percentage": 98.11 + "covered": 53, + "total": 54, + "percentage": 98.14 }, "lines": { - "covered": 285, - "total": 285, + "covered": 290, + "total": 290, "percentage": 100 } }, @@ -4856,9 +4922,9 @@ }, "src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts": { "statements": { - "covered": 908, - "total": 1201, - "percentage": 75.6 + "covered": 910, + "total": 1203, + "percentage": 75.64 }, "branches": { "covered": 96, @@ -4871,31 +4937,31 @@ "percentage": 67.74 }, "lines": { - "covered": 908, - "total": 1201, - "percentage": 75.6 + "covered": 910, + "total": 1203, + "percentage": 75.64 } }, "src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts": { "statements": { - "covered": 539, - "total": 829, - "percentage": 65.01 + "covered": 572, + "total": 866, + "percentage": 66.05 }, "branches": { - "covered": 62, - "total": 130, - "percentage": 47.69 + "covered": 60, + "total": 134, + "percentage": 44.77 }, "functions": { - "covered": 17, - "total": 18, - "percentage": 94.44 + "covered": 19, + "total": 20, + "percentage": 95 }, "lines": { - "covered": 539, - "total": 829, - "percentage": 65.01 + "covered": 572, + "total": 866, + "percentage": 66.05 } }, "src/vs/platform/agentHost/node/copilot/pendingEditContentStore.ts": { @@ -5098,31 +5164,31 @@ }, "src/vs/platform/agentHost/node/diffComputeService.ts": { "statements": { - "covered": 75, - "total": 94, - "percentage": 79.78 + "covered": 81, + "total": 102, + "percentage": 79.41 }, "branches": { - "covered": 9, - "total": 13, - "percentage": 69.23 + "covered": 11, + "total": 15, + "percentage": 73.33 }, "functions": { - "covered": 4, - "total": 4, - "percentage": 100 + "covered": 6, + "total": 7, + "percentage": 85.71 }, "lines": { - "covered": 75, - "total": 94, - "percentage": 79.78 + "covered": 81, + "total": 102, + "percentage": 79.41 } }, "src/vs/platform/agentHost/node/diffWorkerMain.ts": { "statements": { - "covered": 78, - "total": 92, - "percentage": 84.78 + "covered": 92, + "total": 174, + "percentage": 52.87 }, "branches": { "covered": 13, @@ -5131,13 +5197,13 @@ }, "functions": { "covered": 4, - "total": 4, - "percentage": 100 + "total": 9, + "percentage": 44.44 }, "lines": { - "covered": 78, - "total": 92, - "percentage": 84.78 + "covered": 92, + "total": 174, + "percentage": 52.87 } }, "src/vs/platform/agentHost/node/gitDiffContent.ts": { @@ -5235,9 +5301,9 @@ "percentage": 100 }, "branches": { - "covered": 13, - "total": 14, - "percentage": 92.85 + "covered": 15, + "total": 16, + "percentage": 93.75 }, "functions": { "covered": 5, @@ -5318,24 +5384,24 @@ }, "src/vs/platform/agentHost/node/protocolServerHandler.ts": { "statements": { - "covered": 1161, - "total": 1646, - "percentage": 70.53 + "covered": 1283, + "total": 1638, + "percentage": 78.32 }, "branches": { - "covered": 154, - "total": 227, - "percentage": 67.84 + "covered": 187, + "total": 261, + "percentage": 71.64 }, "functions": { - "covered": 62, + "covered": 66, "total": 81, - "percentage": 76.54 + "percentage": 81.48 }, "lines": { - "covered": 1161, - "total": 1646, - "percentage": 70.53 + "covered": 1283, + "total": 1638, + "percentage": 78.32 } }, "src/vs/platform/agentHost/node/serverUrls.ts": { @@ -5384,24 +5450,24 @@ }, "src/vs/platform/agentHost/node/sessionDatabase.ts": { "statements": { - "covered": 591, - "total": 781, - "percentage": 75.67 + "covered": 654, + "total": 869, + "percentage": 75.25 }, "branches": { - "covered": 98, - "total": 119, - "percentage": 82.35 + "covered": 104, + "total": 125, + "percentage": 83.2 }, "functions": { - "covered": 31, - "total": 51, - "percentage": 60.78 + "covered": 32, + "total": 53, + "percentage": 60.37 }, "lines": { - "covered": 591, - "total": 781, - "percentage": 75.67 + "covered": 654, + "total": 869, + "percentage": 75.25 } }, "src/vs/platform/agentHost/node/sessionDiffAggregator.ts": { @@ -5428,24 +5494,24 @@ }, "src/vs/platform/agentHost/node/sessionPermissions.ts": { "statements": { - "covered": 480, - "total": 635, - "percentage": 75.59 + "covered": 521, + "total": 687, + "percentage": 75.83 }, "branches": { - "covered": 60, - "total": 98, - "percentage": 61.22 + "covered": 72, + "total": 112, + "percentage": 64.28 }, "functions": { - "covered": 21, - "total": 25, - "percentage": 84 + "covered": 23, + "total": 28, + "percentage": 82.14 }, "lines": { - "covered": 480, - "total": 635, - "percentage": 75.59 + "covered": 521, + "total": 687, + "percentage": 75.83 } }, "src/vs/platform/agentHost/node/shared/agentBranchNameGenerator.ts": { @@ -5558,6 +5624,28 @@ "percentage": 94.44 } }, + "src/vs/platform/agentHost/node/shared/arcToolEdit.ts": { + "statements": { + "covered": 78, + "total": 97, + "percentage": 80.41 + }, + "branches": { + "covered": 14, + "total": 24, + "percentage": 58.33 + }, + "functions": { + "covered": 7, + "total": 7, + "percentage": 100 + }, + "lines": { + "covered": 78, + "total": 97, + "percentage": 80.41 + } + }, "src/vs/platform/agentHost/node/shared/copilotApiService.ts": { "statements": { "covered": 1029, @@ -5580,6 +5668,28 @@ "percentage": 80.26 } }, + "src/vs/platform/agentHost/node/shared/editArcReporter.ts": { + "statements": { + "covered": 142, + "total": 373, + "percentage": 38.06 + }, + "branches": { + "covered": 6, + "total": 11, + "percentage": 54.54 + }, + "functions": { + "covered": 4, + "total": 16, + "percentage": 25 + }, + "lines": { + "covered": 142, + "total": 373, + "percentage": 38.06 + } + }, "src/vs/platform/agentHost/node/shared/editChunkExtractor.ts": { "statements": { "covered": 123, @@ -5648,24 +5758,24 @@ }, "src/vs/platform/agentHost/node/shared/fileEditTracker.ts": { "statements": { - "covered": 254, - "total": 307, - "percentage": 82.73 + "covered": 229, + "total": 246, + "percentage": 93.08 }, "branches": { "covered": 27, - "total": 37, - "percentage": 72.97 + "total": 34, + "percentage": 79.41 }, "functions": { - "covered": 9, - "total": 12, - "percentage": 75 + "covered": 7, + "total": 7, + "percentage": 100 }, "lines": { - "covered": 254, - "total": 307, - "percentage": 82.73 + "covered": 229, + "total": 246, + "percentage": 93.08 } }, "src/vs/platform/agentHost/node/shared/forwardedChatError.ts": { @@ -5780,9 +5890,9 @@ }, "src/vs/platform/agentHost/node/shared/sessionServerTools.ts": { "statements": { - "covered": 503, - "total": 1097, - "percentage": 45.85 + "covered": 505, + "total": 1098, + "percentage": 45.99 }, "branches": { "covered": 3, @@ -5791,13 +5901,13 @@ }, "functions": { "covered": 3, - "total": 49, - "percentage": 6.12 + "total": 50, + "percentage": 6 }, "lines": { - "covered": 503, - "total": 1097, - "percentage": 45.85 + "covered": 505, + "total": 1098, + "percentage": 45.99 } }, "src/vs/platform/agentHost/node/shared/shellCommandExecution.ts": { @@ -5824,24 +5934,24 @@ }, "src/vs/platform/agentHost/node/shared/worktreeIsolation.ts": { "statements": { - "covered": 652, - "total": 837, - "percentage": 77.89 + "covered": 731, + "total": 926, + "percentage": 78.94 }, "branches": { - "covered": 67, - "total": 105, - "percentage": 63.8 + "covered": 76, + "total": 115, + "percentage": 66.08 }, "functions": { - "covered": 29, - "total": 40, - "percentage": 72.5 + "covered": 31, + "total": 42, + "percentage": 73.8 }, "lines": { - "covered": 652, - "total": 837, - "percentage": 77.89 + "covered": 731, + "total": 926, + "percentage": 78.94 } }, "src/vs/platform/agentHost/node/webSocketTransport.ts": { @@ -5851,9 +5961,9 @@ "percentage": 85.71 }, "branches": { - "covered": 14, - "total": 22, - "percentage": 63.63 + "covered": 15, + "total": 23, + "percentage": 65.21 }, "functions": { "covered": 7, diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index e0b7a610aee..fc9fbc895a1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -180,12 +180,12 @@ const POSIX_COMMAND_EXCEPTIONS = new Set([]); * `harness/modelRequestProjection.ts`. */ const STALE_RECORDED_REQUEST_EXCEPTIONS = new Set([ - // Re-recording drives a real provider-context fork, which Claude rejects - // with "Invalid upToMessageId: turn-source" — the same defect that gates - // `supportsChatForkE2E`. The capture predates the host's - // `` preamble and cannot be refreshed until that is - // fixed. Claude only: the other providers fork fine and their captures are - // current. + // Re-recording anchors a side chat on a source turn, which hits the same + // anchor-resolution defect that gates `supportsChatForkE2E`: Claude cannot + // resolve a client-assigned turn id, so the fork silently degrades to an + // injected context preamble. The capture predates that preamble and cannot + // be refreshed until the defect is fixed. Claude only: the other providers + // fork fine and their captures are current. 'claude:side chat receives bounded source context without copied history', ]); @@ -268,6 +268,8 @@ export interface IAgentHostE2EProviderConfig { * plan mode. (`exit_plan_mode` for Copilot, `ExitPlanMode` for Claude.) */ readonly exitPlanModeToolName: string; + /** File-creation tool that exposes model-generated argument deltas, when supported. */ + readonly streamingFileCreateToolName?: string; /** * Whether the suite should be enabled. Returning false skips the suite * entirely (mirrors `suite.skip(...)`). @@ -792,6 +794,23 @@ export class AgentHostE2EServerLease { return { server: this._server, client: this._client }; } + /** + * Open an additional connection to the current server. + * + * `reconnect` is only answerable on a transport that has not completed the + * handshake, so a test that exercises connection recovery needs a second + * socket it can close and re-establish without disturbing the shared + * client. The caller owns the returned client and must close it. + */ + async connectClient(): Promise { + if (!this._server) { + throw new Error('[agent-host-e2e] no server acquired yet'); + } + const client = new TestProtocolClient(this._server.port); + await client.connect(); + return client; + } + /** Stop the current shared server so the next {@link acquire} starts a fresh one. */ private async _recycleSharedServer(): Promise { try { diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml index a82a43c2b4d..a105f202a42 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__client_tool_reaches_ready_after_start_and_completes.traffic.ahp.yaml @@ -67,9 +67,6 @@ rounds: - channel: ${session_0} action: type: session/changesetsChanged - - channel: ${session_0} - action: - type: session/metaChanged - channel: ${chat_0} action: type: chat/usage @@ -169,9 +166,6 @@ rounds: part: kind: markdown content: 'The magic word is: **XYLOPHONE**' - - channel: ${session_0} - action: - type: session/metaChanged - channel: ${chat_0} action: type: chat/usage diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts index b767dea2572..1ac1564dc6a 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts @@ -61,6 +61,7 @@ const CLAUDE_CONFIG: IAgentHostE2EProviderConfig = { shellToolName: 'Bash', subagentToolNames: ['Task', 'Agent'], exitPlanModeToolName: 'ExitPlanMode', + streamingFileCreateToolName: 'Write', enabled: !!CLAUDE_SDK_ROOT, claudeSdkRoot: CLAUDE_SDK_ROOT, // Worktree isolation is now shared across agents via the host-owned diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts index b8704b66dd4..7864133e0ea 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts @@ -29,8 +29,8 @@ import { mkdtemp, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { MessageAttachmentKind, MessageKind, PendingMessageKind, ToolCallConfirmationReason, buildDefaultChatUri, type MessageAttachment } from '../../../../common/state/sessionState.js'; -import { ActionType, type ChatUsageAction } from '../../../../common/state/sessionActions.js'; +import { MessageAttachmentKind, MessageKind, PendingMessageKind, ToolCallConfirmationReason, ToolCallContributorKind, buildDefaultChatUri, type MessageAttachment } from '../../../../common/state/sessionState.js'; +import { ActionType, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatUsageAction } from '../../../../common/state/sessionActions.js'; import { AgentHostE2EServerLease, createRealSession, dispatchTurn, driveTurnWithAttachmentsToCompletion, runAhpSnapshotTest, type IAgentHostE2EProviderConfig, @@ -45,6 +45,7 @@ const COPILOT_CONFIG: IAgentHostE2EProviderConfig = { shellToolName: 'bash', subagentToolNames: ['task'], exitPlanModeToolName: 'exit_plan_mode', + streamingFileCreateToolName: 'create', // The shared suite runs by default in deterministic replay mode (tokenless, // against committed fixtures). Recording new fixtures is opt-in via // `AGENT_HOST_REPLAY_RECORD=1`. The Copilot CLI is always present (dev dep). @@ -106,6 +107,27 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { test('client tool reaches ready after start and completes', async function () { this.timeout(180_000); await runAhpSnapshotTest(client, COPILOT_CONFIG, this.test!, createdSessions, tempDirs); + + const start = client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallStart')) + .map(n => getActionEnvelope(n).action as ChatToolCallStartAction) + .find(action => action.toolName === 'get_magic_word'); + const ready = start && client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallReady')) + .map(n => getActionEnvelope(n).action as ChatToolCallReadyAction) + .find(action => action.toolCallId === start.toolCallId); + const deltas = start && client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallDelta')) + .map(n => getActionEnvelope(n).action as ChatToolCallDeltaAction) + .filter(action => action.toolCallId === start.toolCallId); + + // The AHP snapshot projects contributor metadata only on Start, so Ready ownership needs an explicit assertion. + assert.deepStrictEqual({ + startContributor: start?.contributor, + readyContributor: ready?.contributor, + deltaCount: deltas?.length, + }, { + startContributor: { kind: ToolCallContributorKind.Client, clientId: 'copilot-client-tool' }, + readyContributor: { kind: ToolCallContributorKind.Client, clientId: 'copilot-client-tool' }, + deltaCount: 0, + }); }); test('client tool disconnect before permission still completes the turn', async function () { diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts index ff85a0dc701..35964e895b8 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts @@ -7,7 +7,10 @@ import { AgentHostE2EServerLease, type IAgentHostE2EProviderConfig, removeTempDi import type { IAgentHostTarget } from '../harness/agentHostTarget.js'; import type { TestProtocolClient } from '../../serverIntegrationTestHelpers.js'; import { defineCoreTests } from './coreSuite.js'; +import { defineAnnotationsTests } from './annotationsSuite.js'; +import { defineChangesetTests } from './changesetSuite.js'; import { defineClientFilesystemTests } from './clientFilesystemSuite.js'; +import { defineProtocolContractTests } from './protocolContractsSuite.js'; import { defineFileOperationsTests } from './fileOperationsSuite.js'; import { defineHostFeaturesTests } from './hostFeaturesSuite.js'; import { defineMultiChatTests } from './multiChatSuite.js'; @@ -50,6 +53,12 @@ function defineSuite(config: IAgentHostE2EProviderConfig, options: IDefineOption runRecordOnlyTests: RUN_RECORD_ONLY_TESTS, registerNoModelTrafficTest: title => noModelTrafficTestTitles.add(title), get observedModelRequestBodies() { return lease?.observedModelRequestBodies ?? []; }, + connectClient: () => { + if (!lease) { + throw new Error('[agent-host-e2e] no server lease'); + } + return lease.connectClient(); + }, }; suiteSetup(async function () { @@ -101,6 +110,9 @@ function defineSuite(config: IAgentHostE2EProviderConfig, options: IDefineOption defineHostFeaturesTests(context); defineStateOperationsTests(context); defineClientFilesystemTests(context); + defineAnnotationsTests(context); + defineProtocolContractTests(context); + defineChangesetTests(context); } // Suites that contain only parity-tier scenarios. diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/annotationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/annotationsSuite.ts new file mode 100644 index 00000000000..160709a9663 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/suites/annotationsSuite.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The annotations channel: client-owned review comments anchored to a resource. + * + * Every annotations action is client-dispatchable (see `ClientAnnotationsAction` + * in `action-origin.generated.ts`), and the same reducer runs on both sides, so + * these scenarios exercise the synchronized-state contract end to end without + * any model traffic: dispatch, observe the server echo, then read the channel + * back to confirm the host applied the same reduction the client did. + * + * This channel was previously covered by neither the E2E suite nor the frozen + * `../protocol/` suite. + */ + +import assert from 'assert'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../../../base/common/path.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import type { SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { ActionType } from '../../../../common/state/sessionActions.js'; +import { buildAnnotationsUri } from '../../../../common/annotationsUri.js'; +import { createRealSession } from '../harness/agentHostE2ETestHarness.js'; +import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; +import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; + +/** The subset of `Annotation` these tests assert on. */ +interface IObservedAnnotation { + readonly id: string; + readonly turnId: string; + readonly resolved: boolean; + readonly entries: readonly { readonly id: string; readonly text: string }[]; +} + +export function defineAnnotationsTests(context: IAgentHostE2ETestContext): void { + const { config, createdSessions, tempDirs } = context; + + /** + * Client sequence numbers must strictly increase for the lifetime of a + * client, and the suite shares one across tests, so they cannot be + * hard-coded per scenario. + */ + let clientSeq = 3000; + function nextClientSeq(): number { + return clientSeq++; + } + + async function createAnnotatedSession(prefix: string): Promise<{ sessionUri: string; annotationsUri: string; resource: string }> { + const workspace = mkdtempSync(join(tmpdir(), `ahp-${prefix}-`)); + tempDirs.push(workspace); + const sessionUri = await createRealSession(context.client, config, `${prefix}-${config.provider}`, createdSessions, URI.file(workspace)); + const annotationsUri = buildAnnotationsUri(sessionUri); + await context.client.call('subscribe', { channel: annotationsUri }); + context.client.clearReceived(); + return { sessionUri, annotationsUri, resource: URI.file(join(workspace, 'reviewed.ts')).toString() }; + } + + function dispatchAnnotationAction(channel: string, action: object): void { + context.client.dispatch({ channel, clientSeq: nextClientSeq(), action: action as Parameters[0]['action'] }); + } + + /** + * Waits for the server to echo `actionType` on the annotations channel, then + * reads the channel back. Reading without waiting would race the reduction + * and could observe the pre-dispatch state. + */ + async function annotationsAfter(channel: string, actionType: string): Promise { + await context.client.waitForNotification(n => + isActionNotification(n, actionType) && getActionEnvelope(n).channel === channel, + 30_000, + ); + const subscribed = await context.client.call('subscribe', { channel }); + return (subscribed.snapshot!.state as { annotations: IObservedAnnotation[] }).annotations; + } + + conformanceTest(context, 'an annotation dispatched by a client is applied to the channel', async function () { + const { annotationsUri, resource } = await createAnnotatedSession('annotations-set'); + const annotationId = generateUuid(); + + dispatchAnnotationAction(annotationsUri, { + type: ActionType.AnnotationsSet, + annotation: { + id: annotationId, + turnId: 'turn-annotate', + resource, + resolved: false, + entries: [{ id: `${annotationId}:0`, text: 'needs a second look' }], + }, + }); + + const annotations = await annotationsAfter(annotationsUri, 'annotations/set'); + + assert.deepStrictEqual(annotations.map(annotation => ({ + id: annotation.id, + turnId: annotation.turnId, + resolved: annotation.resolved, + entries: annotation.entries.map(entry => entry.text), + })), [{ + id: annotationId, + turnId: 'turn-annotate', + resolved: false, + entries: ['needs a second look'], + }]); + }); + + conformanceTest(context, 'an annotation can be resolved without resending its entries', async function () { + const { annotationsUri, resource } = await createAnnotatedSession('annotations-resolve'); + const annotationId = generateUuid(); + + dispatchAnnotationAction(annotationsUri, { + type: ActionType.AnnotationsSet, + annotation: { id: annotationId, turnId: 'turn-resolve', resource, resolved: false, entries: [{ id: `${annotationId}:0`, text: 'why this branch?' }] }, + }); + await annotationsAfter(annotationsUri, 'annotations/set'); + + // `annotations/updated` carries only the fields that change, so + // resolving must not disturb the entries already on the annotation. + context.client.clearReceived(); + dispatchAnnotationAction(annotationsUri, { type: ActionType.AnnotationsUpdated, annotationId, resolved: true }); + + const annotations = await annotationsAfter(annotationsUri, 'annotations/updated'); + + assert.deepStrictEqual(annotations.map(annotation => ({ + resolved: annotation.resolved, + entries: annotation.entries.map(entry => entry.text), + })), [{ + resolved: true, + entries: ['why this branch?'], + }]); + }); + + conformanceTest(context, 'entries can be added to and removed from an annotation', async function () { + const { annotationsUri, resource } = await createAnnotatedSession('annotations-entries'); + const annotationId = generateUuid(); + const replyId = `${annotationId}:1`; + + dispatchAnnotationAction(annotationsUri, { + type: ActionType.AnnotationsSet, + annotation: { id: annotationId, turnId: 'turn-entries', resource, resolved: false, entries: [{ id: `${annotationId}:0`, text: 'original' }] }, + }); + await annotationsAfter(annotationsUri, 'annotations/set'); + + context.client.clearReceived(); + dispatchAnnotationAction(annotationsUri, { type: ActionType.AnnotationsEntrySet, annotationId, entry: { id: replyId, text: 'reply' } }); + const withReply = await annotationsAfter(annotationsUri, 'annotations/entrySet'); + + context.client.clearReceived(); + dispatchAnnotationAction(annotationsUri, { type: ActionType.AnnotationsEntryRemoved, annotationId, entryId: replyId }); + const withoutReply = await annotationsAfter(annotationsUri, 'annotations/entryRemoved'); + + assert.deepStrictEqual({ + afterEntrySet: withReply[0]?.entries.map(entry => entry.text), + afterEntryRemoved: withoutReply[0]?.entries.map(entry => entry.text), + }, { + afterEntrySet: ['original', 'reply'], + afterEntryRemoved: ['original'], + }); + }); + + conformanceTest(context, 'removing an annotation clears it from the channel', async function () { + const { annotationsUri, resource } = await createAnnotatedSession('annotations-remove'); + const annotationId = generateUuid(); + + dispatchAnnotationAction(annotationsUri, { + type: ActionType.AnnotationsSet, + annotation: { id: annotationId, turnId: 'turn-remove', resource, resolved: false, entries: [{ id: `${annotationId}:0`, text: 'transient' }] }, + }); + await annotationsAfter(annotationsUri, 'annotations/set'); + + context.client.clearReceived(); + dispatchAnnotationAction(annotationsUri, { type: ActionType.AnnotationsRemoved, annotationId }); + + assert.deepStrictEqual(await annotationsAfter(annotationsUri, 'annotations/removed'), []); + }); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts new file mode 100644 index 00000000000..63539797715 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/suites/changesetSuite.ts @@ -0,0 +1,278 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The changeset channel: how the host reports what a session changed on disk. + * + * A changeset is computed from git rather than from what a tool reported, so + * it sees edits the agent made by any means — the scenarios here drive real + * file changes through host-executed bang commands and never cross the model + * boundary. + * + * The host publishes several changesets per session, each on its own + * subscribable channel: `branch` (against the branch point), `uncommitted` + * (working-tree state), and `session` (cumulative for the session). They are + * separate channels because `changeset/*` actions are scoped to the changeset + * URI, so a session-only subscription never receives them. + * + * This contract previously existed only in the frozen `../protocol/` suite, + * which drives a mock agent with the magic prompt `terminal-edit:` and + * so cannot describe the contract for any other AHP implementation. + */ + +import assert from 'assert'; +import { execSync } from 'child_process'; +import { mkdtempSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../../../base/common/path.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import type { SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { ActionType } from '../../../../common/state/sessionActions.js'; +import { + buildBranchChangesetUri, + buildSessionChangesetUri, + buildUncommittedChangesetUri, +} from '../../../../common/changesetUri.js'; +import { createRealSession, dispatchTurn, initTestGitRepo } from '../harness/agentHostE2ETestHarness.js'; +import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; +import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; + +/** The subset of `ChangesetFile` these tests assert on. */ +interface IObservedChangesetFile { + readonly id: string; + readonly reviewed?: boolean; + readonly edit: { + readonly before?: { readonly uri: string }; + readonly after?: { readonly uri: string }; + readonly diff?: { readonly added: number; readonly removed: number }; + }; +} + +interface IContentChangedAction { + readonly files: readonly IObservedChangesetFile[]; + readonly operations?: readonly { readonly id: string; readonly scopes: readonly string[] }[]; +} + +export function defineChangesetTests(context: IAgentHostE2ETestContext): void { + const { config, createdSessions, tempDirs } = context; + + /** + * Client sequence numbers must strictly increase for the lifetime of a + * client, and the suite shares one across tests, so they cannot be + * hard-coded per scenario. + */ + let clientSeq = 1000; + function nextClientSeq(): number { + return clientSeq++; + } + + /** A git repository with one committed file, so a branch point exists. */ + function createGitWorkspace(prefix: string): string { + const workspace = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(workspace); + initTestGitRepo(workspace); + writeFileSync(join(workspace, 'seed.txt'), 'seed\n'); + execSync('git add .', { cwd: workspace }); + execSync('git commit -q -m "seed"', { cwd: workspace }); + return workspace; + } + + async function createSessionIn(workspace: string, prefix: string): Promise { + return createRealSession(context.client, config, `${prefix}-${config.provider}`, createdSessions, URI.file(workspace)); + } + + /** + * Writes `file` through a host-executed bang command, so the change reaches + * disk the way an agent's shell edit would rather than from the test + * process. Paths are relative so no Windows backslash has to survive into a + * JavaScript string literal. + * + * The file name and contents are passed as `process.argv` entries rather + * than interpolated into the script, so a value containing a quote or a + * backslash cannot break out of the literal or change what runs. + */ + function writeFileCommand(file: string, contents: string): string { + return `!node -e "require('fs').writeFileSync(process.argv[1],process.argv[2])" ${file} ${contents}`; + } + + function fileUri(file: IObservedChangesetFile): string { + return file.edit.after?.uri ?? file.edit.before?.uri ?? ''; + } + + /** + * Waits for a `changeset/contentChanged` on `channel` that reports + * `basename`. Matched by basename because git resolves symlinks when + * reporting its top level (macOS `/var` versus `/private/var`), so the + * reported URI need not share a prefix with the workspace path. + */ + async function waitForFileInChangeset(channel: string, basename: string, timeout = 60_000): Promise { + const notification = await context.client.waitForNotification(n => { + if (!isActionNotification(n, 'changeset/contentChanged') || getActionEnvelope(n).channel !== channel) { + return false; + } + const action = getActionEnvelope(n).action as IContentChangedAction; + return action.files.some(file => fileUri(file).endsWith(`/${basename}`)); + }, timeout); + const action = getActionEnvelope(notification).action as IContentChangedAction; + return action.files.find(file => fileUri(file).endsWith(`/${basename}`))!; + } + + + conformanceTest(context, 'subscribing to a changeset reports its computation status', async function () { + const workspace = createGitWorkspace('ahp-changeset-status-'); + const sessionUri = await createSessionIn(workspace, 'changeset-status'); + const branchUri = buildBranchChangesetUri(sessionUri); + + const subscribed = await context.client.call('subscribe', { channel: branchUri }); + + // A changeset is computed asynchronously, so the snapshot a subscriber + // receives is a starting point and the terminal status arrives as an + // action. Asserting only the snapshot would pass without the host ever + // finishing the computation. + await context.client.waitForNotification(n => + isActionNotification(n, 'changeset/statusChanged') + && getActionEnvelope(n).channel === branchUri + && (getActionEnvelope(n).action as { status: string }).status === 'ready', + 60_000, + ); + + assert.deepStrictEqual({ + resource: subscribed.snapshot!.resource, + files: (subscribed.snapshot!.state as { files: unknown[] }).files, + }, { + resource: branchUri, + files: [], + }); + }); + + conformanceTest(context, 'a file written during a turn appears in the branch changeset', async function () { + const workspace = createGitWorkspace('ahp-changeset-add-'); + const sessionUri = await createSessionIn(workspace, 'changeset-add'); + const branchUri = buildBranchChangesetUri(sessionUri); + await context.client.call('subscribe', { channel: branchUri }); + + context.client.clearReceived(); + dispatchTurn(context.client, sessionUri, 'turn-changeset-add', writeFileCommand('added.txt', 'ADDED'), 1); + + const file = await waitForFileInChangeset(branchUri, 'added.txt'); + + // A newly added file has no before-side, and its diff counts the added + // line. Both come from git rather than from anything the tool reported, + // which is the property that makes the changeset trustworthy. + assert.deepStrictEqual({ + hasBeforeSide: file.edit.before !== undefined, + hasAfterSide: file.edit.after !== undefined, + diff: file.edit.diff, + reviewed: file.reviewed, + }, { + hasBeforeSide: false, + hasAfterSide: true, + diff: { added: 1, removed: 0 }, + reviewed: false, + }); + }); + + conformanceTest(context, 'editing a committed file reports both sides of the change', async function () { + const workspace = createGitWorkspace('ahp-changeset-edit-'); + const sessionUri = await createSessionIn(workspace, 'changeset-edit'); + const branchUri = buildBranchChangesetUri(sessionUri); + await context.client.call('subscribe', { channel: branchUri }); + + context.client.clearReceived(); + dispatchTurn(context.client, sessionUri, 'turn-changeset-edit', writeFileCommand('seed.txt', 'edited'), 1); + + const file = await waitForFileInChangeset(branchUri, 'seed.txt'); + + // Unlike an added file, an edit to a committed file has a before-side — + // the committed revision — so the client can render a real diff. + assert.deepStrictEqual({ + hasBeforeSide: file.edit.before !== undefined, + hasAfterSide: file.edit.after !== undefined, + }, { + hasBeforeSide: true, + hasAfterSide: true, + }); + }); + + conformanceTest(context, 'a client can mark a changeset file reviewed', async function () { + const workspace = createGitWorkspace('ahp-changeset-review-'); + const sessionUri = await createSessionIn(workspace, 'changeset-review'); + const branchUri = buildBranchChangesetUri(sessionUri); + await context.client.call('subscribe', { channel: branchUri }); + + context.client.clearReceived(); + dispatchTurn(context.client, sessionUri, 'turn-changeset-review', writeFileCommand('reviewme.txt', 'REVIEW'), 1); + const file = await waitForFileInChangeset(branchUri, 'reviewme.txt'); + + // `changeset/filesReviewChanged` is the one client-dispatchable action + // on this channel: review state is the client's to own, and the server + // echoes it back so other connected clients converge. + context.client.dispatch({ + channel: branchUri, + clientSeq: nextClientSeq(), + action: { type: ActionType.ChangesetFilesReviewChanged, files: [file.id], reviewed: true }, + }); + + const echoed = await context.client.waitForNotification(n => + isActionNotification(n, 'changeset/filesReviewChanged') + && getActionEnvelope(n).channel === branchUri, + 60_000, + ); + + assert.deepStrictEqual(getActionEnvelope(echoed).action, { + type: ActionType.ChangesetFilesReviewChanged, + files: [file.id], + reviewed: true, + }); + }); + + conformanceTest(context, 'uncommitted changes advertise the operations that act on them', async function () { + const workspace = createGitWorkspace('ahp-changeset-ops-'); + const sessionUri = await createSessionIn(workspace, 'changeset-ops'); + const uncommittedUri = buildUncommittedChangesetUri(sessionUri); + await context.client.call('subscribe', { channel: uncommittedUri }); + + context.client.clearReceived(); + dispatchTurn(context.client, sessionUri, 'turn-changeset-ops', writeFileCommand('operate.txt', 'OPERATE'), 1); + + // Operations are what a client turns into affordances, and they are + // only offered once there is something to act on — a session with no + // uncommitted changes advertises none. Each carries the scope it + // applies to, so a client knows whether to offer it for the whole + // changeset or per file. + const notification = await context.client.waitForNotification(n => { + if (!isActionNotification(n, 'changeset/contentChanged') || getActionEnvelope(n).channel !== uncommittedUri) { + return false; + } + return ((getActionEnvelope(n).action as IContentChangedAction).operations ?? []).length > 0; + }, 60_000); + + const operations = (getActionEnvelope(notification).action as IContentChangedAction).operations ?? []; + assert.deepStrictEqual(operations.map(operation => ({ id: operation.id, scopes: operation.scopes })), [ + { id: 'commit', scopes: ['changeset'] }, + { id: 'discard-changes', scopes: ['resource'] }, + ]); + }); + + conformanceTest(context, 'the session advertises its changeset catalog on separate channels', async function () { + const workspace = createGitWorkspace('ahp-changeset-catalog-'); + const sessionUri = await createSessionIn(workspace, 'changeset-catalog'); + + // Each changeset is its own channel. A client that subscribes only to + // the session never receives `changeset/*` actions, so the catalog is + // how it learns what else to subscribe to. + const subscribed = await Promise.all([ + context.client.call('subscribe', { channel: buildBranchChangesetUri(sessionUri) }), + context.client.call('subscribe', { channel: buildUncommittedChangesetUri(sessionUri) }), + context.client.call('subscribe', { channel: buildSessionChangesetUri(sessionUri) }), + ]); + + assert.deepStrictEqual(subscribed.map(result => result.snapshot!.resource), [ + buildBranchChangesetUri(sessionUri), + buildUncommittedChangesetUri(sessionUri), + buildSessionChangesetUri(sessionUri), + ]); + }); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/e2eTestContext.ts b/src/vs/platform/agentHost/test/node/e2e/suites/e2eTestContext.ts index d4a40b49d91..0744932eed5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/e2eTestContext.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/e2eTestContext.ts @@ -44,6 +44,12 @@ export interface IAgentHostE2ETestContext { readonly runRecordOnlyTests: boolean; readonly registerNoModelTrafficTest: (title: string) => void; readonly observedModelRequestBodies: readonly string[]; + /** + * Open an extra connection to the same server. Needed only by tests that + * exercise connection lifecycle, which cannot be expressed on the single + * shared connection. The caller must close what it opens. + */ + readonly connectClient: () => Promise; } function registerHostOnlyTest(context: IAgentHostE2ETestContext, title: string, run: Mocha.AsyncFunc, enabled: boolean): void { diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts index e8f2fcf6fe5..563e58079d2 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/fileOperationsSuite.ts @@ -9,10 +9,17 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from 'os'; import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; +import type { StringOrMarkdown } from '../../../../common/state/protocol/state.js'; +import type { ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallStartAction } from '../../../../common/state/sessionActions.js'; import { createRealSession, driveTurnToCompletion, initTestGitRepo } from '../harness/agentHostE2ETestHarness.js'; import { assertRecordedAhpSnapshot } from '../harness/ahpSnapshot.js'; +import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; +function stringOrMarkdownText(value: StringOrMarkdown | undefined): string | undefined { + return typeof value === 'string' ? value : value?.markdown; +} + export function defineFileOperationsTests(context: IAgentHostE2ETestContext): void { const { config, createdSessions, tempDirs, portableShellToolReplayEnabled, supportsFileTools, stableSharedServerFileScenarios } = context; const BEHAVIOR_SNAPSHOT = { profile: 'behavior' } as const; @@ -75,6 +82,52 @@ export function defineFileOperationsTests(context: IAgentHostE2ETestContext): vo await assertRecordedAhpSnapshot(this.test!, context.client, BEHAVIOR_SNAPSHOT); }); + (supportsFileTools && config.streamingFileCreateToolName ? test : test.skip)('streams rich file creation progress without exposing partial input', async function () { + this.timeout(180_000); + const workspace = mkdtempSync(join(tmpdir(), 'ahp-streaming-create-')); + tempDirs.push(workspace); + const sessionUri = await createRealSession(context.client, config, `streaming-create-${config.provider}`, createdSessions, URI.file(workspace)); + const turnId = 'turn-streaming-create'; + const expectedContent = 'STREAM_ALPHA\nSTREAM_BETA\nSTREAM_GAMMA'; + + await driveTurnToCompletion(context.client, sessionUri, turnId, `Create streaming.txt containing exactly these three lines, with no other content: +STREAM_ALPHA +STREAM_BETA +STREAM_GAMMA +Use your file creation tool; do not run a shell command. Then reply exactly "done".`, 1); + + const start = context.client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallStart')) + .map(n => getActionEnvelope(n).action as ChatToolCallStartAction) + .find(action => action.turnId === turnId && action.toolName === config.streamingFileCreateToolName); + const deltas = start ? context.client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallDelta')) + .map(n => getActionEnvelope(n).action as ChatToolCallDeltaAction) + .filter(action => action.toolCallId === start.toolCallId) : []; + const ready = start ? context.client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallReady')) + .map(n => getActionEnvelope(n).action as ChatToolCallReadyAction) + .filter(action => action.toolCallId === start.toolCallId) : []; + const progressMessages = deltas.map(delta => stringOrMarkdownText(delta.invocationMessage)); + const fileContent = readFileSync(join(workspace, 'streaming.txt'), 'utf8'); + const normalizedFileContent = fileContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + const lineCount = fileContent.split(/\r\n|\r|\n/).length; + const readyInputs = ready.map(action => action.toolInput).filter(input => input !== undefined); + + assert.deepStrictEqual({ + fileContent: normalizedFileContent.trimEnd(), + hasProgress: deltas.length > 0, + hidesPartialInput: deltas.every(delta => delta.content === ''), + showsFile: progressMessages.some(message => message?.includes('streaming.txt')), + showsLineCount: progressMessages.some(message => message?.includes(`(${lineCount} lines)`)), + readyHasFinalInput: readyInputs.some(input => ['STREAM_ALPHA', 'STREAM_BETA', 'STREAM_GAMMA'].every(value => input.includes(value))), + }, { + fileContent: expectedContent, + hasProgress: true, + hidesPartialInput: true, + showsFile: true, + showsLineCount: true, + readyHasFinalInput: true, + }); + }); + // Copilot never completes the replayed turn; Codex has no file tools, so it // cannot honor this prompt's steer away from the shell. (supportsFileTools && config.provider === 'claude' ? test : test.skip)('reads a value from JSON', async function () { diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/protocolContractsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/protocolContractsSuite.ts new file mode 100644 index 00000000000..4062e93d12e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/suites/protocolContractsSuite.ts @@ -0,0 +1,236 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Protocol-level contracts that are not tied to any one channel: liveness, + * turn-history paging, and how the host answers a client action it declares + * but does not yet implement. + * + * All of these are host-owned and cross no model boundary. + */ + +import assert from 'assert'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../../../base/common/path.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ReconnectResultType, type ReconnectResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { ActionType, type StateAction } from '../../../../common/state/sessionActions.js'; +import { buildDefaultChatUri, MessageKind, ROOT_STATE_URI } from '../../../../common/state/sessionState.js'; +import { createRealSession, dispatchTurn } from '../harness/agentHostE2ETestHarness.js'; +import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; +import { getActionEnvelope, isActionNotification, type TestProtocolClient } from '../../serverIntegrationTestHelpers.js'; +import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; + +export function defineProtocolContractTests(context: IAgentHostE2ETestContext): void { + const { config, createdSessions, tempDirs } = context; + + /** + * Client sequence numbers must strictly increase for the lifetime of a + * client, and the suite shares one across tests, so they cannot be + * hard-coded per scenario. + */ + let clientSeq = 4000; + function nextClientSeq(): number { + return clientSeq++; + } + + /** Dispatch on the shared client and wait for the server to echo it back. */ + async function dispatchAndWaitOnShared(channel: string, action: StateAction): Promise { + const seq = nextClientSeq(); + context.client.dispatch({ channel, clientSeq: seq, action }); + await context.client.waitForNotification(n => + isActionNotification(n, action.type) + && getActionEnvelope(n).channel === channel + && getActionEnvelope(n).origin?.clientSeq === seq, + 30_000, + ); + } + + async function createSession(prefix: string): Promise<{ sessionUri: string; workspace: string }> { + const workspace = mkdtempSync(join(tmpdir(), `ahp-${prefix}-`)); + tempDirs.push(workspace); + const sessionUri = await createRealSession(context.client, config, `${prefix}-${config.provider}`, createdSessions, URI.file(workspace)); + return { sessionUri, workspace }; + } + + conformanceTest(context, 'ping answers while the connection is live', async function () { + // Liveness has no payload — the response itself is the signal, so the + // contract is that the call resolves rather than what it returns. + await context.client.call('ping', { channel: ROOT_STATE_URI }); + }); + + conformanceTest(context, 'fetchTurns reports the turns a chat already has', async function () { + const { sessionUri } = await createSession('fetch-turns'); + const chatUri = buildDefaultChatUri(sessionUri); + await context.client.call('subscribe', { channel: chatUri }); + + // Give the chat a turn to page over. `/rename` is handled entirely by the + // host's local-command dispatcher, so the turn is real without crossing + // the model boundary and without depending on a shell. + dispatchTurn(context.client, sessionUri, 'turn-fetch', '/rename Fetch Turns', 1); + await context.client.waitForNotification(n => + isActionNotification(n, 'chat/turnComplete') + && getActionEnvelope(n).channel === chatUri + && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-fetch', + 60_000, + ); + + context.client.clearReceived(); + await context.client.call('fetchTurns', { channel: chatUri }); + + // `fetchTurns` answers with an empty result and delivers the page as a + // `chat/turnsLoaded` action, so the action is the contract. + const loaded = await context.client.waitForNotification(n => + isActionNotification(n, 'chat/turnsLoaded') && getActionEnvelope(n).channel === chatUri, + 30_000, + ); + + assert.strictEqual((getActionEnvelope(loaded).action as { type: string }).type, ActionType.ChatTurnsLoaded); + }); + + /** + * Runs `body` against a second connection that has completed the handshake + * under its own clientId, then drops that connection and hands back a fresh + * un-handshaked one. `reconnect` is only answerable pre-handshake, so + * recovery cannot be exercised on the shared client. + */ + async function afterConnectionDrop( + clientId: string, + body: (client: TestProtocolClient) => Promise, + ): Promise<{ carried: T; revived: TestProtocolClient }> { + const first = await context.connectClient(); + let carried: T; + try { + await first.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId }); + carried = await body(first); + } finally { + first.close(); + } + return { carried, revived: await context.connectClient() }; + } + + conformanceTest(context, 'reconnect replays only the actions a dropped client missed', async function () { + const { sessionUri } = await createSession('reconnect'); + const chatUri = buildDefaultChatUri(sessionUri); + + // The cutoff comes from the subscribe response rather than from watching + // this client receive its own dispatch: a subscription is not guaranteed + // to be installed before a dispatch sent immediately after it is handled, + // so waiting for that echo races. `fromSeq` is the same boundary and the + // response itself guarantees it. + const { carried: seenThrough, revived } = await afterConnectionDrop(`reconnect-${config.provider}`, async first => { + const subscribed = await first.call('subscribe', { channel: chatUri }); + return subscribed.snapshot!.fromSeq; + }); + + try { + // Produced while nobody was listening on that clientId, so it can only + // reach the client through replay. + await dispatchAndWaitOnShared(chatUri, { type: ActionType.ChatDraftChanged, draft: { text: 'missed while disconnected', origin: { kind: MessageKind.User } } }); + + const result = await revived.call('reconnect', { + channel: ROOT_STATE_URI, + clientId: `reconnect-${config.provider}`, + lastSeenServerSeq: seenThrough, + subscriptions: [chatUri], + }); + + // A client that reconnects inside the replay window must be able to + // catch up by applying actions rather than discarding local state for + // a fresh snapshot, so the cutoff has to be exclusive and exact. + assert.deepStrictEqual({ + type: result.type, + replayedAlreadySeen: result.type === ReconnectResultType.Replay + && result.actions.some(envelope => envelope.serverSeq <= seenThrough), + replayedTheGap: result.type === ReconnectResultType.Replay + && result.actions.some(envelope => envelope.serverSeq > seenThrough), + }, { + type: ReconnectResultType.Replay, + replayedAlreadySeen: false, + replayedTheGap: true, + }); + } finally { + revived.close(); + } + }); + + conformanceTest(context, 'reconnect reports a subscription it cannot resume as missing', async function () { + const { sessionUri } = await createSession('reconnect-missing'); + const chatUri = buildDefaultChatUri(sessionUri); + // A channel that never existed stands in for one disposed while the client + // was away: either way the server cannot resume it, and the client has to + // be told rather than left waiting on a dead channel. + const goneUri = URI.from({ scheme: 'agenthost-terminal', authority: 'e2e', path: '/never-existed' }).toString(); + + const { carried: seenThrough, revived } = await afterConnectionDrop(`reconnect-missing-${config.provider}`, async first => { + const subscribed = await first.call('subscribe', { channel: chatUri }); + return subscribed.snapshot!.fromSeq; + }); + + try { + const result = await revived.call('reconnect', { + channel: ROOT_STATE_URI, + clientId: `reconnect-missing-${config.provider}`, + lastSeenServerSeq: seenThrough, + subscriptions: [chatUri, goneUri], + }); + + assert.deepStrictEqual({ + type: result.type, + missing: result.type === ReconnectResultType.Replay ? result.missing : undefined, + }, { + type: ReconnectResultType.Replay, + missing: [goneUri], + }); + } finally { + revived.close(); + } + }); + + // The protocol declares working-directory mutation on both the session and + // chat channels, but the host rejects all four: applying one would change + // the synchronized directory set without reconfiguring the agent's actual + // access. Each is answered through the normal reconciliation path so the + // client can roll back its optimistic write-ahead action instead of leaving + // it pending until reconnect. + const unsupportedWorkingDirectoryActions = [ + { notification: 'session/workingDirectorySet', channel: 'session', build: (directory: string): StateAction => ({ type: ActionType.SessionWorkingDirectorySet, directory }) }, + { notification: 'session/workingDirectoryRemoved', channel: 'session', build: (directory: string): StateAction => ({ type: ActionType.SessionWorkingDirectoryRemoved, directory }) }, + { notification: 'chat/workingDirectorySet', channel: 'chat', build: (directory: string): StateAction => ({ type: ActionType.ChatWorkingDirectorySet, directory }) }, + { notification: 'chat/workingDirectoryRemoved', channel: 'chat', build: (directory: string): StateAction => ({ type: ActionType.ChatWorkingDirectoryRemoved, directory }) }, + ] as const; + + for (const unsupported of unsupportedWorkingDirectoryActions) { + conformanceTest(context, `${unsupported.notification} is rejected rather than silently dropped`, async function () { + const { sessionUri, workspace } = await createSession('unsupported-action'); + const channel = unsupported.channel === 'session' ? sessionUri : buildDefaultChatUri(sessionUri); + await context.client.call('subscribe', { channel }); + context.client.clearReceived(); + + const seq = nextClientSeq(); + const directory = URI.file(join(workspace, 'second-root')).toString(); + context.client.dispatch({ channel, clientSeq: seq, action: unsupported.build(directory) }); + + const rejected = await context.client.waitForNotification(n => + isActionNotification(n, unsupported.notification) && getActionEnvelope(n).channel === channel, + 30_000, + ); + const envelope = getActionEnvelope(rejected) as { rejectionReason?: string; origin?: { clientSeq?: number } }; + const state = (await context.client.call('subscribe', { channel })).snapshot!.state as { workingDirectories?: readonly string[] }; + + assert.deepStrictEqual({ + hasRejectionReason: typeof envelope.rejectionReason === 'string' && envelope.rejectionReason.length > 0, + echoedClientSeq: envelope.origin?.clientSeq, + // The reducer is deliberately not run, so state never moves. + directoryApplied: (state.workingDirectories ?? []).includes(directory), + }, { + hasRejectionReason: true, + echoedClientSeq: seq, + directoryApplied: false, + }); + }); + } +} diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/stateOperationsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/stateOperationsSuite.ts index eb674db23ef..635acee49f8 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/stateOperationsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/stateOperationsSuite.ts @@ -27,6 +27,7 @@ import { } from '../../../../common/state/sessionState.js'; import { createRealSession } from '../harness/agentHostE2ETestHarness.js'; import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; +import type { AhpNotification } from '../../../../common/state/sessionProtocol.js'; import { conformanceTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; export function defineStateOperationsTests(context: IAgentHostE2ETestContext): void { @@ -55,6 +56,13 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v return result.snapshot!.state as TerminalState; } + /** The terminal's visible text, flattening command parts and raw output alike. */ + function terminalText(state: TerminalState): string { + return state.content + .map(part => part.type === 'command' ? part.output : part.value) + .join(''); + } + async function dispatchAndWait(channel: string, clientSeq: number, action: StateAction): Promise { context.client.clearReceived(); context.client.dispatch({ channel, clientSeq, action }); @@ -239,6 +247,41 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v assert.strictEqual((await chatState(chatUri)).draft, undefined); }); + conformanceTest(context, 'a message queued on an idle chat is promoted straight into a turn', async function () { + const { chatUri } = await createSession('queue-promote'); + context.client.clearReceived(); + + // Queueing exists to hold work while a turn is running. With nothing + // running there is nothing to wait for, so the host must start the + // message rather than park it — otherwise a queued message on an idle + // chat would never run at all. `/rename` keeps the promoted turn inside + // the host's local-command dispatcher, with no shell and no model. + await dispatchAndWait(chatUri, 1, { + type: ActionType.ChatPendingMessageSet, + kind: PendingMessageKind.Queued, + id: 'queued-1', + message: userMessage('/rename Queue Promoted'), + }); + + const started = await context.client.waitForNotification(n => + isActionNotification(n, 'chat/turnStarted') + && getActionEnvelope(n).channel === chatUri + && (getActionEnvelope(n).action as { queuedMessageId?: string }).queuedMessageId === 'queued-1', + 30_000, + ); + const turnId = (getActionEnvelope(started).action as { turnId: string }).turnId; + await context.client.waitForNotification(n => + isActionNotification(n, 'chat/turnComplete') + && getActionEnvelope(n).channel === chatUri + && (getActionEnvelope(n).action as { turnId: string }).turnId === turnId, + 60_000, + ); + + // Promotion has to be atomic with removal: a message left in the queue + // after being started would run a second time on the next idle event. + assert.deepStrictEqual((await chatState(chatUri)).queuedMessages ?? [], []); + }); + conformanceTest(context, 'removing a missing queued message leaves chat state unchanged', async function () { const { chatUri } = await createSession('queue-remove-missing'); @@ -357,13 +400,110 @@ export function defineStateOperationsTests(context: IAgentHostE2ETestContext): v streamedOutput += action.data; return /(?:^|\D)42(?:\D|$)/.test(streamedOutput); }, 30_000); - const output = (await terminalState(terminalUri)).content - .map(part => part.type === 'command' ? part.output : part.value) - .join(''); + const output = terminalText(await terminalState(terminalUri)); assert.match(output, /(?:^|\D)42(?:\D|$)/); }); }); + conformanceTest(context, 'clearing a terminal drops the scrollback the client already saw', async function () { + await withTerminal('terminal-clear', async ({ terminalUri }) => { + context.client.dispatch({ + channel: terminalUri, + clientSeq: 1, + action: { type: ActionType.TerminalInput, data: 'node -p "\'CLEAR_MARKER\'"\r' }, + }); + await context.client.waitForNotification(n => + isActionNotification(n, 'terminal/data') + && getActionEnvelope(n).channel === terminalUri + && (getActionEnvelope(n).action as { data: string }).data.includes('CLEAR_MARKER'), + 30_000, + ); + const before = terminalText(await terminalState(terminalUri)); + + await dispatchAndWait(terminalUri, 2, { type: ActionType.TerminalCleared }); + + // The scrollback lives in host state, not just in the client's view, + // so clearing must drop it for every subscriber including one that + // subscribes later. + // + // Asserting the buffer is *empty* would be wrong: the shell is live + // and redraws its prompt as soon as the screen is cleared, so bytes + // legitimately arrive after the clear reduces. What has to be gone + // is the output the client had already accumulated. + const after = terminalText(await terminalState(terminalUri)); + assert.deepStrictEqual({ + markerBeforeClear: before.includes('CLEAR_MARKER'), + markerAfterClear: after.includes('CLEAR_MARKER'), + }, { + markerBeforeClear: true, + markerAfterClear: false, + }); + }); + }); + + conformanceTest(context, 'a terminal whose shell exits reports its exit code', async function () { + await withTerminal('terminal-exit', async ({ terminalUri }) => { + context.client.clearReceived(); + context.client.dispatch({ + channel: terminalUri, + clientSeq: 1, + action: { type: ActionType.TerminalInput, data: 'exit\r' }, + }); + + const exited = await context.client.waitForNotification(n => + isActionNotification(n, 'terminal/exited') && getActionEnvelope(n).channel === terminalUri, + 30_000, + ); + + // The exit code itself is the shell's, not the host's, so only its + // presence and its arrival in state are contractual. + const action = getActionEnvelope(exited).action as { exitCode?: number }; + assert.deepStrictEqual({ + reportedExitCode: typeof action.exitCode, + stateMatchesNotification: (await terminalState(terminalUri)).exitCode === action.exitCode, + }, { + reportedExitCode: 'number', + stateMatchesNotification: true, + }); + }); + }); + + conformanceTest(context, 'root state tracks terminals as they appear and disappear', async function () { + // The first terminal also establishes the connection; root can only be + // subscribed once the client has handshaked. + const { clientId, workspace } = await createTerminal('terminal-root'); + await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + context.client.clearReceived(); + + function terminalsIn(n: AhpNotification): readonly { resource: string }[] { + return (getActionEnvelope(n).action as { terminals?: readonly { resource: string }[] }).terminals ?? []; + } + + // Root is how a client discovers terminals it did not create itself, so + // it has to be told on both edges, not only on creation. + const observedUri = URI.from({ scheme: 'agenthost-terminal', authority: 'e2e', path: `/${generateUuid()}` }).toString(); + await context.client.call('createTerminal', { + channel: observedUri, + claim: { kind: TerminalClaimKind.Client, clientId }, + name: 'E2E terminal-root-observed', + cwd: URI.file(workspace).toString(), + cols: 90, + rows: 30, + }); + await context.client.waitForNotification(n => + isActionNotification(n, 'root/terminalsChanged') + && terminalsIn(n).some(terminal => terminal.resource === observedUri), + 30_000, + ); + + await disposeTerminal(observedUri); + await context.client.waitForNotification(n => + isActionNotification(n, 'root/terminalsChanged') + && !terminalsIn(n).some(terminal => terminal.resource === observedUri), + 30_000, + ); + }); + conformanceTest(context, 'disposeTerminal removes the terminal from root state', async function () { const { terminalUri } = await createTerminal('terminal-dispose'); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/turnLifecycleSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/turnLifecycleSuite.ts index d09fa8e6336..35a858deb63 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/turnLifecycleSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/turnLifecycleSuite.ts @@ -78,6 +78,13 @@ export function defineTurnLifecycleTests(context: IAgentHostE2ETestContext): voi const toolStarts = context.client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallStart')); assert.ok(toolStarts.length > 0, 'expected at least one shell tool call'); + if (config.provider === 'copilotcli') { + const toolDeltas = context.client.receivedNotifications(n => isActionNotification(n, 'chat/toolCallDelta')); + assert.ok(toolDeltas.length > 0, 'expected Copilot tool progress before the tool was ready'); + const delta = getActionEnvelope(toolDeltas[0]).action as { content?: string; invocationMessage?: unknown }; + assert.ok(delta.invocationMessage, 'expected Copilot to stream an invocation message'); + assert.strictEqual(delta.content, '', 'Copilot should keep partial tool input in the agent host'); + } // Drain the post-tool continuation to `turnComplete` so the turn ends // within this test's window. This is required for the shared replay diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index f9fd1fe17d6..c94b753765a 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { AgentSession } from '../../common/agentService.js'; @@ -124,6 +125,34 @@ suite('mapSessionEvents — history replay', () => { ]); }); + test('resolves relative patch links in restored tool messages', async () => { + const patch = [ + '*** Begin Patch', + '*** Update File: src/file.ts', + '@@', + '-old', + '+new', + '*** End Patch', + ].join('\n'); + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'm1', content: 'edit the file' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'apply_patch' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'apply_patch', arguments: patch } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-1', success: true } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events), URI.file('/workspace')); + const part = turns[0].responseParts.find(part => part.kind === ResponsePartKind.ToolCall) as ToolCallResponsePart | undefined; + assert.ok(part); + assert.deepStrictEqual({ + invocationMessage: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.invocationMessage : undefined, + pastTenseMessage: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.pastTenseMessage : undefined, + }, { + invocationMessage: { markdown: 'Editing [file.ts](file:///workspace/src/file.ts)' }, + pastTenseMessage: { markdown: 'Edited [file.ts](file:///workspace/src/file.ts)' }, + }); + }); + test('restores MCP app data for completed tool calls', async () => { const events: ISessionEvent[] = [ { type: 'user.message', data: { interactionId: 'm1', content: 'call an MCP app tool' } }, diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotByokResponses.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotByokResponses.integrationTest.ts new file mode 100644 index 00000000000..dbc000188cb --- /dev/null +++ b/src/vs/platform/agentHost/test/node/providerIntegration/copilotByokResponses.integrationTest.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { CopilotClient } from '@github/copilot-sdk'; +import { Emitter } from '../../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../log/common/log.js'; +import type { IByokLmChatRequest, IByokLmModelInfo } from '../../../common/agentHostByokLm.js'; +import { ByokLmBridgeRegistry } from '../../../node/byokLmBridgeRegistry.js'; +import { ByokLmProxyService } from '../../../node/copilot/byokLmProxyService.js'; + +const REAL_SDK_ENABLED = process.env['AGENT_HOST_REAL_SDK'] === '1'; + +(REAL_SDK_ENABLED ? suite : suite.skip)('Agent Host Provider Integration - Copilot BYOK Responses', function () { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('real SDK consumes structured reasoning and text from the proxy', async function () { + this.timeout(120_000); + + const sessionId = 'byok-responses-integration'; + const baseDirectory = await mkdtemp(`${tmpdir()}/byok-responses-sdk-`); + const models = store.add(new Emitter()); + const registry = new ByokLmBridgeRegistry(); + const captured: IByokLmChatRequest[] = []; + const registration = registry.register('client', { + chat: async request => { + captured.push(request); + if (captured.length > 1) { + return { + responseId: 'resp_provider_2', + output: [{ type: 'message', content: [{ type: 'text', text: 'second' }] }], + }; + } + return { + responseId: 'resp_provider', + output: [ + { type: 'reasoning', id: 'rs_provider', summary: ['considered options'], encryptedContent: 'opaque' }, + { type: 'message', content: [{ type: 'text', text: 'hello' }] }, + ], + usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 1 }, + }; + }, + onDidChangeModels: models.event, + }); + models.fire([{ vendor: 'acme', id: 'test-model' }]); + + const proxy = new ByokLmProxyService(new NullLogService(), registry); + const handle = await proxy.start(); + const client = new CopilotClient({ + mode: 'empty', + baseDirectory, + useLoggedInUser: false, + logLevel: 'error', + }); + let session: Awaited> | undefined; + let clientStarted = false; + + try { + await client.start(); + clientStarted = true; + session = await client.createSession({ + sessionId, + model: 'test-model', + availableTools: [], + provider: { + type: 'openai', + wireApi: 'responses', + baseUrl: handle.providerBaseUrl('acme'), + bearerToken: `${handle.nonce}.${sessionId}`, + }, + }); + const reasoning: string[] = []; + session.on('assistant.reasoning', event => reasoning.push(event.data.content)); + + const result = await session.sendAndWait({ prompt: 'Reply exactly hello.' }, 30_000); + const secondResult = await session.sendAndWait({ prompt: 'Reply exactly second.' }, 30_000); + const replayedReasoning = captured[1]?.input.find(item => item.type === 'reasoning'); + + assert.deepStrictEqual({ + result: result?.type === 'assistant.message' ? result.data.content : undefined, + secondResult: secondResult?.type === 'assistant.message' ? secondResult.data.content : undefined, + reasoning, + firstRequest: { + vendor: captured[0]?.vendor, + modelId: captured[0]?.modelId, + inputTypes: captured[0]?.input.map(item => item.type), + reasoningEffort: captured[0]?.reasoningEffort, + }, + replayedReasoning, + }, { + result: 'hello', + secondResult: 'second', + reasoning: ['considered options'], + firstRequest: { + vendor: 'acme', + modelId: 'test-model', + inputTypes: ['message'], + reasoningEffort: 'medium', + }, + replayedReasoning: { + type: 'reasoning', + id: 'rs_provider', + summary: ['considered options'], + encryptedContent: 'opaque', + }, + }); + + } finally { + try { + await session?.disconnect(); + } finally { + try { + if (clientStarted) { + await client.stop(); + } + } finally { + handle.dispose(); + registration.dispose(); + proxy.dispose(); + await rm(baseDirectory, { recursive: true, force: true }); + } + } + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index 6dbb241ba30..b0b52247dd4 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -8,7 +8,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { changesetReducer, chatReducer, sessionReducer } from '../../common/state/protocol/reducers.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { ChangesetStatus, ChangesetOperationStatus, CustomizationLoadStatus, MessageKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ResponsePartKind, ToolCallStatus, TurnState, type AgentCustomization, type ChangesetState, type Customization, type PluginCustomization, type ChatState, type SessionState } from '../../common/state/sessionState.js'; -import { CustomizationType } from '../../common/state/protocol/state.js'; +import { CustomizationType, ToolCallContributorKind, type ToolCallContributor } from '../../common/state/protocol/state.js'; function makeSession(): SessionState { return { @@ -334,6 +334,129 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ ]); }); + test('ChatToolCallDelta can update the invocation message without exposing partial input', () => { + let state = chatReducer(makeChat(), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + state = chatReducer(state, { + type: ActionType.ChatToolCallStart, + turnId: 'turn-1', + toolCallId: 'tc-1', + toolName: 'edit', + displayName: 'Edit File', + }); + state = chatReducer(state, { + type: ActionType.ChatToolCallDelta, + turnId: 'turn-1', + toolCallId: 'tc-1', + content: '', + invocationMessage: 'Replacing 2 lines with 3 lines', + }); + + const part = state.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall); + assert.ok(part?.kind === ResponsePartKind.ToolCall); + assert.deepStrictEqual({ + invocationMessage: part.toolCall.status === ToolCallStatus.Streaming ? part.toolCall.invocationMessage : undefined, + partialInput: part.toolCall.status === ToolCallStatus.Streaming ? part.toolCall.partialInput : undefined, + }, { + invocationMessage: 'Replacing 2 lines with 3 lines', + partialInput: '', + }); + }); + + test('ChatToolCallReady replaces provisional contributor and intention', () => { + let state = chatReducer(makeChat(), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + state = chatReducer(state, { + type: ActionType.ChatToolCallStart, + turnId: 'turn-1', + toolCallId: 'tc-1', + toolName: 'mcp_tool', + displayName: 'MCP Tool', + intention: 'Query', + }); + state = chatReducer(state, { + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-1', + contributor: { kind: ToolCallContributorKind.MCP, customizationId: 'mcp-1' }, + intention: 'Query project metadata', + invocationMessage: 'Querying project metadata', + toolInput: '{"query":"metadata"}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const part = state.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall); + assert.ok(part?.kind === ResponsePartKind.ToolCall); + assert.deepStrictEqual({ + status: part.toolCall.status, + contributor: part.toolCall.contributor, + intention: part.toolCall.intention, + }, { + status: ToolCallStatus.Running, + contributor: { kind: ToolCallContributorKind.MCP, customizationId: 'mcp-1' }, + intention: 'Query project metadata', + }); + }); + + test('ChatToolCallReady cannot change client execution ownership', () => { + const readyContributor = (startContributor: ToolCallContributor | undefined, contributor: ToolCallContributor) => { + let state = chatReducer(makeChat(), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + state = chatReducer(state, { + type: ActionType.ChatToolCallStart, + turnId: 'turn-1', + toolCallId: 'tc-1', + toolName: 'tool', + displayName: 'Tool', + contributor: startContributor, + }); + state = chatReducer(state, { + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-1', + contributor, + invocationMessage: 'Running tool', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + const part = state.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall); + assert.ok(part?.kind === ResponsePartKind.ToolCall); + return part.toolCall.contributor; + }; + + assert.deepStrictEqual([ + readyContributor(undefined, { kind: ToolCallContributorKind.Client, clientId: 'client-1' }), + readyContributor( + { kind: ToolCallContributorKind.MCP, customizationId: 'mcp-1' }, + { kind: ToolCallContributorKind.Client, clientId: 'client-1' }, + ), + readyContributor( + { kind: ToolCallContributorKind.Client, clientId: 'client-1' }, + { kind: ToolCallContributorKind.Client, clientId: 'client-2' }, + ), + readyContributor( + { kind: ToolCallContributorKind.Client, clientId: 'client-1' }, + { kind: ToolCallContributorKind.Client, clientId: 'client-1' }, + ), + ], [ + undefined, + { kind: ToolCallContributorKind.MCP, customizationId: 'mcp-1' }, + { kind: ToolCallContributorKind.Client, clientId: 'client-1' }, + { kind: ToolCallContributorKind.Client, clientId: 'client-1' }, + ]); + }); + test('ChatToolCallReady updates an asynchronous judge result on a pending confirmation', () => { const loading = chatReducer(withActiveTurnAndToolCall(makeChat()), { type: ActionType.ChatToolCallReady, diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index c92d9777032..e126d5f6660 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -240,7 +240,74 @@ suite('WorktreeIsolation', () => { }); }); - test('resolveWorkingDirectory names each creation phase, rounding percentages down and skipping repeats', async () => { + test('resolveWorkingDirectory creates from the primary worktree while copying include files from the selected checkout', async () => { + const checkoutRoot = URI.joinPath(repoRoot, 'linked-checkout'); + const gitService = createGitService(); + let addWorktreeRoot: URI | undefined; + gitService.getRepositoryRoot = async () => checkoutRoot; + gitService.getWorktreeRoots = async () => [repoRoot, checkoutRoot]; + gitService.addWorktree = async (repositoryRoot, worktree, branch, startPoint, track) => { + addWorktreeRoot = repositoryRoot; + addWorktreeCalls.push({ worktree, branchName: branch, startPoint, track }); + mkdirSync(worktree.fsPath, { recursive: true }); + }; + const isolation = createIsolation(disposables, { gitService }); + const includeFiles = ['.env']; + + const worktree = await isolation.resolveWorkingDirectory({ + sessionUri, + sessionId, + workingDirectory: checkoutRoot, + config: { + [SessionConfigKey.Isolation]: 'worktree', + [SessionConfigKey.Branch]: 'main', + [SessionConfigKey.WorktreeIncludeFiles]: includeFiles, + }, + }); + const meta = await isolation.readWorktreeMetadata(sessionUri); + const project = isolation.createdWorktreeProject(sessionId); + + assert.deepStrictEqual({ + worktree: worktree?.toString(), + addWorktreeRoot: addWorktreeRoot?.toString(), + includeFileRoot: copyIncludeCalls[0]?.repositoryRoot.toString(), + metaRepositoryRoot: meta?.repositoryRoot?.toString(), + project: project && { uri: project.uri.toString(), displayName: project.displayName }, + }, { + worktree: URI.joinPath(worktreesRoot, getWorktreeName(branchName)).toString(), + addWorktreeRoot: repoRoot.toString(), + includeFileRoot: checkoutRoot.toString(), + metaRepositoryRoot: repoRoot.toString(), + project: { uri: repoRoot.toString(), displayName: basename(repoRoot) }, + }); + }); + + test('resolveWorkingDirectory falls back to the selected checkout when primary worktree resolution fails', async () => { + const checkoutRoot = URI.joinPath(repoRoot, 'linked-checkout'); + const gitService = createGitService(); + gitService.getRepositoryRoot = async () => checkoutRoot; + gitService.getWorktreeRoots = async () => { throw new Error('worktree enumeration failed'); }; + const isolation = createIsolation(disposables, { gitService }); + + const worktree = await isolation.resolveWorkingDirectory({ + sessionUri, + sessionId, + workingDirectory: checkoutRoot, + config: { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' }, + }); + const meta = await isolation.readWorktreeMetadata(sessionUri); + const fallbackWorktreesRoot = getWorktreesRoot(checkoutRoot); + + assert.deepStrictEqual({ + worktree: worktree?.toString(), + metaRepositoryRoot: meta?.repositoryRoot?.toString(), + }, { + worktree: URI.joinPath(fallbackWorktreesRoot, getWorktreeName(branchName)).toString(), + metaRepositoryRoot: checkoutRoot.toString(), + }); + }); + + test('resolveWorkingDirectory names each creation phase, rounding percentages down and debouncing updates', async () => { const gitService = createGitService(); gitService.addWorktree = async (_root, worktree, branch, startPoint, track, onProgress) => { addWorktreeCalls.push({ worktree, branchName: branch, startPoint, track }); @@ -248,6 +315,7 @@ suite('WorktreeIsolation', () => { onProgress?.({ filesDone: 7, filesTotal: 800 }); onProgress?.({ filesDone: 96, filesTotal: 800 }); onProgress?.({ filesDone: 100, filesTotal: 800 }); + await timeout(50); onProgress?.({ filesDone: 800, filesTotal: 800 }); }; gitService.copyWorktreeIncludeFiles = async (_root, _worktree, _globs, onProgress) => { @@ -274,11 +342,9 @@ suite('WorktreeIsolation', () => { 'Creating isolated worktree', 'Creating isolated worktree (naming branch)', 'Creating isolated worktree (checking out files)', - 'Creating isolated worktree (checking out files, 0%)', 'Creating isolated worktree (checking out files, 12%)', 'Creating isolated worktree (checking out files, 100%)', 'Creating isolated worktree (copying additional files)', - 'Creating isolated worktree (copying additional files, 25%)', 'Creating isolated worktree (copying additional files, 100%)', ]); }); @@ -347,9 +413,13 @@ suite('WorktreeIsolation', () => { test('resolveWorkingDirectory serializes concurrent creation in the same repository', async () => { const gitService = createGitService(); + const checkoutRootA = URI.joinPath(repoRoot, 'linked-checkout-a'); + const checkoutRootB = URI.joinPath(repoRoot, 'linked-checkout-b'); const existingBranches = new Set(); let activeAddWorktrees = 0; let maxActiveAddWorktrees = 0; + gitService.getRepositoryRoot = async workingDirectory => workingDirectory; + gitService.getWorktreeRoots = async () => [repoRoot, checkoutRootA, checkoutRootB]; gitService.branchExists = async (_repositoryRoot, candidate) => existingBranches.has(candidate); gitService.addWorktree = async (_repositoryRoot, worktree, candidate, startPoint, track) => { activeAddWorktrees++; @@ -367,8 +437,8 @@ suite('WorktreeIsolation', () => { const config = { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' }; const worktrees = await Promise.all([ - isolation.resolveWorkingDirectory({ sessionUri: URI.parse('agent-session://test/12345678-aaaa-bbbb-cccc-123456789abc'), sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', workingDirectory: repoRoot, config, prompt: 'Add feature' }), - isolation.resolveWorkingDirectory({ sessionUri: URI.parse('agent-session://test/87654321-aaaa-bbbb-cccc-123456789abc'), sessionId: '87654321-aaaa-bbbb-cccc-123456789abc', workingDirectory: repoRoot, config, prompt: 'Add feature' }), + isolation.resolveWorkingDirectory({ sessionUri: URI.parse('agent-session://test/12345678-aaaa-bbbb-cccc-123456789abc'), sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', workingDirectory: checkoutRootA, config, prompt: 'Add feature' }), + isolation.resolveWorkingDirectory({ sessionUri: URI.parse('agent-session://test/87654321-aaaa-bbbb-cccc-123456789abc'), sessionId: '87654321-aaaa-bbbb-cccc-123456789abc', workingDirectory: checkoutRootB, config, prompt: 'Add feature' }), ]); assert.deepStrictEqual({ @@ -574,6 +644,41 @@ suite('WorktreeIsolation', () => { }); }); + test('resolveWorktreeProject normalizes persisted linked-checkout metadata', async () => { + const checkoutRoot = URI.joinPath(repoRoot, 'linked-checkout'); + const existingWorktree = URI.joinPath(repoRoot, 'existing-worktree'); + mkdirSync(existingWorktree.fsPath, { recursive: true }); + await Promise.all([ + db.setMetadata('copilot.worktree.branchName', 'feature/x'), + db.setMetadata('copilot.worktree.path', existingWorktree.toString()), + db.setMetadata('copilot.worktree.repositoryRoot', checkoutRoot.toString()), + ]); + const gitService = createGitService(); + let resolvedFrom: URI | undefined; + let resolutionCount = 0; + gitService.getWorktreeRoots = async workingDirectory => { + resolvedFrom = workingDirectory; + resolutionCount++; + return [repoRoot, checkoutRoot, existingWorktree]; + }; + const isolation = createIsolation(disposables, { gitService }); + + const project = await isolation.resolveWorktreeProject(sessionUri); + await isolation.resolveWorktreeProject(sessionUri); + + assert.deepStrictEqual({ + resolutionCount, + resolvedFrom: resolvedFrom?.toString(), + project: project && { uri: project.uri.toString(), displayName: project.displayName }, + persistedRepositoryRoot: await db.getMetadata('copilot.worktree.repositoryRoot'), + }, { + resolutionCount: 1, + resolvedFrom: existingWorktree.toString(), + project: { uri: repoRoot.toString(), displayName: basename(repoRoot) }, + persistedRepositoryRoot: repoRoot.toString(), + }); + }); + test('applyRestoreAnnouncement prepends a markdown part when worktree metadata exists', async () => { const isolation = createIsolation(disposables); const turn: Turn = { diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index c9488802dbe..88f8969d37c 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -42,6 +42,7 @@ export enum BrowserViewCommandId { // Chat actions AddElementToChat = `${commandPrefix}.addElementToChat`, + AddElementCommentToChat = `${commandPrefix}.addElementCommentToChat`, AddConsoleLogsToChat = `${commandPrefix}.addConsoleLogsToChat`, AddScreenshotToChat = `${commandPrefix}.addScreenshotToChat`, AddAreaScreenshotToChat = `${commandPrefix}.addAreaScreenshotToChat`, @@ -68,12 +69,26 @@ export interface IElementAncestor { readonly classNames?: string[]; } +export enum BrowserElementSelectionMode { + Select = 'select', + Comment = 'comment' +} + export interface IBrowserElementSelectionOptions { readonly highlightFocusedElement?: boolean; + readonly continuous?: boolean; + readonly mode?: BrowserElementSelectionMode; +} + +export interface IBrowserElementSelectionState { + readonly active: boolean; + readonly options: IBrowserElementSelectionOptions; } export interface IElementData { readonly url?: string; + readonly elementId?: string; + readonly comment?: string; readonly outerHTML: string; readonly computedStyle: string; readonly bounds: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }; @@ -84,6 +99,16 @@ export interface IElementData { readonly innerText?: string; } +export interface IBrowserElementComment { + readonly elementId: string; + readonly body: string; +} + +export interface IBrowserElementCommentsUpdate { + readonly comments?: readonly IBrowserElementComment[]; + readonly pendingCommentIdsToDiscard?: readonly string[]; +} + export interface IBrowserViewRect { readonly x: number; readonly y: number; @@ -91,11 +116,31 @@ export interface IBrowserViewRect { readonly height: number; } +export interface IBrowserViewPreloadLocalizedStrings { + readonly addComment: string; + readonly addCommentPlaceholder: string; + readonly commentOnSelectedElement: string; + readonly elementComment: string; + readonly elementCommentWithBody: string; + readonly emptyElementComment: string; + readonly removeComment: string; + readonly removeElementComment: string; +} + export interface IBrowserViewTheme { readonly focusBorder?: string; readonly buttonBackground?: string; readonly buttonForeground?: string; + readonly widgetBackground?: string; + readonly widgetForeground?: string; + readonly widgetBorder?: string; + readonly widgetShadow?: string; + readonly contrastBorder?: string; + readonly descriptionForeground?: string; + readonly inputPlaceholderForeground?: string; + readonly toolbarHoverBackground?: string; readonly font?: string; + readonly reducedMotion?: boolean; } /** @@ -250,7 +295,7 @@ export interface IBrowserViewState { storageKeys: IBrowserViewStorageKeys; permissions: ISerializedBrowserPermissionsSnapshot; browserZoomIndex: number; - isElementSelectionActive: boolean; + elementSelectionState: IBrowserElementSelectionState; isRemoteSession: boolean; isAreaSelectionActive: boolean; device: IBrowserDeviceProfile | undefined; @@ -406,7 +451,8 @@ export interface IBrowserViewService { onDynamicDidFindInPage(id: string): Event; onDynamicDidClose(id: string): Event; onDynamicDidSelectElement(id: string): Event; - onDynamicDidChangeElementSelectionActive(id: string): Event; + onDynamicDidRemoveElementComment(id: string): Event; + onDynamicDidChangeElementSelectionState(id: string): Event; onDynamicDidPickArea(id: string): Event; onDynamicDidChangeAreaSelectionActive(id: string): Event; onDynamicDidChangeDeviceEmulation(id: string): Event; @@ -628,14 +674,22 @@ export interface IBrowserViewService { /** * Toggle element selection mode in a browser view. * Element selections are delivered via {@link onDynamicDidSelectElement}. - * State changes are delivered via {@link onDynamicDidChangeElementSelectionActive}. + * State changes are delivered via {@link onDynamicDidChangeElementSelectionState}. * * @param id The browser view identifier * @param enabled Whether to enable or disable. Omit to toggle. - * @param options Options used when enabling element selection. + * @param options Options to update while enabling or continuing element selection. */ toggleElementSelection(id: string, enabled?: boolean, options?: IBrowserElementSelectionOptions): Promise; + /** + * Synchronize the element comments displayed in a browser view. + * + * @param id The browser view identifier + * @param update The comment state to synchronize + */ + setElementComments(id: string, update: IBrowserElementCommentsUpdate): Promise; + /** * Toggle drag-to-select area picking on the top frame of a browser view. * The pick result (rectangle, or `undefined` on cancellation) is delivered via diff --git a/src/vs/platform/browserView/electron-browser/preload-browserView.ts b/src/vs/platform/browserView/electron-browser/preload-browserView.ts index b37a28d1328..47c565b7a3e 100644 --- a/src/vs/platform/browserView/electron-browser/preload-browserView.ts +++ b/src/vs/platform/browserView/electron-browser/preload-browserView.ts @@ -7,7 +7,19 @@ /* eslint-disable no-restricted-syntax */ // Only `import type` is allowed in preload scripts — Electron preloads cannot resolve module imports at runtime. -import type { IBrowserElementSelectionOptions, IBrowserViewTheme, IBrowserViewRect } from '../common/browserView.js'; +import type { BrowserElementSelectionMode, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewPreloadLocalizedStrings, IBrowserViewTheme, IBrowserViewRect } from '../common/browserView.js'; + +const commentElementSelectionMode = 'comment' as BrowserElementSelectionMode; +let localizedStrings: IBrowserViewPreloadLocalizedStrings = { + addComment: 'Add Comment', + addCommentPlaceholder: 'Add a comment', + commentOnSelectedElement: 'Comment on selected element', + elementComment: 'Element comment {0}', + elementCommentWithBody: 'Element comment {0}: {1}', + emptyElementComment: 'Empty element comment {0}', + removeComment: 'Remove Comment', + removeElementComment: 'Remove element comment', +}; /** * Preload script for pages loaded in Integrated Browser @@ -124,7 +136,12 @@ function init() { }); const elementPicker = new ElementPicker( - el => ipcRenderer.send('vscode:browserView:elementPicked', track(el)), + (el, comment) => { + const elementId = track(el); + ipcRenderer.send('vscode:browserView:elementPicked', { elementId, comment }); + return elementId; + }, + elementId => ipcRenderer.send('vscode:browserView:elementCommentRemoved', elementId), () => ipcRenderer.send('vscode:browserView:elementPickStopped') ); @@ -145,22 +162,24 @@ function init() { return id; } - let contextMenuTargetRef: WeakRef | undefined; + let contextMenuTarget: { ref: WeakRef; anchor: { x: number; y: number } } | undefined; window.addEventListener('contextmenu', (event) => { if (!event.isTrusted) { return; } - - const target = event.target; - if (target instanceof Element) { + const target = elementPicker.resolveContextMenuTarget(event); + if (target) { const els = [target]; const selection = window.getSelection(); if (selection && !selection.isCollapsed) { els.push(selection.anchorNode as Element, selection.focusNode as Element); } - contextMenuTargetRef = new WeakRef(findCommonVisibleAncestor(els) ?? target); + contextMenuTarget = { + ref: new WeakRef(findCommonVisibleAncestor(els) ?? target), + anchor: { x: event.clientX, y: event.clientY } + }; } else { - contextMenuTargetRef = undefined; + contextMenuTarget = undefined; } }, { capture: true }); @@ -169,6 +188,10 @@ function init() { elementPicker.setTheme(theme); areaPicker.setTheme(theme); }); + ipcRenderer.on('vscode:browserView:setLocalizedStrings', (_event: unknown, strings: IBrowserViewPreloadLocalizedStrings) => { + localizedStrings = strings; + elementPicker.updateLocalizedStrings(); + }); ipcRenderer.on('vscode:browserView:startElementPicker', (_event: unknown, options: IBrowserElementSelectionOptions) => { elementPicker.start(options); }); @@ -187,16 +210,25 @@ function init() { elementPicker.highlight(element); } }); + ipcRenderer.on('vscode:browserView:showElementComment', (_event: unknown, { elementId }: { elementId: string }) => { + const element = getElement(elementId); + if (element) { + elementPicker.comment(element, elementId === 'context-menu-target' ? contextMenuTarget?.anchor : undefined); + } + }); ipcRenderer.on('vscode:browserView:hideHighlight', (_event: unknown) => { elementPicker.hideHighlight(); }); + ipcRenderer.on('vscode:browserView:setElementComments', (_event: unknown, update: IBrowserElementCommentsUpdate) => { + elementPicker.updateComments(update); + }); const getElement = (id: string): Element | null => { switch (id) { case 'active': return document.activeElement; case 'context-menu-target': - return contextMenuTargetRef?.deref() ?? null; + return contextMenuTarget?.ref.deref() ?? null; default: return trackedElementsById.get(id)?.deref() ?? null; } @@ -316,25 +348,51 @@ class ElementPicker { private _selectionActive = false; private _continuous = false; + private _commentMode = false; // DOM — created once in the constructor, reused across start/stop cycles. private readonly _shadowHost: HTMLDivElement; + private readonly _commentBackdrop: SVGSVGElement; + private readonly _commentBackdropCutout: SVGRectElement; + private readonly _highlightShape: SVGRectElement; private readonly _highlight: HTMLDivElement; + private readonly _commentPreviewRemoveButton: HTMLButtonElement; + private readonly _overlay: HTMLDivElement; private readonly _label: HTMLDivElement; private readonly _labelSelector: HTMLSpanElement; private readonly _labelClasses: HTMLSpanElement; private readonly _labelDims: HTMLSpanElement; + private readonly _commentPreview: HTMLDivElement; + private readonly _commentPreviewBody: HTMLSpanElement; private readonly _dragbox: HTMLDivElement; + private readonly _commentLayer: HTMLDivElement; + private readonly _commentComposer: HTMLDivElement; + private readonly _commentInput: HTMLTextAreaElement; + private readonly _commentSendButton: HTMLButtonElement; + private readonly _comments = new Map(); + private readonly _pendingComments = new Map(); // Interaction state (reset on stop) private _dragStart: { x: number; y: number } | undefined; private _dragStartTarget: Element | undefined; private _highlightTarget: Element | undefined; + private _externalHighlightTarget: Element | undefined; private _focusedTarget: Element | undefined; private _cursorStylesheet: HTMLStyleElement | undefined; + private _dismissedCommentOnPointerDown = false; + private _commentTarget: Element | undefined; + private _commentAnchor: { x: number; y: number } | undefined; + private _commentBackdropTarget: Element | undefined; + private _commentBackdropRequest = 0; + private _commentPreviewElementId: string | undefined; + private _commentPreviewHideTimeout: number | undefined; + private _commentPreviewAnimations: Animation[] = []; + private _commentPreviewCollapsing = false; + private _reducedMotion = false; constructor( - private readonly _onPicked: (element: Element) => void, + private readonly _onPicked: (element: Element, comment?: string) => string, + private readonly _onCommentRemoved: (elementId: string) => void, private readonly _onStopped: () => void ) { // Build the shadow DOM tree once. The host is appended/removed from the @@ -346,15 +404,70 @@ class ElementPicker { root.appendChild(ElementPicker._buildStyle()); this._shadowHost = shadowHost; + const svgNamespace = 'http://www.w3.org/2000/svg'; + const commentBackdrop = document.createElementNS(svgNamespace, 'svg'); + commentBackdrop.classList.add('comment-backdrop'); + const backdropMaskId = `vscode-comment-cutout-${Math.random().toString(36).slice(2)}`; + const backdropDefinitions = document.createElementNS(svgNamespace, 'defs'); + const backdropMask = document.createElementNS(svgNamespace, 'mask'); + backdropMask.id = backdropMaskId; + backdropMask.setAttribute('maskUnits', 'userSpaceOnUse'); + backdropMask.setAttribute('x', '0'); + backdropMask.setAttribute('y', '0'); + backdropMask.setAttribute('width', '100%'); + backdropMask.setAttribute('height', '100%'); + const backdropMaskFill = document.createElementNS(svgNamespace, 'rect'); + backdropMaskFill.setAttribute('width', '100%'); + backdropMaskFill.setAttribute('height', '100%'); + backdropMaskFill.setAttribute('fill', 'white'); + const backdropCutout = document.createElementNS(svgNamespace, 'rect'); + backdropCutout.setAttribute('fill', 'black'); + backdropMask.append(backdropMaskFill, backdropCutout); + backdropDefinitions.appendChild(backdropMask); + const backdropFill = document.createElementNS(svgNamespace, 'rect'); + backdropFill.classList.add('comment-backdrop-fill'); + backdropFill.setAttribute('width', '100%'); + backdropFill.setAttribute('height', '100%'); + backdropFill.setAttribute('mask', `url(#${backdropMaskId})`); + const highlightShape = document.createElementNS(svgNamespace, 'rect'); + highlightShape.classList.add('highlight-shape'); + highlightShape.style.display = 'none'; + commentBackdrop.append(backdropDefinitions, backdropFill, highlightShape); + root.appendChild(commentBackdrop); + this._commentBackdrop = commentBackdrop; + this._commentBackdropCutout = backdropCutout; + this._highlightShape = highlightShape; + const highlight = document.createElement('div'); highlight.className = 'highlight'; highlight.style.display = 'none'; root.appendChild(highlight); this._highlight = highlight; + const commentPreviewRemoveButton = document.createElement('button'); + commentPreviewRemoveButton.className = 'comment-preview-remove'; + commentPreviewRemoveButton.type = 'button'; + const commentPreviewRemoveIcon = document.createElementNS(svgNamespace, 'svg'); + commentPreviewRemoveIcon.setAttribute('viewBox', '0 0 16 16'); + commentPreviewRemoveIcon.setAttribute('fill', 'currentColor'); + commentPreviewRemoveIcon.setAttribute('aria-hidden', 'true'); + const commentPreviewRemoveIconPath = document.createElementNS(svgNamespace, 'path'); + commentPreviewRemoveIconPath.setAttribute('d', 'M3.854 3.146a.5.5 0 0 0-.708.708L7.293 8l-4.147 4.146a.5.5 0 0 0 .708.708L8 8.707l4.146 4.147a.5.5 0 0 0 .708-.708L8.707 8l4.147-4.146a.5.5 0 0 0-.708-.708L8 7.293 3.854 3.146Z'); + commentPreviewRemoveIcon.appendChild(commentPreviewRemoveIconPath); + commentPreviewRemoveButton.appendChild(commentPreviewRemoveIcon); + commentPreviewRemoveButton.title = localizedStrings.removeComment; + commentPreviewRemoveButton.setAttribute('aria-label', localizedStrings.removeElementComment); + commentPreviewRemoveButton.addEventListener('click', () => { + if (this._commentPreviewElementId) { + this._removeComment(this._commentPreviewElementId); + } + }); + this._commentPreviewRemoveButton = commentPreviewRemoveButton; + const overlay = document.createElement('div'); overlay.className = 'overlay'; root.appendChild(overlay); + this._overlay = overlay; const label = document.createElement('div'); label.className = 'label'; @@ -381,23 +494,104 @@ class ElementPicker { label.appendChild(labelDims); this._labelDims = labelDims; + const commentPreview = document.createElement('div'); + commentPreview.className = 'comment-surface comment-preview'; + commentPreview.style.display = 'none'; + commentPreview.setAttribute('role', 'note'); + const commentPreviewBody = document.createElement('span'); + commentPreviewBody.className = 'comment-preview-body'; + commentPreview.appendChild(commentPreviewBody); + commentPreview.appendChild(commentPreviewRemoveButton); + root.appendChild(commentPreview); + this._commentPreview = commentPreview; + this._commentPreviewBody = commentPreviewBody; + + for (const element of [highlight, label, commentPreview]) { + element.addEventListener('mouseenter', () => this._cancelCommentPreviewHide()); + element.addEventListener('mouseleave', () => this._scheduleCommentPreviewHide()); + element.addEventListener('focusin', () => this._cancelCommentPreviewHide()); + element.addEventListener('focusout', () => this._scheduleCommentPreviewHide()); + } + const dragbox = document.createElement('div'); dragbox.className = 'dragbox'; dragbox.style.display = 'none'; root.appendChild(dragbox); this._dragbox = dragbox; + const commentLayer = document.createElement('div'); + commentLayer.className = 'comment-layer'; + root.appendChild(commentLayer); + this._commentLayer = commentLayer; + + const commentComposer = document.createElement('div'); + commentComposer.className = 'comment-surface comment-composer'; + commentComposer.style.display = 'none'; + commentComposer.setAttribute('role', 'dialog'); + commentComposer.setAttribute('aria-label', localizedStrings.commentOnSelectedElement); + commentComposer.setAttribute('aria-modal', 'true'); + commentLayer.appendChild(commentComposer); + this._commentComposer = commentComposer; + + const commentInput = document.createElement('textarea'); + commentInput.className = 'comment-input'; + commentInput.rows = 1; + commentInput.placeholder = localizedStrings.addCommentPlaceholder; + commentInput.setAttribute('aria-label', localizedStrings.commentOnSelectedElement); + commentInput.addEventListener('input', () => this._layoutCommentInput()); + commentInput.addEventListener('keydown', event => { + if (event.key === 'Enter' && !event.isComposing) { + event.preventDefault(); + this._submitComment(); + } + }); + commentComposer.appendChild(commentInput); + this._commentInput = commentInput; + + const sendButton = document.createElement('button'); + sendButton.className = 'comment-send'; + sendButton.type = 'button'; + const sendButtonIcon = document.createElementNS(svgNamespace, 'svg'); + sendButtonIcon.setAttribute('viewBox', '0 0 16 16'); + sendButtonIcon.setAttribute('fill', 'currentColor'); + sendButtonIcon.setAttribute('aria-hidden', 'true'); + const sendButtonIconPath = document.createElementNS(svgNamespace, 'path'); + sendButtonIconPath.setAttribute('d', 'M8.5 3a.5.5 0 0 0-1 0v4.5H3a.5.5 0 0 0 0 1h4.5V13a.5.5 0 0 0 1 0V8.5H13a.5.5 0 0 0 0-1H8.5V3Z'); + sendButtonIcon.appendChild(sendButtonIconPath); + sendButton.appendChild(sendButtonIcon); + sendButton.title = localizedStrings.addComment; + sendButton.setAttribute('aria-label', localizedStrings.addComment); + sendButton.addEventListener('click', () => this._submitComment()); + commentComposer.appendChild(sendButton); + this._commentSendButton = sendButton; + + commentComposer.addEventListener('keydown', event => { + if (event.key !== 'Tab') { + return; + } + if (event.shiftKey && event.target === commentInput) { + event.preventDefault(); + sendButton.focus(); + } else if (!event.shiftKey && event.target === sendButton) { + event.preventDefault(); + commentInput.focus(); + } + }); + window.addEventListener('scroll', () => this._onScrollOrResize(), { passive: true, capture: true }); window.addEventListener('resize', () => this._onScrollOrResize()); } start(options: IBrowserElementSelectionOptions): boolean { if (this._selectionActive) { + this._updateSelectionOptions(options); return true; } - this._continuous = false; // for now - document.documentElement.appendChild(this._shadowHost); + this._commentMode = options.mode === commentElementSelectionMode; + this._continuous = options.continuous ?? false; + this._ensureMounted(); this._selectionActive = true; + this._overlay.style.display = 'block'; // Inject a stylesheet into the page to override all cursors while element selection is active, // so the cursor always appears as a normal pointer even when over e.g. links. @@ -418,19 +612,36 @@ class ElementPicker { window.addEventListener('blur', this._onWindowBlur); window.addEventListener('keydown', this._onKeyDown, true); - const focusedElement = this._getFocusedElement(); - this._focusedTarget = options.highlightFocusedElement ? focusedElement : undefined; - this._updateHighlight(this._focusedTarget); + if (!this._externalHighlightTarget) { + const focusedElement = this._getFocusedElement(); + this._focusedTarget = options.highlightFocusedElement ? focusedElement : undefined; + this._updateHighlight(this._focusedTarget); + } return true; } + private _updateSelectionOptions(options: IBrowserElementSelectionOptions): void { + const wasCommentMode = this._commentMode; + this._commentMode = options.mode === commentElementSelectionMode; + this._continuous = options.continuous ?? false; + if (wasCommentMode && !this._commentMode && this._commentTarget) { + this._closeCommentComposer(); + } + if (options.highlightFocusedElement && !this._commentTarget && !this._commentPreviewElementId && !this._externalHighlightTarget) { + this._focusedTarget = this._getFocusedElement(); + this._updateHighlight(this._focusedTarget); + } + } + stop(): void { if (!this._selectionActive) { return; } + this._hideActiveCommentPreview(); this._selectionActive = false; - this._shadowHost.remove(); + this._closeCommentComposer(); + this._overlay.style.display = 'none'; this._cursorStylesheet?.remove(); this._cursorStylesheet = undefined; @@ -451,10 +662,15 @@ class ElementPicker { this._dragbox.style.display = 'none'; this._dragStart = undefined; this._dragStartTarget = undefined; + this._dismissedCommentOnPointerDown = false; this._highlightTarget = undefined; this._focusedTarget = undefined; + if (this._externalHighlightTarget) { + this._updateHighlight(this._externalHighlightTarget); + } this._onStopped(); + this._unmountWhenIdle(); } /** @@ -463,6 +679,20 @@ class ElementPicker { */ setTheme(theme: IBrowserViewTheme): void { ElementPicker._applyTheme(this._shadowHost, theme); + this._reducedMotion = theme.reducedMotion ?? false; + this._shadowHost.classList.toggle('reduce-motion', this._reducedMotion); + } + + updateLocalizedStrings(): void { + this._applyLocalizedStrings(); + } + + resolveContextMenuTarget(event: MouseEvent): Element | undefined { + if (this._commentPreviewElementId && event.composedPath().includes(this._shadowHost)) { + this._hideActiveCommentPreview(); + return this._pickElementAt(event.clientX, event.clientY); + } + return event.target instanceof Element ? event.target : undefined; } /** @@ -470,9 +700,9 @@ class ElementPicker { * Mounts the shadow host if not already in the document. */ highlight(element: Element): void { - if (!this._shadowHost.parentNode) { - document.documentElement.appendChild(this._shadowHost); - } + this._ensureMounted(); + this._externalHighlightTarget = element; + this._hideActiveCommentPreview(); this._updateHighlight(element); } @@ -481,10 +711,63 @@ class ElementPicker { * removes the shadow host from the document. */ hideHighlight(): void { - this._updateHighlight(undefined); - if (!this._selectionActive && this._shadowHost.parentNode) { - this._shadowHost.remove(); + this._externalHighlightTarget = undefined; + if (this._commentTarget) { + return; } + this._updateHighlight(undefined); + this._unmountWhenIdle(); + } + + comment(element: Element, anchor?: { x: number; y: number }): void { + this._externalHighlightTarget = undefined; + if (this._selectionActive) { + this.stop(); + } + this.start({ mode: commentElementSelectionMode }); + const bounds = element.getBoundingClientRect(); + this._showCommentComposer(element, anchor ?? { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2 + }); + } + + updateComments(update: IBrowserElementCommentsUpdate): void { + if (update.comments) { + const incoming = new Map(update.comments.map((comment, index) => [comment.elementId, { body: comment.body, ordinal: index + 1 }])); + for (const [elementId, comment] of this._comments) { + const incomingComment = incoming.get(elementId); + if (!incomingComment) { + this._clearCommentPreview(comment.target); + comment.pin.remove(); + this._comments.delete(elementId); + } else { + comment.ordinal = incomingComment.ordinal; + if (incomingComment.body === comment.body) { + continue; + } + comment.body = incomingComment.body; + if (this._commentPreviewElementId === elementId) { + this._setCommentPreviewBody(incomingComment.body); + this._renderHighlight(comment.target); + } + } + } + for (const [elementId, comment] of incoming) { + if (this._comments.has(elementId)) { + continue; + } + const pending = this._pendingComments.get(elementId); + if (pending) { + this._createCommentPin(elementId, pending.target, pending.anchor, comment.body, comment.ordinal); + } + } + } + for (const elementId of update.pendingCommentIdsToDiscard ?? []) { + this._pendingComments.delete(elementId); + } + this._updateCommentPinNumbers(); + this._unmountWhenIdle(); } // --- Event handlers --- @@ -493,6 +776,9 @@ class ElementPicker { if (!this._selectionActive) { return; } + if (this._commentTarget || this._commentPreviewElementId || this._externalHighlightTarget || e.composedPath().includes(this._shadowHost)) { + return; + } e.preventDefault(); e.stopPropagation(); if (!this._dragStart) { @@ -520,7 +806,7 @@ class ElementPicker { }; private _onPointerLeave = (): void => { - if (!this._selectionActive) { + if (!this._selectionActive || this._commentTarget || this._commentPreviewElementId || this._externalHighlightTarget) { return; } if (!this._dragStart) { @@ -532,6 +818,17 @@ class ElementPicker { if (!this._selectionActive) { return; } + this._dismissedCommentOnPointerDown = false; + if (e.composedPath().includes(this._shadowHost)) { + return; + } + if (this._commentTarget) { + this._dismissedCommentOnPointerDown = true; + this._finishCommentInteraction(); + e.preventDefault(); + e.stopPropagation(); + return; + } this._dragStart = { x: e.clientX, y: e.clientY }; this._dragStartTarget = this._pickElementAt(e.clientX, e.clientY); if (this._cursorStylesheet) { @@ -545,6 +842,14 @@ class ElementPicker { if (!this._selectionActive) { return; } + if (this._dismissedCommentOnPointerDown) { + e.preventDefault(); + e.stopPropagation(); + return; + } + if (e.composedPath().includes(this._shadowHost)) { + return; + } if (!this._dragStart) { return; } @@ -561,7 +866,7 @@ class ElementPicker { const target = this._dragStartTarget ?? this._pickElementAt(e.clientX, e.clientY); this._dragStartTarget = undefined; if (target) { - this._commit(target); + this._commit(target, { x: e.clientX, y: e.clientY }); } } else { // Drag → pick the deepest common ancestor of the region. @@ -574,7 +879,7 @@ class ElementPicker { const top = Math.min(start.y, e.clientY); const ancestor = this._pickRegionAncestor({ x: left, y: top, width: dx, height: dy }); if (ancestor) { - this._commit(ancestor); + this._commit(ancestor, { x: e.clientX, y: e.clientY }); } } e.preventDefault(); @@ -585,12 +890,24 @@ class ElementPicker { if (!this._selectionActive) { return; } + if (this._dismissedCommentOnPointerDown) { + this._dismissedCommentOnPointerDown = false; + e.preventDefault(); + e.stopPropagation(); + return; + } + if (e.composedPath().includes(this._shadowHost)) { + return; + } e.preventDefault(); e.stopPropagation(); }; - private _onFocusIn = (): void => { - if (!this._selectionActive) { + private _onFocusIn = (event: FocusEvent): void => { + if (!this._selectionActive || this._commentTarget || this._externalHighlightTarget) { + return; + } + if (event.composedPath().includes(this._shadowHost)) { return; } const focusedElement = this._getFocusedElement(); @@ -599,7 +916,7 @@ class ElementPicker { }; private _onWindowBlur = (): void => { - if (!this._selectionActive) { + if (!this._selectionActive || this._commentTarget || this._externalHighlightTarget) { return; } this._focusedTarget = undefined; @@ -611,6 +928,14 @@ class ElementPicker { return; } if (e.key === 'Escape') { + if (this._commentTarget) { + const target = this._commentTarget; + this._finishCommentInteraction(); + this._focusCommentTarget(target); + e.preventDefault(); + e.stopPropagation(); + return; + } this.stop(); e.preventDefault(); e.stopPropagation(); @@ -625,9 +950,19 @@ class ElementPicker { }; private _onScrollOrResize(): void { + if (this._commentPreviewCollapsing) { + this._hideActiveCommentPreview(); + } + this._cancelCommentPreviewAnimations(); if (this._highlightTarget) { this._renderHighlight(this._highlightTarget); } + if (this._commentBackdropTarget) { + this._layoutCommentBackdrop(this._commentBackdropTarget); + } + for (const comment of this._comments.values()) { + this._layoutCommentPin(comment); + } } // --- Picking helpers --- @@ -694,6 +1029,8 @@ class ElementPicker { const scrollX = window.scrollX || 0; const scrollY = window.scrollY || 0; const viewportHeight = window.innerHeight; + const viewportWidth = document.documentElement.clientWidth; + const visibleRect = this._getVisibleTargetBounds(rect); const labelHeight = 22; // label height (20) + 2px gap above the box. // Highlight box is in *page* coordinates so it scrolls with the document. @@ -702,6 +1039,12 @@ class ElementPicker { highlight.style.top = `${rect.top + scrollY}px`; highlight.style.width = `${rect.width}px`; highlight.style.height = `${rect.height}px`; + this._highlightShape.style.display = 'block'; + this._highlightShape.setAttribute('x', `${visibleRect.x}`); + this._highlightShape.setAttribute('y', `${visibleRect.y}`); + this._highlightShape.setAttribute('width', `${visibleRect.width}`); + this._highlightShape.setAttribute('height', `${visibleRect.height}`); + this._highlightShape.setAttribute('rx', '2'); // Label is in *viewport* coordinates and sticky-clamped to the viewport. const tagName = String(target.tagName || '').toLowerCase(); @@ -717,7 +1060,6 @@ class ElementPicker { const labelTop = Math.max(0, Math.min(viewportHeight - labelHeight, idealTop)); // Use clientWidth (excludes scrollbar) rather than innerWidth so the // label doesn't extend behind the scrollbar on Windows/Linux. - const viewportWidth = document.documentElement.clientWidth; // Position label at the element's left edge, but push it left if it // would overflow the viewport. Clamp to 0 so it never goes off-screen. label.style.left = '0'; @@ -726,13 +1068,59 @@ class ElementPicker { const labelLeft = Math.max(0, Math.min(idealLeft, viewportWidth - naturalWidth)); label.style.left = `${labelLeft}px`; label.style.top = `${labelTop}px`; + + let commentSurfaceAbove = false; + for (const surface of [this._commentPreview, this._commentComposer]) { + if (surface.style.display !== 'none') { + commentSurfaceAbove = this._layoutCommentSurface(surface, visibleRect, viewportWidth, viewportHeight) === 'above' || commentSurfaceAbove; + } + } + if (commentSurfaceAbove) { + label.style.top = `${Math.max(0, Math.min(viewportHeight - labelHeight, visibleRect.bottom + 2))}px`; + } + } + + private _getVisibleTargetBounds(rect: DOMRect): DOMRect { + const left = Math.max(0, Math.min(rect.left, window.innerWidth)); + const right = Math.max(left, Math.min(rect.right, window.innerWidth)); + const top = Math.max(0, Math.min(rect.top, window.innerHeight)); + const bottom = Math.max(top, Math.min(rect.bottom, window.innerHeight)); + return new DOMRect(left, top, right - left, bottom - top); + } + + private _layoutCommentSurface(surface: HTMLElement, targetBounds: DOMRect, viewportWidth: number, viewportHeight: number): 'above' | 'below' { + if (surface === this._commentPreview) { + const availableWidth = Math.min(320, viewportWidth - 16); + const maximumWidth = Math.min(Math.max(320, targetBounds.width), availableWidth); + surface.style.width = 'max-content'; + surface.style.minWidth = '0'; + surface.style.maxWidth = `${maximumWidth}px`; + } + const surfaceHeight = surface.offsetHeight; + const belowTop = targetBounds.bottom; + const placement = belowTop + surfaceHeight <= viewportHeight - 8 ? 'below' : 'above'; + const surfaceTop = belowTop + surfaceHeight <= viewportHeight - 8 + ? belowTop + : Math.max(0, targetBounds.top - surfaceHeight); + const surfaceWidth = surface.offsetWidth; + const alignLeft = targetBounds.left + surfaceWidth <= viewportWidth; + const alignment = alignLeft ? 'left' : 'right'; + const surfaceLeft = alignLeft + ? Math.max(0, targetBounds.left) + : Math.max(0, targetBounds.right - surfaceWidth); + surface.dataset.attachmentCorner = `${placement === 'below' ? 'top' : 'bottom'}-${alignment}`; + surface.style.left = `${surfaceLeft}px`; + surface.style.top = `${surfaceTop}px`; + return placement; } private _updateHighlight(target: Element | undefined): void { this._highlightTarget = target; if (!target) { this._highlight.style.display = 'none'; + this._highlightShape.style.display = 'none'; this._label.style.display = 'none'; + this._commentPreview.style.display = 'none'; return; } this._renderHighlight(target); @@ -740,10 +1128,18 @@ class ElementPicker { // --- Commit --- - private _commit(target: Element): void { + private _commit(target: Element, anchor?: { x: number; y: number }): void { if (!this._selectionActive) { return; } + if (this._commentMode) { + const bounds = target.getBoundingClientRect(); + this._showCommentComposer(target, anchor ?? { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2, + }); + return; + } // Wait a frame so any pending event handlers can be completed in the selecting active state. requestAnimationFrame(() => { if (!this._continuous) { @@ -757,6 +1153,413 @@ class ElementPicker { }); } + private _showCommentComposer(target: Element, anchor: { x: number; y: number }): void { + this._externalHighlightTarget = undefined; + this._hideActiveCommentPreview(); + this._commentTarget = target; + this._commentAnchor = { + x: anchor.x + window.scrollX, + y: anchor.y + window.scrollY + }; + this._updateHighlight(target); + this._showCommentBackdrop(target); + this._commentLayer.classList.add('composing'); + this._commentInput.value = ''; + this._commentComposer.style.display = 'flex'; + this._layoutCommentComposer(); + this._layoutCommentInput(); + this._animateCommentHighlight( + new DOMRect(anchor.x - 3, anchor.y - 3, 6, 6), + target, + [this._label, this._commentComposer] + ); + this._commentInput.focus({ preventScroll: true }); + requestAnimationFrame(() => { + if (this._commentTarget === target) { + this._commentInput.focus({ preventScroll: true }); + } + }); + } + + private _closeCommentComposer(): void { + this._commentTarget = undefined; + this._commentAnchor = undefined; + this._hideCommentBackdrop(); + this._commentLayer.classList.remove('composing'); + this._commentComposer.style.display = 'none'; + this._commentInput.value = ''; + this._cancelCommentPreviewAnimations(); + this._updateHighlight(undefined); + } + + private _finishCommentInteraction(): void { + if (this._continuous) { + this._closeCommentComposer(); + } else { + this.stop(); + } + } + + private _submitComment(): void { + const target = this._commentTarget; + const anchor = this._commentAnchor; + if (!target || !anchor) { + return; + } + const body = this._commentInput.value.replace(/\r?\n/g, ' '); + const elementId = this._onPicked(target, body); + this._pendingComments.set(elementId, { target, anchor, body }); + this._finishCommentInteraction(); + this._focusCommentTarget(target); + } + + private _focusCommentTarget(target: Element): void { + if (!target.isConnected || !(target instanceof HTMLElement || target instanceof SVGElement)) { + return; + } + + const hadTabIndex = target.hasAttribute('tabindex'); + if (!hadTabIndex) { + target.tabIndex = -1; + } + target.focus({ preventScroll: true }); + if (!hadTabIndex) { + target.removeAttribute('tabindex'); + } + } + + private _createCommentPin(elementId: string, target: Element, anchor: { x: number; y: number }, body: string, ordinal: number): void { + this._ensureMounted(); + const existing = this._comments.get(elementId); + if (existing) { + this._clearCommentPreview(existing.target); + } + existing?.pin.remove(); + this._pendingComments.delete(elementId); + const rect = target.getBoundingClientRect(); + const offset = { + x: anchor.x - (rect.left + window.scrollX), + y: anchor.y - (rect.top + window.scrollY) + }; + + const pin = document.createElement('div'); + pin.className = 'comment-pin'; + pin.tabIndex = 0; + pin.setAttribute('role', 'note'); + const bubble = document.createElement('span'); + bubble.className = 'comment-pin-bubble'; + const numberElement = document.createElement('span'); + numberElement.className = 'comment-pin-number'; + bubble.appendChild(numberElement); + pin.appendChild(bubble); + + const show = () => { + if (this._commentTarget || this._externalHighlightTarget) { + return; + } + this._showCommentPreview(elementId, target, body); + }; + pin.addEventListener('mouseenter', show); + pin.addEventListener('mouseleave', () => this._scheduleCommentPreviewHide()); + pin.addEventListener('focusin', show); + pin.addEventListener('focusout', () => this._scheduleCommentPreviewHide()); + this._commentLayer.appendChild(pin); + const comment = { target, pin, numberElement, body, ordinal, offset }; + this._comments.set(elementId, comment); + this._updateCommentPinNumbers(); + this._layoutCommentPin(comment); + } + + private _updateCommentPinNumbers(): void { + for (const comment of this._comments.values()) { + const numberLabel = String(comment.ordinal); + comment.numberElement.textContent = numberLabel; + comment.pin.title = comment.body || this._formatLocalizedString(localizedStrings.elementComment, numberLabel); + comment.pin.setAttribute( + 'aria-label', + comment.body + ? this._formatLocalizedString(localizedStrings.elementCommentWithBody, numberLabel, comment.body) + : this._formatLocalizedString(localizedStrings.emptyElementComment, numberLabel) + ); + } + } + + private _applyLocalizedStrings(): void { + this._commentPreviewRemoveButton.title = localizedStrings.removeComment; + this._commentPreviewRemoveButton.setAttribute('aria-label', localizedStrings.removeElementComment); + this._commentComposer.setAttribute('aria-label', localizedStrings.commentOnSelectedElement); + this._commentInput.placeholder = localizedStrings.addCommentPlaceholder; + this._commentInput.setAttribute('aria-label', localizedStrings.commentOnSelectedElement); + this._commentSendButton.title = localizedStrings.addComment; + this._commentSendButton.setAttribute('aria-label', localizedStrings.addComment); + this._updateCommentPinNumbers(); + } + + private _formatLocalizedString(template: string, ...values: readonly string[]): string { + return template.replace(/\{(\d+)\}/g, (_, index) => values[Number(index)] ?? ''); + } + + private _layoutCommentPin(comment: { target: Element; pin: HTMLDivElement; offset: { x: number; y: number } }): void { + const rect = comment.target.getBoundingClientRect(); + const x = rect.left + window.scrollX + comment.offset.x; + const y = rect.top + window.scrollY + comment.offset.y; + const scrollingElement = document.scrollingElement ?? document.documentElement; + const halfWidth = comment.pin.offsetWidth / 2; + const halfHeight = comment.pin.offsetHeight / 2; + const clampedX = Math.max(halfWidth, Math.min(x, scrollingElement.scrollWidth - halfWidth)); + const clampedY = Math.max(halfHeight, Math.min(y, scrollingElement.scrollHeight - halfHeight)); + comment.pin.style.left = `${clampedX}px`; + comment.pin.style.top = `${clampedY}px`; + } + + private _showCommentPreview(elementId: string, target: Element, fallbackBody: string): void { + if (this._commentPreviewCollapsing) { + return; + } + if (this._commentPreviewElementId === elementId) { + this._cancelCommentPreviewHide(); + return; + } + this._hideActiveCommentPreview(); + this._commentPreviewElementId = elementId; + const comment = this._comments.get(elementId); + const pinBounds = comment ? this._getCommentPinPointBounds(comment.pin) : undefined; + if (comment) { + comment.pin.classList.add('previewing'); + comment.pin.after(this._commentPreview); + } + const body = comment?.body ?? fallbackBody; + this._setCommentPreviewBody(body); + this._shadowHost.classList.add('comment-preview-active'); + this._updateHighlight(target); + this._showCommentBackdrop(target); + if (pinBounds) { + this._animateCommentHighlight( + pinBounds, + target, + [this._label, this._commentPreview] + ); + } + } + + private _setCommentPreviewBody(body: string): void { + this._commentPreviewBody.textContent = body; + this._commentPreview.title = body; + this._commentPreview.classList.toggle('empty', !body); + this._commentPreview.style.display = 'flex'; + } + + private _getCommentPinPointBounds(pin: HTMLElement): DOMRect { + const pinBounds = pin.getBoundingClientRect(); + return new DOMRect(pinBounds.left + 8, pinBounds.top + 8, 6, 6); + } + + private _animateCommentHighlight(pinBounds: DOMRect, target: Element, supportingElements: readonly HTMLElement[], collapsing = false): Animation | undefined { + if (this._reducedMotion) { + return undefined; + } + const targetBounds = this._getVisibleTargetBounds(target.getBoundingClientRect()); + const duration = 180; + const easing = 'cubic-bezier(0.2, 0, 0, 1)'; + const pinKeyframe: Keyframe = { + x: `${pinBounds.left}px`, + y: `${pinBounds.top}px`, + width: `${pinBounds.width}px`, + height: `${pinBounds.height}px`, + rx: `${pinBounds.width / 2}px` + }; + const targetKeyframe: Keyframe = { + x: `${targetBounds.left}px`, + y: `${targetBounds.top}px`, + width: `${targetBounds.width}px`, + height: `${targetBounds.height}px`, + rx: '2px' + }; + const highlightAnimation = this._highlightShape.animate( + collapsing ? [targetKeyframe, pinKeyframe] : [pinKeyframe, targetKeyframe], + { duration, easing, fill: 'forwards' } + ); + this._commentPreviewAnimations.push(highlightAnimation); + this._commentPreviewAnimations.push(this._commentBackdropCutout.animate( + collapsing ? [targetKeyframe, pinKeyframe] : [pinKeyframe, targetKeyframe], + { duration, easing, fill: 'forwards' } + )); + + for (const element of supportingElements) { + if (element.style.display === 'none') { + continue; + } + const hiddenKeyframe: Keyframe = { opacity: 0, transform: 'translateY(-4px)' }; + const keyframes = collapsing + ? [{ opacity: 1, transform: 'translateY(0)' }, { ...hiddenKeyframe, offset: 0.55 }, hiddenKeyframe] + : [hiddenKeyframe, { ...hiddenKeyframe, offset: 0.45 }, { opacity: 1, transform: 'translateY(0)' }]; + this._commentPreviewAnimations.push(element.animate(keyframes, { duration, easing, fill: 'forwards' })); + } + return highlightAnimation; + } + + private _scheduleCommentPreviewHide(): void { + this._cancelCommentPreviewHide(); + this._commentPreviewHideTimeout = window.setTimeout(() => { + this._commentPreviewHideTimeout = undefined; + const comment = this._commentPreviewElementId ? this._comments.get(this._commentPreviewElementId) : undefined; + if ( + comment?.pin.matches(':hover, :focus-within') || + this._highlight.matches(':hover, :focus-within') || + this._label.matches(':hover, :focus-within') || + this._commentPreview.matches(':hover, :focus-within') || + this._commentPreviewRemoveButton.matches(':hover, :focus-within') + ) { + return; + } + this._collapseActiveCommentPreview(); + }, 80); + } + + private _cancelCommentPreviewHide(): void { + if (this._commentPreviewHideTimeout !== undefined) { + window.clearTimeout(this._commentPreviewHideTimeout); + this._commentPreviewHideTimeout = undefined; + } + } + + private _collapseActiveCommentPreview(): void { + const elementId = this._commentPreviewElementId; + const comment = elementId ? this._comments.get(elementId) : undefined; + if (!elementId || !comment || this._reducedMotion) { + this._hideActiveCommentPreview(); + return; + } + + this._commentPreviewCollapsing = true; + this._shadowHost.classList.add('comment-preview-collapsing'); + this._fadeOutCommentBackdrop(); + let highlightAnimation: Animation | undefined = this._commentPreviewAnimations[0]; + if (highlightAnimation) { + for (const animation of this._commentPreviewAnimations) { + animation.reverse(); + } + } else { + highlightAnimation = this._animateCommentHighlight( + this._getCommentPinPointBounds(comment.pin), + comment.target, + [this._label, this._commentPreview], + true + ); + } + if (!highlightAnimation) { + this._hideActiveCommentPreview(); + return; + } + highlightAnimation.onfinish = () => { + if (this._commentPreviewCollapsing && this._commentPreviewElementId === elementId) { + this._commentPreviewCollapsing = false; + this._hideActiveCommentPreview(); + } + }; + } + + private _cancelCommentPreviewAnimations(): void { + for (const animation of this._commentPreviewAnimations) { + animation.cancel(); + } + this._commentPreviewAnimations = []; + } + + private _hideActiveCommentPreview(): void { + this._cancelCommentPreviewHide(); + this._commentPreviewCollapsing = false; + this._shadowHost.classList.remove('comment-preview-collapsing'); + this._cancelCommentPreviewAnimations(); + if (this._commentPreviewElementId) { + this._comments.get(this._commentPreviewElementId)?.pin.classList.remove('previewing'); + } + this._commentPreviewElementId = undefined; + this._shadowHost.classList.remove('comment-preview-active'); + this._commentPreview.style.display = 'none'; + this._hideCommentBackdrop(); + if (!this._commentTarget) { + this._updateHighlight(this._externalHighlightTarget); + } + } + + private _removeComment(elementId: string): void { + const comment = this._comments.get(elementId); + if (!comment) { + return; + } + this._hideActiveCommentPreview(); + comment.pin.remove(); + this._comments.delete(elementId); + this._updateCommentPinNumbers(); + this._unmountWhenIdle(); + this._onCommentRemoved(elementId); + } + + private _layoutCommentInput(): void { + this._commentInput.style.height = 'auto'; + this._commentInput.style.height = `${Math.min(this._commentInput.scrollHeight, 96)}px`; + this._layoutCommentComposer(); + } + + private _layoutCommentBackdrop(target: Element): void { + const rect = this._getVisibleTargetBounds(target.getBoundingClientRect()); + this._commentBackdropCutout.setAttribute('x', `${rect.x}`); + this._commentBackdropCutout.setAttribute('y', `${rect.y}`); + this._commentBackdropCutout.setAttribute('width', `${rect.width}`); + this._commentBackdropCutout.setAttribute('height', `${rect.height}`); + this._commentBackdropCutout.setAttribute('rx', '2'); + } + + private _showCommentBackdrop(target: Element): void { + const request = ++this._commentBackdropRequest; + this._commentBackdropTarget = target; + this._layoutCommentBackdrop(target); + this._commentBackdrop.classList.remove('visible'); + requestAnimationFrame(() => { + if (this._commentBackdropRequest === request) { + this._commentBackdrop.classList.add('visible'); + } + }); + } + + private _hideCommentBackdrop(): void { + this._commentBackdropRequest++; + this._commentBackdropTarget = undefined; + this._commentBackdrop.classList.remove('visible'); + } + + private _fadeOutCommentBackdrop(): void { + this._commentBackdropRequest++; + this._commentBackdrop.classList.remove('visible'); + } + + private _clearCommentPreview(target: Element): void { + if (this._commentTarget || this._commentBackdropTarget !== target) { + return; + } + this._hideActiveCommentPreview(); + } + + private _layoutCommentComposer(): void { + if (!this._commentTarget) { + return; + } + this._renderHighlight(this._commentTarget); + } + + private _ensureMounted(): void { + if (!this._shadowHost.parentNode) { + document.documentElement.appendChild(this._shadowHost); + } + } + + private _unmountWhenIdle(): void { + if (!this._selectionActive && !this._highlightTarget && this._comments.size === 0) { + this._shadowHost.remove(); + } + } + // --- Static helpers --- /** @@ -777,15 +1580,216 @@ class ElementPicker { } .highlight { position: absolute; box-sizing: border-box; - border: 2px solid var(--vscode-focusBorder, #0078d4); - background: color-mix(in srgb, var(--vscode-focusBorder, #0078d4) 12%, transparent); - border-radius: 2px; + z-index: 2; + } + .comment-backdrop { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 2; + } + .comment-backdrop-fill { + fill: var(--vscode-widget-shadow, transparent); + opacity: 0; + transition: opacity 120ms linear; + } + .comment-backdrop.visible .comment-backdrop-fill { + opacity: 1; + } + .highlight-shape { + fill: color-mix(in srgb, var(--vscode-focusBorder, #0078d4) 12%, transparent); + stroke: var(--vscode-focusBorder, #0078d4); + stroke-width: 2px; } .overlay { position: fixed; inset: 0; background: transparent; box-sizing: border-box; z-index: 1; } + .comment-layer { + position: absolute; inset: 0; pointer-events: none; + } + .comment-surface { + position: fixed; + box-sizing: border-box; + width: min(320px, calc(100vw - 16px)); + border: var(--vscode-strokeThickness, 1px) solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder, #454545)); + border-radius: var(--vscode-cornerRadius-large, 8px); + background: var(--vscode-editorWidget-background, #252526); + color: var(--vscode-editorWidget-foreground, #cccccc); + box-shadow: 0 2px 6px var(--vscode-widget-shadow, transparent); + font-size: 13px; + font-weight: 400; + z-index: 3; + } + .comment-surface[data-attachment-corner='top-left'] { + border-top-left-radius: 0; + } + .comment-surface[data-attachment-corner='top-right'] { + border-top-right-radius: 0; + } + .comment-surface[data-attachment-corner='bottom-left'] { + border-bottom-left-radius: 0; + } + .comment-surface[data-attachment-corner='bottom-right'] { + border-bottom-right-radius: 0; + } + .comment-preview { + align-items: flex-start; + gap: 8px; + max-height: 96px; + padding: 6px 8px; + overflow: hidden; + line-height: 20px; + pointer-events: none; + } + .comment-preview.empty { + gap: 0; + padding: 4px; + } + .comment-preview.empty .comment-preview-body { + display: none; + } + .comment-preview.empty .comment-preview-remove { + margin-block: 0; + } + .comment-preview-body { + flex: 1; + min-width: 0; + max-height: 82px; + overflow-x: hidden; + overflow-y: auto; + overflow-wrap: anywhere; + scrollbar-width: thin; + white-space: pre-wrap; + } + :host(.comment-preview-active) .highlight, + :host(.comment-preview-active) .label, + :host(.comment-preview-active) .comment-preview { + pointer-events: auto; + } + :host(.comment-preview-collapsing) .highlight, + :host(.comment-preview-collapsing) .label, + :host(.comment-preview-collapsing) .comment-preview { + pointer-events: none; + } + .comment-preview-remove { + flex: none; + display: grid; + place-items: center; + box-sizing: border-box; + width: 24px; + height: 24px; + margin-block: -2px; + padding: 0; + border: 0; + border-radius: var(--vscode-cornerRadius-small, 4px); + background: transparent; + color: var(--vscode-editorWidget-foreground, inherit); + cursor: pointer; + font-family: inherit; + } + .comment-preview-remove svg { + display: block; + width: var(--vscode-codiconFontSize, 16px); + height: var(--vscode-codiconFontSize, 16px); + } + .comment-preview-remove:hover { + background: var(--vscode-toolbar-hoverBackground, transparent); + } + .comment-composer { + align-items: flex-end; gap: 6px; padding: 6px; + pointer-events: auto; + z-index: 4; + } + .comment-input { + flex: 1; min-width: 0; resize: none; overflow: auto; + scrollbar-width: none; + box-sizing: border-box; margin: 0; padding: 2px 6px; + background: transparent; color: inherit; + border: var(--vscode-strokeThickness, 1px) solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder, #454545)); + border-radius: var(--vscode-cornerRadius-small, 4px); + outline: 0; + font: inherit; + line-height: 20px; + caret-color: var(--vscode-focusBorder, currentColor); + } + .comment-input::-webkit-scrollbar { + display: none; + } + .comment-input::placeholder { + color: var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground, #ccccccb3)); + opacity: 1; + } + .comment-send { + box-sizing: border-box; border: 0; cursor: pointer; font-family: inherit; + } + .comment-send { + flex: none; width: 24px; height: 24px; padding: 0; + border-radius: var(--vscode-cornerRadius-small, 4px); + background: transparent; + color: var(--vscode-editorWidget-foreground, #cccccc); + display: grid; + place-items: center; + } + .comment-send svg { + display: block; + width: var(--vscode-codiconFontSize, 16px); + height: var(--vscode-codiconFontSize, 16px); + } + .comment-send:hover { + background: var(--vscode-toolbar-hoverBackground, transparent); + } + .comment-pin { + position: absolute; + display: grid; + place-items: center; + width: 22px; + height: 22px; + transform: translate(-11px, -11px); + pointer-events: auto; + z-index: 4; + } + .comment-layer.composing .comment-pin { + pointer-events: none; + z-index: auto; + } + .comment-pin:hover, .comment-pin:focus-within { + z-index: 5; + } + .comment-pin.previewing:not(:focus-within) .comment-pin-bubble { + visibility: hidden; + } + .comment-pin-bubble { + box-sizing: border-box; + display: grid; + place-items: center; + width: 22px; + height: 22px; + padding: 0; + border: var(--vscode-strokeThickness, 1px) solid var(--vscode-editorWidget-background, #252526); + border-radius: var(--vscode-cornerRadius-circle, 9999px); + background: var(--vscode-button-background, #0078d4); + color: var(--vscode-button-foreground, white); + box-shadow: 0 2px 6px var(--vscode-widget-shadow, transparent); + } + .comment-pin-number { + display: block; + width: 100%; + font-size: 11px; + font-weight: 600; + line-height: 12px; + text-align: center; + } + .comment-send:focus-visible, .comment-preview-remove:focus-visible, .comment-pin:focus-visible, .comment-input:focus-visible { + outline: 2px solid var(--vscode-focusBorder, #0078d4); + outline-offset: 2px; + } + :host(.reduce-motion) .comment-backdrop-fill { + transition: none; + } .label { position: fixed; box-sizing: border-box; display: inline-flex; align-items: center; gap: 6px; height: 20px; padding: 0 6px; @@ -822,6 +1826,14 @@ class ElementPicker { host.style.setProperty('--vscode-focusBorder', theme?.focusBorder ?? null); host.style.setProperty('--vscode-button-background', theme?.buttonBackground ?? null); host.style.setProperty('--vscode-button-foreground', theme?.buttonForeground ?? null); + host.style.setProperty('--vscode-editorWidget-background', theme?.widgetBackground ?? null); + host.style.setProperty('--vscode-editorWidget-foreground', theme?.widgetForeground ?? null); + host.style.setProperty('--vscode-editorWidget-border', theme?.widgetBorder ?? null); + host.style.setProperty('--vscode-widget-shadow', theme?.widgetShadow ?? null); + host.style.setProperty('--vscode-contrastBorder', theme?.contrastBorder ?? null); + host.style.setProperty('--vscode-descriptionForeground', theme?.descriptionForeground ?? null); + host.style.setProperty('--vscode-input-placeholderForeground', theme?.inputPlaceholderForeground ?? null); + host.style.setProperty('--vscode-toolbar-hoverBackground', theme?.toolbarHoverBackground ?? null); host.style.setProperty('--pick-font', theme?.font ?? null); } } diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index b7f963076bb..aa403231eb4 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -603,7 +603,7 @@ export class BrowserView extends Disposable { storageKeys: { ...this.session.history.storageKeys, ...this.session.permissions.storageKeys }, permissions: this.session.permissions.serialize(), browserZoomIndex: this._browserZoomIndex, - isElementSelectionActive: this.inspector.isElementSelectionActive, + elementSelectionState: this.inspector.elementSelectionState, isRemoteSession: this.session.remote.isRemote, isAreaSelectionActive: this.inspector.isAreaSelectionActive, device: this.emulator.device diff --git a/src/vs/platform/browserView/electron-main/browserViewFrameInspector.ts b/src/vs/platform/browserView/electron-main/browserViewFrameInspector.ts index e35ab51cd8d..ef381a9175a 100644 --- a/src/vs/platform/browserView/electron-main/browserViewFrameInspector.ts +++ b/src/vs/platform/browserView/electron-main/browserViewFrameInspector.ts @@ -5,12 +5,13 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { IBrowserElementSelectionOptions, IElementData, IElementAncestor, IBrowserViewTheme } from '../common/browserView.js'; +import { BrowserElementSelectionMode, IElementData, IElementAncestor, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewTheme } from '../common/browserView.js'; import { collapseToShorthands, formatMatchedStyles, keyComputedProperties, type IMatchedStyles } from '../common/cssHelpers.js'; import { ICDPConnection } from '../common/cdp/types.js'; export interface IFrameElementHandle extends IDisposable { addToChat(): Promise; + addComment(): void; highlight(): Promise; hideHighlight(): Promise; } @@ -42,6 +43,11 @@ interface ILayoutMetricsResult { }; } +interface IActiveInspection extends IDisposable { + readonly mode: 'cdp' | 'preload'; + stop(): Promise; +} + /** Slightly customised CDP debugger inspect highlight colours. */ export const inspectHighlightConfig = { showInfo: true, @@ -107,12 +113,14 @@ export class BrowserViewFrameInspector extends Disposable { private readonly _onDidInspectElement = this._register(new Emitter()); readonly onDidInspectElement: Event = this._onDidInspectElement.event; + private readonly _onDidRemoveElementComment = this._register(new Emitter()); + readonly onDidRemoveElementComment = this._onDidRemoveElementComment.event; private readonly _onDidStopPicking = this._register(new Emitter()); readonly onDidStopPicking: Event = this._onDidStopPicking.event; private _isPaused = false; - private readonly _activeInspection = this._register(new MutableDisposable()); + private readonly _activeInspection = this._register(new MutableDisposable()); /** Whether this frame's JavaScript execution is currently paused by the debugger. */ get isPaused(): boolean { return this._isPaused; } @@ -177,19 +185,27 @@ export class BrowserViewFrameInspector extends Disposable { })); // Listen for element-picked IPC from this frame's preload - const onPicked = async (event: Electron.IpcMainEvent, pickId: string) => { - if (!pickId || event.senderFrame !== this.frame) { + const onPicked = async (event: Electron.IpcMainEvent, result: { elementId?: string; comment?: string }) => { + if (!result?.elementId || event.senderFrame !== this.frame) { return; } try { - const nodeData = await this.extractNodeDataById(pickId); - this._onDidInspectElement.fire(nodeData); + const nodeData = await this.extractNodeDataById(result.elementId); + this._onDidInspectElement.fire({ ...nodeData, elementId: result.elementId, comment: result.comment }); } catch { + this._updateElementComments({ pendingCommentIdsToDiscard: [result.elementId] }); // Best effort; user can re-pick. } }; frame.ipc.on('vscode:browserView:elementPicked', onPicked); this._register({ dispose: () => frame.ipc.removeListener('vscode:browserView:elementPicked', onPicked) }); + const onCommentRemoved = (event: Electron.IpcMainEvent, elementId: string) => { + if (elementId && event.senderFrame === this.frame) { + this._onDidRemoveElementComment.fire(elementId); + } + }; + frame.ipc.on('vscode:browserView:elementCommentRemoved', onCommentRemoved); + this._register({ dispose: () => frame.ipc.removeListener('vscode:browserView:elementCommentRemoved', onCommentRemoved) }); // Listen for pick-stopped IPC from this frame's preload const onPickStopped = (event: Electron.IpcMainEvent) => { @@ -234,37 +250,63 @@ export class BrowserViewFrameInspector extends Disposable { * Stores a disposable so stop always tears down the correct mode. */ async startInspection(options: IBrowserElementSelectionOptions): Promise { - if (this._isPaused) { + const mode = this._isPaused && options.mode !== BrowserElementSelectionMode.Comment ? 'cdp' : 'preload'; + if (this._activeInspection.value?.mode === mode) { + if (mode === 'preload') { + this.frame.postMessage('vscode:browserView:startElementPicker', options); + } + return; + } + + await this._stopInspection(); + if (mode === 'cdp') { await this.connection.sendCommand('Overlay.setInspectMode', { mode: 'searchForNode', highlightConfig: inspectHighlightConfig, }); + const stop = async () => { + if (this.frame.isDestroyed()) { + return; + } + try { + await this.connection.sendCommand('Overlay.setInspectMode', { + mode: 'none', + highlightConfig: { showInfo: false, showStyles: false } + }); + await this.connection.sendCommand('Overlay.hideHighlight'); + } catch { + // Best effort. + } + }; this._activeInspection.value = { - dispose: async () => { - if (this.frame.isDestroyed()) { - return; - } - try { - await this.connection.sendCommand('Overlay.setInspectMode', { - mode: 'none', - highlightConfig: { showInfo: false, showStyles: false } - }); - await this.connection.sendCommand('Overlay.hideHighlight'); - } catch { - // Best effort. - } + mode, + stop, + dispose: () => { + void stop(); } }; } else { this.frame.postMessage('vscode:browserView:startElementPicker', options); - this._activeInspection.value = { - dispose: () => { - if (this.frame.isDestroyed()) { - return; - } + const stop = async () => { + if (!this.frame.isDestroyed()) { this.frame.postMessage('vscode:browserView:stopElementPicker', {}); } }; + this._activeInspection.value = { + mode, + stop, + dispose: () => { + void stop(); + } + }; + } + } + + private async _stopInspection(): Promise { + const activeInspection = this._activeInspection.value; + if (activeInspection) { + this._activeInspection.clearAndLeak(); + await activeInspection.stop(); } } @@ -272,7 +314,17 @@ export class BrowserViewFrameInspector extends Disposable { * Stop element inspection on this frame. */ async stopInspection(): Promise { - this._activeInspection.clear(); + await this._stopInspection(); + } + + setElementComments(update: IBrowserElementCommentsUpdate): void { + this._updateElementComments(update); + } + + private _updateElementComments(update: IBrowserElementCommentsUpdate): void { + if (!this.frame.isDestroyed()) { + this.frame.postMessage('vscode:browserView:setElementComments', update); + } } /** @@ -328,6 +380,9 @@ export class BrowserViewFrameInspector extends Disposable { const nodeData = await this.extractNodeDataById(elementId); this._onDidInspectElement.fire(nodeData); }, + addComment: () => { + this.frame.postMessage('vscode:browserView:showElementComment', { elementId }); + }, highlight: async () => { this.frame.postMessage('vscode:browserView:highlightElement', { elementId }); }, diff --git a/src/vs/platform/browserView/electron-main/browserViewInspector.ts b/src/vs/platform/browserView/electron-main/browserViewInspector.ts index 21c0815cd10..0099fd3d7b0 100644 --- a/src/vs/platform/browserView/electron-main/browserViewInspector.ts +++ b/src/vs/platform/browserView/electron-main/browserViewInspector.ts @@ -5,17 +5,32 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { IBrowserElementSelectionOptions, IElementData, IBrowserViewTheme, IBrowserViewRect } from '../common/browserView.js'; +import { BrowserElementSelectionMode, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserElementSelectionState, IElementData, IBrowserViewTheme, IBrowserViewRect, IBrowserViewPreloadLocalizedStrings } from '../common/browserView.js'; import { ICDPConnection } from '../common/cdp/types.js'; import type { BrowserView } from './browserView.js'; import { BrowserViewFrameInspector } from './browserViewFrameInspector.js'; +import { localize } from '../../../nls.js'; + +const localizedStrings: IBrowserViewPreloadLocalizedStrings = { + addComment: localize('browserView.addComment', "Add Comment"), + addCommentPlaceholder: localize('browserView.addCommentPlaceholder', "Add a comment"), + commentOnSelectedElement: localize('browserView.commentOnSelectedElement', "Comment on selected element"), + elementComment: localize('browserView.elementComment', "Element comment {0}"), + elementCommentWithBody: localize('browserView.elementCommentWithBody', "Element comment {0}: {1}"), + emptyElementComment: localize('browserView.emptyElementComment', "Empty element comment {0}"), + removeComment: localize('browserView.removeComment', "Remove Comment"), + removeElementComment: localize('browserView.removeElementComment', "Remove element comment"), +}; interface IActiveSelection extends IDisposable { - readonly options?: IBrowserElementSelectionOptions; + options: IBrowserElementSelectionOptions; } +interface IActiveAreaSelection extends IDisposable { } + export interface IElementHandle extends IDisposable { addToChat(): Promise; + addComment(): void; highlight(): Promise; hideHighlight(): Promise; } @@ -48,14 +63,23 @@ export class BrowserViewInspector extends Disposable { private readonly _onDidSelectElement = this._register(new Emitter()); readonly onDidSelectElement: Event = this._onDidSelectElement.event; + private readonly _onDidRemoveElementComment = this._register(new Emitter()); + readonly onDidRemoveElementComment = this._onDidRemoveElementComment.event; - private readonly _onDidChangeElementSelectionActive = this._register(new Emitter()); - readonly onDidChangeElementSelectionActive: Event = this._onDidChangeElementSelectionActive.event; + private readonly _onDidChangeElementSelectionState = this._register(new Emitter()); + readonly onDidChangeElementSelectionState: Event = this._onDidChangeElementSelectionState.event; private _elementSelectionActive = false; get isElementSelectionActive(): boolean { return this._elementSelectionActive; } + get elementSelectionState(): IBrowserElementSelectionState { + return { + active: this._elementSelectionActive, + options: this._activeSelection.value?.options ?? {} + }; + } private readonly _activeSelection = this._register(new MutableDisposable()); + private _inspectionOperation: Promise = Promise.resolve(); private _theme: IBrowserViewTheme = {}; // Area selection — drag-to-select a rectangle on the top frame. @@ -74,7 +98,7 @@ export class BrowserViewInspector extends Disposable { private _areaSelectionActive = false; get isAreaSelectionActive(): boolean { return this._areaSelectionActive; } - private readonly _activeAreaSelection = this._register(new MutableDisposable()); + private readonly _activeAreaSelection = this._register(new MutableDisposable()); private readonly _registry = this._register(new FrameInspectorRegistry()); @@ -108,6 +132,7 @@ export class BrowserViewInspector extends Disposable { // Apply theme immediately regardless of inspector state senderFrame.postMessage('vscode:browserView:setTheme', this._theme); + senderFrame.postMessage('vscode:browserView:setLocalizedStrings', localizedStrings); this._registry.notifyFrameReady(senderFrame, frameToken); @@ -225,7 +250,9 @@ export class BrowserViewInspector extends Disposable { */ private _onInspectorAdopted(inspector: BrowserViewFrameInspector): void { inspector.onDidInspectElement(async nodeData => { - this._activeSelection.clear(); + if (!this._activeSelection.value?.options?.continuous) { + this._activeSelection.clear(); + } try { const offset = await this._getFrameOffsetInPage(inspector.frame); nodeData = this._offsetElementData(nodeData, offset); @@ -234,6 +261,7 @@ export class BrowserViewInspector extends Disposable { } this._onDidSelectElement.fire(nodeData); }); + inspector.onDidRemoveElementComment(elementId => this._onDidRemoveElementComment.fire(elementId)); // When a frame's preload stops picking, stop all other frames too inspector.onDidStopPicking(() => { @@ -241,9 +269,13 @@ export class BrowserViewInspector extends Disposable { }); // If element selection is currently active, start it on the new frame - const activeSelection = this._activeSelection.value; - if (activeSelection) { - inspector.startInspection(activeSelection.options ?? {}).catch(() => { }); + if (this._activeSelection.value) { + void this._queueInspectionOperation(async () => { + const activeSelection = this._activeSelection.value; + if (activeSelection) { + await inspector.startInspection(activeSelection.options); + } + }).catch(() => { }); } inspector.setTheme(this._theme); @@ -262,43 +294,77 @@ export class BrowserViewInspector extends Disposable { */ async toggleElementSelection(enabled?: boolean, options: IBrowserElementSelectionOptions = {}): Promise { const newEnabled = enabled ?? !this._elementSelectionActive; - if (newEnabled === this._elementSelectionActive) { - return; - } - if (!newEnabled) { this._activeSelection.clear(); return; } - // Element and area selection are mutually exclusive — enabling one // cancels the other so both pickers never overlay the page at once. this._activeAreaSelection.clear(); - const start = () => Promise.all([...this._registry.inspectors].map(i => i.startInspection(options))); - const stop = () => Promise.all([...this._registry.inspectors].map(i => i.stopInspection())); + const activeSelection = this._activeSelection.value; + const updatedOptions = activeSelection ? { ...activeSelection.options, ...options } : { mode: BrowserElementSelectionMode.Select, ...options }; + + if (activeSelection) { + activeSelection.options = updatedOptions; + try { + if (await this._startInspection(activeSelection, updatedOptions)) { + this._elementSelectionActive = true; + this._onDidChangeElementSelectionState.fire({ active: true, options: updatedOptions }); + } + } catch { + if (this._activeSelection.value === activeSelection && activeSelection.options === updatedOptions) { + this._activeSelection.clear(); + } + } + return; + } const selection: IActiveSelection = { - options, + options: updatedOptions, dispose: () => { if (this._activeSelection.value === selection) { this._elementSelectionActive = false; - this._onDidChangeElementSelectionActive.fire(false); + this._onDidChangeElementSelectionState.fire({ active: false, options: selection.options }); this._activeSelection.clearAndLeak(); - void stop().catch(() => { }); + void this._queueInspectionOperation(async () => { + await Promise.all([...this._registry.inspectors].map(i => i.stopInspection())); + }).catch(() => { }); } } }; this._activeSelection.value = selection; - try { - await start(); - if (this._activeSelection.value === selection) { + if (await this._startInspection(selection, updatedOptions)) { this._elementSelectionActive = true; - this._onDidChangeElementSelectionActive.fire(true); + this._onDidChangeElementSelectionState.fire({ active: true, options: updatedOptions }); } } catch { - this._activeSelection.clear(); + if (this._activeSelection.value === selection && selection.options === updatedOptions) { + this._activeSelection.clear(); + } + } + } + + private async _startInspection(selection: IActiveSelection, options: IBrowserElementSelectionOptions): Promise { + await this._queueInspectionOperation(async () => { + if (this._activeSelection.value !== selection || selection.options !== options) { + return; + } + await Promise.all([...this._registry.inspectors].map(i => i.startInspection(options))); + }); + return this._activeSelection.value === selection && selection.options === options; + } + + private _queueInspectionOperation(operation: () => Promise): Promise { + const result = this._inspectionOperation.then(operation); + this._inspectionOperation = result.catch(() => { }); + return result; + } + + setElementComments(update: IBrowserElementCommentsUpdate): void { + for (const inspector of this._registry.inspectors) { + inspector.setElementComments(update); } } @@ -326,7 +392,7 @@ export class BrowserViewInspector extends Disposable { const start = () => { mainFrame.postMessage('vscode:browserView:startAreaPicker', undefined); }; const stop = () => { try { mainFrame.postMessage('vscode:browserView:stopAreaPicker', undefined); } catch { /* frame may be gone */ } }; - const selection: IActiveSelection = { + const selection: IActiveAreaSelection = { dispose: () => { // External cancellation (toggleAreaSelection(false), navigation, element // selection takeover). The IPC-driven termination paths use clearAndLeak @@ -371,7 +437,37 @@ export class BrowserViewInspector extends Disposable { * Resolve a handle to an element. Routes to the correct frame inspector. */ getElementHandle(id: string, frame: Electron.WebFrameMain): IElementHandle | undefined { - return this._registry.getByFrame(frame)?.getElementHandle(id); + const handle = this._registry.getByFrame(frame)?.getElementHandle(id); + if (!handle) { + return undefined; + } + let commentRequested = false; + return { + addToChat: () => handle.addToChat(), + addComment: () => { + if (commentRequested) { + return; + } + commentRequested = true; + setTimeout(() => { + this._activeAreaSelection.clear(); + this._activeSelection.clear(); + void this._queueInspectionOperation(async () => { + if (!this.browser.webContents.isDestroyed()) { + this.browser.webContents.focus(); + handle.addComment(); + } + }); + }, 0); + }, + highlight: () => handle.highlight(), + hideHighlight: () => handle.hideHighlight(), + dispose: () => { + if (!commentRequested) { + handle.dispose(); + } + } + }; } async getVisualViewportScale(frame: Electron.WebFrameMain = this.browser.webContents.mainFrame): Promise { diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index d56290b93b8..3861f6ee808 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserElementSelectionOptions, IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions, IBrowserViewWindowConfiguration, IBrowserDeviceProfile } from '../common/browserView.js'; +import { IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewOpenOptions, IBrowserViewCreateOptions, IBrowserViewWindowConfiguration, IBrowserDeviceProfile } from '../common/browserView.js'; import { clipboard, Menu, MenuItem } from 'electron'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -190,8 +190,12 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).inspector.onDidSelectElement; } - onDynamicDidChangeElementSelectionActive(id: string) { - return this._getBrowserView(id).inspector.onDidChangeElementSelectionActive; + onDynamicDidRemoveElementComment(id: string) { + return this._getBrowserView(id).inspector.onDidRemoveElementComment; + } + + onDynamicDidChangeElementSelectionState(id: string) { + return this._getBrowserView(id).inspector.onDidChangeElementSelectionState; } onDynamicDidPickArea(id: string) { @@ -342,6 +346,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).inspector.toggleElementSelection(enabled, options); } + async setElementComments(id: string, update: IBrowserElementCommentsUpdate): Promise { + this._getBrowserView(id).inspector.setElementComments(update); + } + async toggleAreaSelection(id: string, enabled?: boolean): Promise { return this._getBrowserView(id).inspector.toggleAreaSelection(enabled); } @@ -589,15 +597,21 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa })); } - menu.append(new MenuItem({ type: 'separator' })); if (inspectTarget) { + menu.append(new MenuItem({ type: 'separator' })); menu.append(new MenuItem({ label: localize('browser.contextMenu.addElementToChat', 'Add Element to Chat'), click: () => inspectTarget.addToChat() })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.addComment', 'Add Comment...'), + click: () => inspectTarget.addComment() + })); void inspectTarget.highlight().catch(() => { }); menu.on('menu-will-close', () => inspectTarget.dispose()); } + + menu.append(new MenuItem({ type: 'separator' })); menu.append(new MenuItem({ label: localize('browser.contextMenu.inspect', 'Inspect'), click: () => webContents.inspectElement(params.x, params.y) diff --git a/src/vs/platform/history/browser/contextScopedHistoryWidget.ts b/src/vs/platform/history/browser/contextScopedHistoryWidget.ts index 4c401062680..18eda7b938e 100644 --- a/src/vs/platform/history/browser/contextScopedHistoryWidget.ts +++ b/src/vs/platform/history/browser/contextScopedHistoryWidget.ts @@ -114,7 +114,6 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ when: ContextKeyExpr.and( ContextKeyExpr.has(HistoryNavigationWidgetFocusContext), ContextKeyExpr.equals(HistoryNavigationBackwardsEnablementContext, true), - ContextKeyExpr.not('isComposing'), historyNavigationVisible.isEqualTo(false), ), primary: KeyCode.UpArrow, @@ -130,7 +129,6 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ when: ContextKeyExpr.and( ContextKeyExpr.has(HistoryNavigationWidgetFocusContext), ContextKeyExpr.equals(HistoryNavigationForwardsEnablementContext, true), - ContextKeyExpr.not('isComposing'), historyNavigationVisible.isEqualTo(false), ), primary: KeyCode.DownArrow, diff --git a/src/vs/platform/keybinding/common/abstractKeybindingService.ts b/src/vs/platform/keybinding/common/abstractKeybindingService.ts index 19ec19eaf50..6af73a017c7 100644 --- a/src/vs/platform/keybinding/common/abstractKeybindingService.ts +++ b/src/vs/platform/keybinding/common/abstractKeybindingService.ts @@ -30,6 +30,15 @@ interface CurrentChord { const HIGH_FREQ_COMMANDS = /^(cursor|delete|undo|redo|tab|editor\.action\.clipboard)/; +/** + * Whether the keystroke belongs to an in-flight IME composition. `StandardKeyboardEvent` normalizes + * every composing keystroke to {@link KeyCode.KEY_IN_COMPOSITION}, including the platform/IME + * combinations that would otherwise report the real key code for keys the IME owns. + */ +function isKeyInComposition(e: IKeyboardEvent): boolean { + return e.keyCode === KeyCode.KEY_IN_COMPOSITION; +} + export abstract class AbstractKeybindingService extends Disposable implements IKeybindingService { public _serviceBrand: undefined; @@ -139,6 +148,13 @@ export abstract class AbstractKeybindingService extends Disposable implements IK // TODO@ulugbekna: this fn doesn't seem to take into account single-modifier keybindings, eg `shift shift` public softDispatch(e: IKeyboardEvent, target: IContextKeyServiceTarget): ResolutionResult { this._log(`/ Soft dispatching keyboard event`); + if (isKeyInComposition(e)) { + // Must agree with `_dispatch`: callers use this to decide whether the workbench will + // claim the key, and a "yes" here followed by a "no" there would drop the keystroke on + // the floor - stopping the widget (e.g. the terminal) from passing it to the IME. + this._log(`\\ Keyboard event is part of an IME composition`); + return NoMatchingKb; + } const keybinding = this.resolveKeyboardEvent(e); if (keybinding.hasMultipleChords()) { console.warn('keyboard event should not be mapped to multiple chords'); @@ -219,10 +235,21 @@ export abstract class AbstractKeybindingService extends Disposable implements IK } protected _dispatch(e: IKeyboardEvent, target: IContextKeyServiceTarget): boolean { + if (isKeyInComposition(e)) { + // The keystroke belongs to the IME, which owns Enter (commit), Space and the arrows + // (candidate selection) and Escape (cancel) for the duration of the composition. + // Dispatching would run commands the user never invoked - e.g. accepting a picker or + // submitting a form while they are still choosing characters. + this._log(`+ Ignoring keybinding dispatch because an IME composition is in progress.`); + return false; + } return this._doDispatch(this.resolveKeyboardEvent(e), target, /*isSingleModiferChord*/false); } protected _singleModifierDispatch(e: IKeyboardEvent, target: IContextKeyServiceTarget): boolean { + if (isKeyInComposition(e)) { + return false; + } const keybinding = this.resolveKeyboardEvent(e); const [singleModifier,] = keybinding.getSingleModifierDispatchChords(); diff --git a/src/vs/platform/keybinding/test/common/abstractKeybindingService.test.ts b/src/vs/platform/keybinding/test/common/abstractKeybindingService.test.ts index 372e4f312bf..bf73df3865b 100644 --- a/src/vs/platform/keybinding/test/common/abstractKeybindingService.test.ts +++ b/src/vs/platform/keybinding/test/common/abstractKeybindingService.test.ts @@ -13,7 +13,7 @@ import { ICommandService } from '../../../commands/common/commands.js'; import { ContextKeyExpr, ContextKeyExpression, IContext, IContextKeyService, IContextKeyServiceTarget } from '../../../contextkey/common/contextkey.js'; import { AbstractKeybindingService } from '../../common/abstractKeybindingService.js'; import { IKeyboardEvent } from '../../common/keybinding.js'; -import { KeybindingResolver } from '../../common/keybindingResolver.js'; +import { KeybindingResolver, ResolutionResult, ResultKind } from '../../common/keybindingResolver.js'; import { ResolvedKeybindingItem } from '../../common/resolvedKeybindingItem.js'; import { USLayoutResolvedKeybinding } from '../../common/usLayoutResolvedKeybinding.js'; import { createUSLayoutResolvedKeybinding } from './keybindingsTestUtils.js'; @@ -71,18 +71,27 @@ suite('AbstractKeybindingService', () => { return []; } - public testDispatch(kb: number): boolean { + public testDispatch(kb: number, isComposing: boolean = false): boolean { + return this._dispatch(this._toKeyboardEvent(kb, isComposing), null!); + } + + public testSoftDispatch(kb: number, isComposing: boolean = false): ResolutionResult { + return this.softDispatch(this._toKeyboardEvent(kb, isComposing), null!); + } + + private _toKeyboardEvent(kb: number, isComposing: boolean): IKeyboardEvent { const keybinding = createSimpleKeybinding(kb, OS); - return this._dispatch({ + return { _standardKeyboardEventBrand: true, ctrlKey: keybinding.ctrlKey, shiftKey: keybinding.shiftKey, altKey: keybinding.altKey, metaKey: keybinding.metaKey, altGraphKey: false, - keyCode: keybinding.keyCode, + // `StandardKeyboardEvent` normalizes composing keystrokes to KEY_IN_COMPOSITION. + keyCode: isComposing ? KeyCode.KEY_IN_COMPOSITION : keybinding.keyCode, code: null! - }, null!); + }; } public _dumpDebugInfo(): string { @@ -475,6 +484,40 @@ suite('AbstractKeybindingService', () => { kbService.dispose(); }); + test('keybindings are not dispatched while an IME composition is in progress', () => { + + const kbService = createTestKeybindingService([ + kbItem(KeyCode.Enter, 'enterCommand'), + ]); + + // Enter commits the IME composition and belongs to the input method, not to the workbench. + const shouldPreventDefaultWhileComposing = kbService.testDispatch(KeyCode.Enter, true); + assert.deepStrictEqual( + [shouldPreventDefaultWhileComposing, executeCommandCalls], + [false, []] + ); + + // `softDispatch` must agree, otherwise callers that ask "will the workbench claim this key?" + // prevent the default and then nobody handles the keystroke. + assert.strictEqual( + kbService.testSoftDispatch(KeyCode.Enter, true).kind, + ResultKind.NoMatchingKb + ); + + // Once the composition has committed, the very same key runs the command as usual. + const shouldPreventDefault = kbService.testDispatch(KeyCode.Enter, false); + assert.deepStrictEqual( + [shouldPreventDefault, executeCommandCalls], + [true, [{ commandId: 'enterCommand', args: [null] }]] + ); + assert.strictEqual( + kbService.testSoftDispatch(KeyCode.Enter, false).kind, + ResultKind.KbFound + ); + + kbService.dispose(); + }); + test('can trigger command that is sharing keybinding with chord', () => { const kbService = createTestKeybindingService([ diff --git a/src/vs/platform/localTranscription/common/localTranscription.ts b/src/vs/platform/localTranscription/common/localTranscription.ts index 2b76ac0457c..2678c915955 100644 --- a/src/vs/platform/localTranscription/common/localTranscription.ts +++ b/src/vs/platform/localTranscription/common/localTranscription.ts @@ -12,6 +12,14 @@ export const ILocalTranscriptionService = createDecorator; + /** + * Imports the default on-device model from an official Foundry Local expansion + * pack or a prepared model directory into `cacheDir`. + */ + importModel(options: { readonly sourcePath: string; readonly cacheDir: string }): Promise; + /** * Ensure the model is downloaded/loaded (idempotent) and begin a new * transcription session. `cacheDir` is where model files are stored. `model` diff --git a/src/vs/platform/localTranscription/node/foundryLocalModelImport.ts b/src/vs/platform/localTranscription/node/foundryLocalModelImport.ts new file mode 100644 index 00000000000..b2fcdc9882b --- /dev/null +++ b/src/vs/platform/localTranscription/node/foundryLocalModelImport.ts @@ -0,0 +1,358 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createHash, randomUUID } from 'crypto'; +import { createReadStream, promises as fs } from 'fs'; +import { basename, dirname, join } from '../../../base/common/path.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { extract } from '../../../base/node/zip.js'; +import { DEFAULT_LOCAL_TRANSCRIPTION_MODEL, ILocalTranscriptionModelImportResult } from '../common/localTranscription.js'; + +const MODEL_PUBLISHER = 'Microsoft'; +const MODEL_VARIANT = `${DEFAULT_LOCAL_TRANSCRIPTION_MODEL}-generic-cpu`; +const INFERENCE_MODEL_FILE = 'inference_model.json'; +const GENAI_CONFIG_FILE = 'genai_config.json'; +const OCI_TITLE_ANNOTATION = 'org.opencontainers.image.title'; + +interface IOciDescriptor { + readonly digest: string; + readonly annotations?: Record; +} + +interface IOciIndex { + readonly manifests?: readonly IOciDescriptor[]; +} + +interface IOciManifest { + readonly layers?: readonly IOciDescriptor[]; +} + +interface IPreparedModel { + readonly version: number; + readonly versionDirectory: string; + readonly canMove: boolean; +} + +/** + * Imports the official Foundry Local expansion pack (ZIP or extracted OCI + * layout), or an already prepared model directory, into the model cache. + */ +export async function importFoundryLocalModel(sourcePath: string, cacheDir: string): Promise { + const sourceStat = await fs.stat(sourcePath); + await fs.mkdir(cacheDir, { recursive: true }); + const workDirectory = await fs.mkdtemp(join(cacheDir, '.dictation-model-import-')); + + try { + const prepared = sourceStat.isDirectory() + ? await prepareModelSource(sourcePath, workDirectory, false) + : await prepareModelArchive(sourcePath, workDirectory); + await verifyPreparedModel(prepared.versionDirectory); + await installPreparedModel(prepared, cacheDir); + return { model: DEFAULT_LOCAL_TRANSCRIPTION_MODEL, version: prepared.version }; + } finally { + await fs.rm(workDirectory, { recursive: true, force: true }); + } +} + +async function prepareModelArchive(sourcePath: string, workDirectory: string): Promise { + if (!sourcePath.toLowerCase().endsWith('.zip')) { + throw new Error('The selected dictation model package must be a ZIP archive or a folder.'); + } + + const extracted = join(workDirectory, 'archive'); + await extract(sourcePath, extracted, {}, CancellationToken.None); + return prepareModelSource(extracted, workDirectory, true); +} + +async function prepareModelSource(sourcePath: string, workDirectory: string, canMove: boolean): Promise { + const nestedPackage = (await findNamedFiles(sourcePath, 'Package.zip'))[0]; + if (nestedPackage) { + const extractedPackage = join(workDirectory, 'package'); + await extract(nestedPackage, extractedPackage, {}, CancellationToken.None); + if (canMove) { + await fs.rm(sourcePath, { recursive: true, force: true }); + } + sourcePath = extractedPackage; + canMove = true; + } + + const ociIndex = await findOciIndex(sourcePath); + if (ociIndex) { + return materializeOciModel(ociIndex, workDirectory, canMove); + } + + return findPreparedModel(sourcePath, canMove); +} + +async function findOciIndex(root: string): Promise { + for (const candidate of await findNamedFiles(root, 'index.json')) { + try { + const layout = JSON.parse(await fs.readFile(join(dirname(candidate), 'oci-layout'), 'utf8')) as { imageLayoutVersion?: string }; + if (layout.imageLayoutVersion) { + return candidate; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } + return undefined; +} + +async function materializeOciModel(indexPath: string, workDirectory: string, canMove: boolean): Promise { + const ociRoot = dirname(indexPath); + const index = JSON.parse(await fs.readFile(indexPath, 'utf8')) as IOciIndex; + if (!index.manifests?.length) { + throw new Error('The selected dictation model package has no OCI manifest.'); + } + for (const descriptor of index.manifests) { + const manifest = JSON.parse((await readVerifiedOciBlob(ociRoot, descriptor.digest)).toString('utf8')) as IOciManifest; + const titledLayers = manifest.layers?.filter(layer => layer.annotations?.[OCI_TITLE_ANNOTATION]); + if (!titledLayers?.length) { + continue; + } + + let version: number | undefined; + const materializedRoot = join(workDirectory, 'materialized'); + for (const layer of titledLayers) { + const title = layer.annotations?.[OCI_TITLE_ANNOTATION]; + if (!title) { + continue; + } + const match = /^v(?[1-9]\d*)\/(?.+)$/.exec(title); + if (!match?.groups?.version || !match.groups.path) { + throw new Error(`Invalid model file path in the OCI package: ${title}`); + } + const layerVersion = Number(match.groups.version); + if (version !== undefined && version !== layerVersion) { + throw new Error('The selected dictation model package contains multiple model versions.'); + } + version = layerVersion; + + const pathSegments = match.groups.path.split('/'); + if (pathSegments.some(segment => !segment || segment === '.' || segment === '..' || segment.includes('\\'))) { + throw new Error(`Invalid model file path in the OCI package: ${title}`); + } + + const target = join(materializedRoot, `v${version}`, ...pathSegments); + await fs.mkdir(dirname(target), { recursive: true }); + const blob = resolveOciBlob(ociRoot, layer.digest); + await verifyOciBlob(blob); + if (canMove) { + await fs.rename(blob.path, target); + } else { + await fs.copyFile(blob.path, target); + } + } + + if (version !== undefined) { + const versionDirectory = join(materializedRoot, `v${version}`); + await assertMaterializedIdentity(versionDirectory, version); + await writeInferenceModel(versionDirectory, version); + return { version, versionDirectory, canMove: true }; + } + } + + throw new Error('The selected package does not contain a dictation model OCI payload.'); +} + +function resolveOciBlob(ociRoot: string, digest: string): { readonly path: string; readonly hash: string } { + const match = /^sha256:(?[a-fA-F0-9]{64})$/.exec(digest); + if (!match?.groups?.hash) { + throw new Error(`Unsupported OCI digest: ${digest}`); + } + const hash = match.groups.hash.toLowerCase(); + return { path: join(ociRoot, 'blobs', 'sha256', hash), hash }; +} + +/** Read a (small) OCI blob into memory and verify it against its content digest. */ +async function readVerifiedOciBlob(ociRoot: string, digest: string): Promise { + const blob = resolveOciBlob(ociRoot, digest); + const contents = await fs.readFile(blob.path); + if (createHash('sha256').update(contents).digest('hex') !== blob.hash) { + throw new Error('The selected model package is corrupt (checksum mismatch).'); + } + return contents; +} + +/** Verify a blob against its content digest by streaming it (no full buffering). */ +async function verifyOciBlob(blob: { readonly path: string; readonly hash: string }): Promise { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(blob.path)) { + hash.update(chunk as Buffer); + } + if (hash.digest('hex') !== blob.hash) { + throw new Error('The selected model package is corrupt (checksum mismatch).'); + } +} + +/** + * Interpret a Foundry Local `inference_model.json` `Name`. Returns the model + * version when the name identifies the supported nemotron CPU model, throws when + * it names a *different* model, and returns `undefined` when there is no name to + * check (a truly unlabeled package we accept on trust). + */ +function modelVersionFromName(name: string | undefined): number | undefined { + if (!name) { + return undefined; + } + const match = new RegExp(`^${MODEL_VARIANT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:(?[1-9]\\d*)$`).exec(name); + if (!match?.groups?.version) { + throw new Error(`The selected package is not the ${DEFAULT_LOCAL_TRANSCRIPTION_MODEL} CPU model.`); + } + return Number(match.groups.version); +} + +/** + * Guard the OCI path against mislabeling: if the materialized payload already + * carries an `inference_model.json`, it must identify the supported model at + * this version before we (re)write our own scanner metadata over it. A payload + * with no embedded identity is accepted on trust. + */ +async function assertMaterializedIdentity(versionDirectory: string, version: number): Promise { + let metadata: { Name?: string }; + try { + metadata = JSON.parse(await fs.readFile(join(versionDirectory, INFERENCE_MODEL_FILE), 'utf8')); + } catch { + return; + } + const named = modelVersionFromName(metadata.Name); + if (named !== undefined && named !== version) { + throw new Error('The selected package identifies a different model version than its files.'); + } +} + +async function findPreparedModel(root: string, canMove: boolean): Promise { + const inferenceModel = (await findNamedFiles(root, INFERENCE_MODEL_FILE))[0]; + if (inferenceModel) { + const metadata = JSON.parse(await fs.readFile(inferenceModel, 'utf8')) as { Name?: string }; + const version = modelVersionFromName(metadata.Name); + if (version === undefined) { + throw new Error(`The selected folder is not the ${DEFAULT_LOCAL_TRANSCRIPTION_MODEL} CPU model.`); + } + const versionDirectory = dirname(inferenceModel); + if (basename(versionDirectory) !== `v${version}`) { + throw new Error(`The model files must be stored in a v${version} folder.`); + } + return { version, versionDirectory, canMove }; + } + + const genaiConfig = (await findNamedFiles(root, GENAI_CONFIG_FILE))[0]; + if (genaiConfig) { + const versionDirectory = dirname(genaiConfig); + const match = /^v(?[1-9]\d*)$/.exec(basename(versionDirectory)); + if (!match?.groups?.version) { + throw new Error('The model files must be stored in a version folder such as v3.'); + } + return { version: Number(match.groups.version), versionDirectory, canMove }; + } + + throw new Error('The selected package does not contain a supported dictation model.'); +} + +async function verifyPreparedModel(versionDirectory: string): Promise { + const entries = await fs.readdir(versionDirectory, { withFileTypes: true }); + const hasOnnxModel = entries.some(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.onnx')); + if (!hasOnnxModel) { + throw new Error('The selected dictation model is missing its ONNX model files.'); + } + try { + JSON.parse(await fs.readFile(join(versionDirectory, GENAI_CONFIG_FILE), 'utf8')); + } catch { + throw new Error(`The selected dictation model has a missing or invalid ${GENAI_CONFIG_FILE}.`); + } +} + +async function installPreparedModel(model: IPreparedModel, cacheDir: string): Promise { + const publisherDirectory = join(cacheDir, MODEL_PUBLISHER); + await fs.mkdir(publisherDirectory, { recursive: true }); + const modelDirectoryName = `${MODEL_VARIANT}-${model.version}`; + const target = join(publisherDirectory, modelDirectoryName); + const staged = join(publisherDirectory, `.${modelDirectoryName}.staged-${randomUUID()}`); + const backup = join(publisherDirectory, `.${modelDirectoryName}.backup-${randomUUID()}`); + const stagedVersion = join(staged, `v${model.version}`); + + try { + if (model.canMove) { + await fs.mkdir(staged, { recursive: true }); + await fs.rename(model.versionDirectory, stagedVersion); + } else { + await copyDirectory(model.versionDirectory, stagedVersion); + } + await writeInferenceModel(stagedVersion, model.version); + await replaceDirectory(staged, target, backup); + } finally { + // Only the staging area is always safe to remove. The backup is owned by + // `replaceDirectory`: it is deleted there once the swap succeeds and is + // otherwise the sole surviving copy of a previously working model, so it + // must never be force-removed here (a failed rollback would lose it). + await fs.rm(staged, { recursive: true, force: true }); + } +} + +async function replaceDirectory(staged: string, target: string, backup: string): Promise { + let movedExisting = false; + try { + await fs.rename(target, backup); + movedExisting = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + try { + await fs.rename(staged, target); + } catch (error) { + if (movedExisting) { + await fs.rename(backup, target); + } + throw error; + } + + if (movedExisting) { + await fs.rm(backup, { recursive: true, force: true }); + } +} + +async function copyDirectory(source: string, target: string): Promise { + await fs.mkdir(target, { recursive: true }); + for (const entry of await fs.readdir(source, { withFileTypes: true })) { + const sourceEntry = join(source, entry.name); + const targetEntry = join(target, entry.name); + if (entry.isDirectory()) { + await copyDirectory(sourceEntry, targetEntry); + } else if (entry.isFile()) { + await fs.copyFile(sourceEntry, targetEntry); + } else { + throw new Error(`Unsupported model package entry: ${entry.name}`); + } + } +} + +async function writeInferenceModel(versionDirectory: string, version: number): Promise { + await fs.writeFile(join(versionDirectory, INFERENCE_MODEL_FILE), JSON.stringify({ + Name: `${MODEL_VARIANT}:${version}`, + PromptTemplate: null, + }, undefined, 2)); +} + +async function findNamedFiles(root: string, name: string): Promise { + const matches: string[] = []; + const directories = [root]; + while (directories.length) { + const directory = directories.pop()!; + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name); + if (entry.isDirectory()) { + directories.push(entryPath); + } else if (entry.isFile() && entry.name === name) { + matches.push(entryPath); + } + } + } + return matches.sort(); +} diff --git a/src/vs/platform/localTranscription/node/localTranscriptionService.ts b/src/vs/platform/localTranscription/node/localTranscriptionService.ts index 02303b89139..4f03794a170 100644 --- a/src/vs/platform/localTranscription/node/localTranscriptionService.ts +++ b/src/vs/platform/localTranscription/node/localTranscriptionService.ts @@ -13,21 +13,17 @@ import { ILocalTranscriptionModelStatus, ILocalTranscriptionResult, ILocalTranscriptionService, + DEFAULT_LOCAL_TRANSCRIPTION_MODEL, + ILocalTranscriptionModelImportResult, LocalTranscriptionModelState, } from '../common/localTranscription.js'; +import { importFoundryLocalModel } from './foundryLocalModelImport.js'; /** PCM audio format the renderer captures and streams: mono 16 kHz signed 16-bit. */ const SAMPLE_RATE = 16000; const CHANNELS = 1; const BITS_PER_SAMPLE = 16; -/** - * Default on-device model. `nemotron-speech-streaming-en-0.6b` is the NVIDIA - * Nemotron streaming RNN-T model the GitHub Copilot app ships for dictation; it - * runs through Foundry Local's native streaming ASR engine (ORT + ORT-GenAI). - */ -const DEFAULT_MODEL = 'nemotron-speech-streaming-en-0.6b'; - /** Application name reported to Foundry Local for logs/telemetry and its data dir. */ const FOUNDRY_APP_NAME = 'vscode-dictation'; @@ -270,6 +266,10 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans return this._status; } + importModel(options: { sourcePath: string; cacheDir: string }): Promise { + return importFoundryLocalModel(options.sourcePath, options.cacheDir); + } + private _setStatus(status: ILocalTranscriptionModelStatus): void { this._status = status; this._onDidChangeModelStatus.fire(status); @@ -298,7 +298,7 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans this._pendingChunks = []; this._runtimeError = undefined; - const model = options.model ?? DEFAULT_MODEL; + const model = options.model ?? DEFAULT_LOCAL_TRANSCRIPTION_MODEL; const language = options.language; // Do not block capture on the (possibly first-use) model download/load and // session open; buffer audio until the session is ready, then flush it. diff --git a/src/vs/platform/localTranscription/test/node/foundryLocalModelImport.test.ts b/src/vs/platform/localTranscription/test/node/foundryLocalModelImport.test.ts new file mode 100644 index 00000000000..caf55d1bd9d --- /dev/null +++ b/src/vs/platform/localTranscription/test/node/foundryLocalModelImport.test.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as fs from 'fs'; +import { createHash } from 'crypto'; +import { tmpdir } from 'os'; +import { join } from '../../../../base/common/path.js'; +import { zip } from '../../../../base/node/zip.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { getRandomTestPath } from '../../../../base/test/node/testUtils.js'; +import { DEFAULT_LOCAL_TRANSCRIPTION_MODEL } from '../../common/localTranscription.js'; +import { importFoundryLocalModel } from '../../node/foundryLocalModelImport.js'; + +suite('FoundryLocalModelImport', () => { + let testDirectory: string; + let cacheDirectory: string; + + ensureNoDisposablesAreLeakedInTestSuite(); + + setup(async () => { + testDirectory = getRandomTestPath(tmpdir(), 'vsctests', 'foundry-model-import'); + cacheDirectory = join(testDirectory, 'cache'); + await fs.promises.mkdir(testDirectory, { recursive: true }); + }); + + teardown(() => fs.promises.rm(testDirectory, { recursive: true, force: true })); + + test('imports a prepared model directory and creates scanner metadata', async () => { + const source = join(testDirectory, 'prepared', 'v4'); + await fs.promises.mkdir(source, { recursive: true }); + await fs.promises.writeFile(join(source, 'genai_config.json'), '{}'); + await fs.promises.writeFile(join(source, 'encoder.onnx'), 'model'); + + const staleTarget = modelVersionDirectory(cacheDirectory, 4); + await fs.promises.mkdir(staleTarget, { recursive: true }); + await fs.promises.writeFile(join(staleTarget, 'stale'), 'old'); + + const result = await importFoundryLocalModel(source, cacheDirectory); + const target = modelVersionDirectory(cacheDirectory, 4); + const metadata = JSON.parse(await fs.promises.readFile(join(target, 'inference_model.json'), 'utf8')); + + assert.deepStrictEqual({ + result, + files: (await fs.promises.readdir(target)).sort(), + metadata, + }, { + result: { model: DEFAULT_LOCAL_TRANSCRIPTION_MODEL, version: 4 }, + files: ['encoder.onnx', 'genai_config.json', 'inference_model.json'], + metadata: { + Name: `${DEFAULT_LOCAL_TRANSCRIPTION_MODEL}-generic-cpu:4`, + PromptTemplate: null, + }, + }); + }); + + test('imports the official nested expansion-pack OCI layout', async () => { + const packageZip = join(testDirectory, 'Package.zip'); + const configContents = '{}'; + const modelContents = 'model'; + const configDigest = digest(configContents); + const modelDigest = digest(modelContents); + const ociPrefix = 'payload/oci/models/foundry-local/nemotron/cpu-onnx'; + const manifest = { + layers: [ + { digest: configDigest, annotations: { 'org.opencontainers.image.title': 'v3/genai_config.json' } }, + { digest: modelDigest, annotations: { 'org.opencontainers.image.title': 'v3/encoder.onnx' } }, + ], + }; + const manifestContents = JSON.stringify(manifest); + const manifestDigest = digest(manifestContents); + await zip(packageZip, [ + { path: `${ociPrefix}/oci-layout`, contents: JSON.stringify({ imageLayoutVersion: '1.0.0' }) }, + { path: `${ociPrefix}/index.json`, contents: JSON.stringify({ manifests: [{ digest: manifestDigest }] }) }, + { path: `${ociPrefix}/blobs/sha256/${manifestDigest.slice('sha256:'.length)}`, contents: manifestContents }, + { path: `${ociPrefix}/blobs/sha256/${configDigest.slice('sha256:'.length)}`, contents: configContents }, + { path: `${ociPrefix}/blobs/sha256/${modelDigest.slice('sha256:'.length)}`, contents: modelContents }, + ]); + + const expansionPack = join(testDirectory, 'model.zip'); + await zip(expansionPack, [ + { path: 'Manifest.xml', contents: '' }, + { path: 'Package.zip', localPath: packageZip }, + ]); + + const result = await importFoundryLocalModel(expansionPack, cacheDirectory); + const target = modelVersionDirectory(cacheDirectory, 3); + + assert.deepStrictEqual({ + result, + files: (await fs.promises.readdir(target)).sort(), + }, { + result: { model: DEFAULT_LOCAL_TRANSCRIPTION_MODEL, version: 3 }, + files: ['encoder.onnx', 'genai_config.json', 'inference_model.json'], + }); + }); + + test('rejects OCI layer paths that escape the model version directory', async () => { + const source = join(testDirectory, 'oci'); + const modelContents = 'model'; + const modelDigest = digest(modelContents); + const manifestContents = JSON.stringify({ + layers: [{ + digest: modelDigest, + annotations: { 'org.opencontainers.image.title': 'v3/../outside.onnx' }, + }], + }); + const manifestDigest = digest(manifestContents); + await fs.promises.mkdir(join(source, 'blobs', 'sha256'), { recursive: true }); + await fs.promises.writeFile(join(source, 'oci-layout'), JSON.stringify({ imageLayoutVersion: '1.0.0' })); + await fs.promises.writeFile(join(source, 'index.json'), JSON.stringify({ manifests: [{ digest: manifestDigest }] })); + await fs.promises.writeFile(join(source, 'blobs', 'sha256', manifestDigest.slice('sha256:'.length)), manifestContents); + await fs.promises.writeFile(join(source, 'blobs', 'sha256', modelDigest.slice('sha256:'.length)), modelContents); + + await assert.rejects( + importFoundryLocalModel(source, cacheDirectory), + /Invalid model file path/, + ); + assert.strictEqual(fs.existsSync(join(cacheDirectory, 'Microsoft')), false); + }); + + test('rejects an OCI package whose embedded metadata names a different model', async () => { + const source = join(testDirectory, 'oci-wrong-model'); + const configContents = '{}'; + const inferenceContents = JSON.stringify({ Name: 'some-other-model-generic-cpu:3' }); + const configDigest = digest(configContents); + const inferenceDigest = digest(inferenceContents); + const manifestContents = JSON.stringify({ + layers: [ + { digest: configDigest, annotations: { 'org.opencontainers.image.title': 'v3/genai_config.json' } }, + { digest: inferenceDigest, annotations: { 'org.opencontainers.image.title': 'v3/inference_model.json' } }, + ], + }); + const manifestDigest = digest(manifestContents); + await fs.promises.mkdir(join(source, 'blobs', 'sha256'), { recursive: true }); + await fs.promises.writeFile(join(source, 'oci-layout'), JSON.stringify({ imageLayoutVersion: '1.0.0' })); + await fs.promises.writeFile(join(source, 'index.json'), JSON.stringify({ manifests: [{ digest: manifestDigest }] })); + await fs.promises.writeFile(join(source, 'blobs', 'sha256', manifestDigest.slice('sha256:'.length)), manifestContents); + await fs.promises.writeFile(join(source, 'blobs', 'sha256', configDigest.slice('sha256:'.length)), configContents); + await fs.promises.writeFile(join(source, 'blobs', 'sha256', inferenceDigest.slice('sha256:'.length)), inferenceContents); + + await assert.rejects( + importFoundryLocalModel(source, cacheDirectory), + new RegExp(`not the ${DEFAULT_LOCAL_TRANSCRIPTION_MODEL}`), + ); + assert.strictEqual(fs.existsSync(join(cacheDirectory, 'Microsoft')), false); + }); + + test('rejects an OCI package with a blob that fails its checksum', async () => { + const source = join(testDirectory, 'oci-corrupt'); + const configContents = '{}'; + const modelContents = 'model'; + const configDigest = digest(configContents); + const modelDigest = digest(modelContents); + const manifestContents = JSON.stringify({ + layers: [ + { digest: configDigest, annotations: { 'org.opencontainers.image.title': 'v3/genai_config.json' } }, + { digest: modelDigest, annotations: { 'org.opencontainers.image.title': 'v3/encoder.onnx' } }, + ], + }); + const manifestDigest = digest(manifestContents); + await fs.promises.mkdir(join(source, 'blobs', 'sha256'), { recursive: true }); + await fs.promises.writeFile(join(source, 'oci-layout'), JSON.stringify({ imageLayoutVersion: '1.0.0' })); + await fs.promises.writeFile(join(source, 'index.json'), JSON.stringify({ manifests: [{ digest: manifestDigest }] })); + await fs.promises.writeFile(join(source, 'blobs', 'sha256', manifestDigest.slice('sha256:'.length)), manifestContents); + await fs.promises.writeFile(join(source, 'blobs', 'sha256', configDigest.slice('sha256:'.length)), configContents); + // Content that does not hash to the digest used as its blob filename. + await fs.promises.writeFile(join(source, 'blobs', 'sha256', modelDigest.slice('sha256:'.length)), 'tampered'); + + await assert.rejects( + importFoundryLocalModel(source, cacheDirectory), + /corrupt/, + ); + assert.strictEqual(fs.existsSync(join(cacheDirectory, 'Microsoft')), false); + }); + + test('rejects prepared directories for other models', async () => { + const source = join(testDirectory, 'prepared', 'v3'); + await fs.promises.mkdir(source, { recursive: true }); + await fs.promises.writeFile(join(source, 'genai_config.json'), '{}'); + await fs.promises.writeFile(join(source, 'encoder.onnx'), 'model'); + await fs.promises.writeFile(join(source, 'inference_model.json'), JSON.stringify({ Name: 'other-model:3' })); + + await assert.rejects( + importFoundryLocalModel(source, cacheDirectory), + new RegExp(`not the ${DEFAULT_LOCAL_TRANSCRIPTION_MODEL}`), + ); + }); +}); + +function modelVersionDirectory(cacheDirectory: string, version: number): string { + return join( + cacheDirectory, + 'Microsoft', + `${DEFAULT_LOCAL_TRANSCRIPTION_MODEL}-generic-cpu-${version}`, + `v${version}`, + ); +} + +function digest(contents: string): string { + return `sha256:${createHash('sha256').update(contents).digest('hex')}`; +} diff --git a/src/vs/platform/quickinput/browser/quickInputActions.ts b/src/vs/platform/quickinput/browser/quickInputActions.ts index 6c705825036..b967d9fdeb3 100644 --- a/src/vs/platform/quickinput/browser/quickInputActions.ts +++ b/src/vs/platform/quickinput/browser/quickInputActions.ts @@ -217,8 +217,7 @@ KeybindingsRegistry.registerCommandAndKeybindingRule({ // All other kinds of Quick things handle Accept, except Widget. In other words, Accepting is a detail on the things // that extend IQuickInput ContextKeyExpr.notEquals(quickInputTypeContextKeyValue, QuickInputType.QuickWidget), - inQuickInputContext, - ContextKeyExpr.not('isComposing') + inQuickInputContext ), metadata: { description: localize('nonQuickWidget', "Used while in the context of some quick input. If you change one keybinding for this command, you should change all of the other keybindings (modifier variants) of this command as well.") }, handler: (accessor) => { diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 285b43e940e..f37d5cf4aa6 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -79,7 +79,7 @@ Automation run history stores the created session as a serialized URI. Its Open Manual automation runs announce that they started once session dispatch commits, while lifecycle tracking continues until completion, failure, cancellation, or timeout. -Automations use a discriminated target that is either workspace-backed or a workspace-less quick chat. The workspace dropdown owns both choices: selecting **No workspace** switches to the existing quick-chat provider/session-type catalog, while selecting a folder restores repository configuration. Workspace-less targets display and announce as `without a workspace` in the list and cannot carry folder, isolation, or branch configuration; workspace-backed targets require a folder, with Worktree isolation requiring its base branch. Ledger schema v3 persists this target union and migrates schema-v1/v2 flat records while preserving valid workspace-backed targets. A successful authoritative CAS updates in-memory state even when restored storage resets the revision counter, while lower-revision change notifications cannot roll observables backward. +Automations use a discriminated target that is either workspace-backed or a workspace-less quick chat. The workspace dropdown owns both choices: selecting **No workspace** switches to the existing quick-chat provider/session-type catalog, while selecting a folder restores repository configuration. Workspace-less targets display and announce as `without a workspace` in the list and cannot carry folder, isolation, or branch configuration; workspace-backed targets require a folder, with Worktree isolation requiring its base branch. The automation dialog suppresses its root outline for pointer focus while preserving keyboard-visible focus indication. Ledger schema v3 persists this target union and migrates schema-v1/v2 flat records while preserving valid workspace-backed targets. A successful authoritative CAS updates in-memory state even when restored storage resets the revision counter, while lower-revision change notifications cannot roll observables backward. The Agents window contributes a built-in **Automations** client-tool set with `listAutomations`, `configureAutomation`, `runAutomation`, and `deleteAutomation`. Listing is read-only and returns stable IDs plus editable fields. Configuration uses the invoking session as the default target for new entries and follows the normal tool-approval policy: calls that require interaction show standard tool confirmation, while auto-approved calls proceed directly. Both paths validate and commit through `IAutomationService`, and successful creates and updates return a clickable chat result that opens the affected automation. `runAutomation` uses the same approval policy, starts a manual run through `IAutomationRunner` even when scheduled runs are disabled, and returns after dispatch with the run and session identifiers while lifecycle tracking continues in the background; an already-active run or unavailable target is reported without claiming a new run started. A run slot is claimed atomically: `recordRunStart` re-checks for an active run inside the same CAS that appends the pending run, so concurrent manual triggers from agents, the **Run now** button, or separate windows cannot both start the same automation, and only the caller that wins the swap dispatches a session. Manual workspace choices in the automation dialog never update the new-session recent-workspace list. Deletion uses **Delete**/**Cancel** confirmation when required, removes the automation and retained run history, and lets already-dispatched sessions continue. Denial, invalid IDs, stale confirmed updates, and cancellation or disablement observed by the mutation guard leave the ledger unchanged. The guard runs immediately before every CAS attempt; once an atomic CAS starts, concurrent cancellation or disablement cannot revoke a committed write, and the tool reports that commit as successful. @@ -140,6 +140,8 @@ The shared plugin discovery pipeline selects format-specific component paths whi Runtime projection is provider-specific. Copilot receives strict skills and MCP explicitly rather than through legacy SDK plugin-directory discovery. Codex receives strict skill roots plus MCP, with remote transport selected by its existing auto-detection. Claude excludes strict packages from legacy plugin discovery and can project remote MCP through its existing auto-detection, but its current SDK cannot register external skill directories or provide the per-server working directory required by strict stdio MCP, so those components are reported and skipped. +Claude Agent Host multi-root customization discovery is gated by the hidden, default-off `chat.agentHost.claudeAgent.multiRootEnabled` setting. When enabled, the primary working directory and each SDK `additionalDirectories` root contribute standalone `.claude/agents`, `.claude/skills`, and native plugin enablement to the Customizations editor. Roots are processed in session order, followed by user scope; same-named standalone agents or skills use the first visible definition as the display source. This display policy is centralized because the SDK reports standalone entries by name rather than source URI. Native plugin loaded state remains authoritative from the SDK snapshot. Rules, hooks, MCP configuration, commands, and CLAUDE.md remain primary-root/user scoped because Claude additional directories do not load those configuration types. Each contributing root has its own writable directory container, and secondary-root watchers observe only agents, skills, and plugin settings. + ### IHarnessDescriptor Key properties on the harness descriptor: diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 5f8fe05ed39..a0a636a2c3f 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -37,6 +37,7 @@ Editors open as modal overlays via `ModalEditorPart`. The main editor part exist | Titlebar | Top, full width | Always visible | Session picker, toggle actions, account widget | | Sidebar | Left, below titlebar | Visible | Sessions list | | Sessions Part | Center of right section | Visible | Grid of one or more session views (each rendering the active chat of its session) | +| Custom View Grid | Same row as the Sessions Part | Hidden | Grid of custom views shown *instead of* the Sessions Part — see [§2.4](#24-custom-view-grid) | | Editor | In grid, beside Sessions Part | Hidden | Shown for explicit editor workflows | | Auxiliary Bar | Right side | Visible | Changes view, file tree | | Panel | Below Sessions Part + Aux Bar | Hidden | Terminal, debug output | @@ -52,7 +53,8 @@ Orientation: VERTICAL (root) ├── Top Right (HORIZONTAL) │ ├── Sessions Part (leaf, remaining width) │ ├── Editor (leaf, hidden by default) - │ └── Auxiliary Bar (leaf, 340px default) + │ ├── Auxiliary Bar (leaf, 340px default) + │ └── Custom View Grid (leaf, hidden by default) └── Panel (leaf, 300px default, hidden) ``` @@ -62,6 +64,8 @@ The **Sessions Part is the flexible ("remaining width") view** in the top-right The Sessions Part-to-Editor gap and the gap above the bottom Panel share `AGENTS_FLOATING_PANEL_GAP` in TypeScript layout and its registered CSS token, `--vscode-agents-layout-floatingPanelGap`. Their grid sashes keep the split boundaries unchanged, but expand and shift their hit areas to fill those visual gaps exactly. Each shows the standard persistent three-dot gripper at rest and yields to the full sash highlight while hovered or dragged. The Auxiliary Bar's leading padding and part-internal sashes retain their independent geometry. +Editor-content overlays must use the editor pane container rather than the editor-group root. In the single-pane layout, the group spans both the editor and the docked detail panel while the pane container is inset to the editor's actual bounds; anchoring feedback controls such as the Submit toolbar to the group would place them over the detail panel. + ### 2.3 Layout Priority Model The workbench grid is built with `proportionalLayout: false` (see `createWorkbenchLayout()` in [browser/workbench.ts](src/vs/sessions/browser/workbench.ts)). In this mode the split views do **not** distribute resize deltas proportionally — instead each delta (window resize, or a part being shown/hidden) is absorbed by the highest-`LayoutPriority` view, while the others keep their established sizes. Each part therefore declares an explicit `priority`: @@ -72,6 +76,7 @@ The workbench grid is built with `proportionalLayout: false` (see `createWorkben | Sessions Part | **`High`** | The single flexible view — grows/shrinks to absorb every horizontal delta. `minimumWidth` 300, `maximumWidth` ∞. | | Editor | `Normal` | Keeps its user-set width (`600` default); only resized via its own sash. | | Auxiliary Bar | `Low` | Keeps its user-set width (`340` default); only resized via its own sash. | +| Custom View Grid | **`High`** | Claims the whole row. Never visible at the same time as the Sessions Part, so the "exactly one `High` view" invariant below still holds. | In the single-pane detail-panel layout, first-run sidebar width is slightly narrower (280px) so a typical window keeps roughly balanced chat and third-pane widths when the pane is shown. Persisted `_savedPartSizes` always win over these defaults. @@ -79,6 +84,24 @@ In the single-pane detail-panel layout, first-run sidebar width is slightly narr > **Pitfall:** the `High` role must live on the Sessions Part, not the editor. It was previously on the editor, but that made the editor drift to its 300px minimum when the auxiliary bar was toggled across session switches. When moving the role, set the Sessions Part to `High` **and** the editor to `Normal` together — removing `High` from the editor without adding it to the Sessions Part leaves the chain with no `High` view and reintroduces the growing-sidebar bug. +### 2.4 Custom View Grid + +The Custom View Grid (`CustomViewGridPart` in [browser/parts/customViewGridPart.ts](src/vs/sessions/browser/parts/customViewGridPart.ts)) hosts full-surface views that replace the sessions grid — for example a management or dashboard surface that is not tied to a single session. + +**Contract — it is mutually exclusive with the sessions surface.** While a custom view is shown, the Sessions Part, the Editor part *in the grid*, the Auxiliary Bar (side panel) and the Panel (terminal) are all hidden, and vice versa. Only the titlebar and the primary sidebar remain. The *modal* editor part is not affected and may still open over the custom view. + +Which view is shown is owned by `ICustomViewService` ([services/customView/browser/customViewService.ts](src/vs/sessions/services/customView/browser/customViewService.ts)): contributions register an `ICustomViewDescriptor` (id, title, view constructor and optional header actions) and call `showCustomView(id)` / `hideCustomView()`. The workbench observes `activeCustomView` and applies the layout; it is not persisted, so a reload always starts on the sessions grid. + +**Desired vs. effective visibility.** The covered parts keep their *desired* visibility in `partVisibility` — showing a custom view only changes what the grid renders (`Workbench._effectiveVisible`). So a layout-controller change made while the custom view is shown (e.g. the user opened a different session in the background) is what gets restored when it is hidden, and `_savePartVisibility` never records the forced-hidden state. `IWorkbenchLayoutService.isVisible` reports the effective value and `onDidChangePartVisibility` fires for the parts whose effective visibility flips, so context keys stay truthful; the layout controller's per-session capture listeners skip those transitions (`_isCustomViewVisible`). + +> **Pitfall:** `SplitView` calls `Part.setVisible` when a view's grid visibility changes, and the workbench maps that event straight back onto the desired visibility (`setSessionsHidden`, `setPanelHidden`, …). The custom view's grid updates therefore run under `_applyingCustomViewGridVisibility`, which makes that listener bail — without it, hiding the parts for a custom view *overwrites* the state that is supposed to be restored, and hiding the custom view leaves neither grid visible. For the same reason, showing a custom view first exits a maximized editor (a maximized editor owns the row instead of the sessions grid) and the grid descriptor is built from the effective values. + +**Dismissal.** Opening a session (`SessionsService._startOpenSession`, which every explicit open gesture funnels through) hides the custom view. On phone layouts showing one pushes a `MobileNavigationStack` layer, so the Android back button dismisses it. Actions that operate on the hidden parts — Toggle Side Panel, Open Terminal, and the secondary side bar toggle — are disabled while it is shown (`CustomViewVisibleContext`). + +**Chrome.** Each grid leaf is a `CustomViewNode` ([browser/parts/customViewNode.ts](src/vs/sessions/browser/parts/customViewNode.ts)) that owns the shared header — title, optional description and the contributed actions rendered either as an icon toolbar or a button bar — above a scroll container that grows a bottom border on the header as soon as the content is scrolled. The header band and the content are centred and capped to `AGENTS_CENTERED_CONTENT_MAX_WIDTH` (the same measure the session views use); a view may override it with `AbstractCustomView.maxWidth`. Views only fill the content container and are disposed when hidden. On phone-class viewports `CustomViewGridParts` selects `MobileCustomViewGridPart` instead, mirroring `SessionsParts`/`MobileSessionsPart`. + +**Card chrome is shared.** The Sessions Part and the Custom View Grid both carry the `agents-part-card` class (`AGENTS_PART_CARD_CLASS`) and use `agentsPartCard.ts` for their metrics, themed colors and content-box math, so their padding, margins, background, border and corner radius are defined once and are identical. + --- ## 3. Titlebar @@ -142,7 +165,7 @@ The Sessions Part (`SessionsPart` in [browser/parts/sessionsPart.ts](src/vs/sess A `SessionView` ([browser/parts/sessionView.ts](src/vs/sessions/browser/parts/sessionView.ts)) is a single leaf in the Sessions Part's internal grid. It hosts: -- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (the contributed workspace folder / changes / pull request buttons), and the session toolbars (Run, Open in VS Code, New Chat). The status icon ([browser/sessionStatusIcon.ts](src/vs/sessions/browser/sessionStatusIcon.ts)) shows the live spinner/status glyph for in-progress / needs-input / error states; in terminal/default states the title shows the read/unread **dot indicator** (filled link-colored dot when unread, small muted dot when read) — neither the session type icon nor the PR icon is shown in the title, since the pull request is surfaced in the meta row instead. (The status icon's `completedStateIcon` argument is generic: the header passes nothing so it falls back to the dot indicator, while the sessions list still passes the PR icon.) The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; by default each contributed action renders as a consistent compact secondary `Button` with an inline `icon title` label via `SessionHeaderMetaActionViewItem` ([browser/parts/sessionHeaderMetaActionViewItem.ts](src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts)) unless it registers its own action view item (spacing between the pills comes from the meta row's `gap`, no separator dot). The files view contributes the workspace folder pill (order -10, so it leads the row, gated by the per-view `SessionHasWorkspaceContext` key which `SessionView` sets when the session has a workspace label, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the workspace icon — cloud / folder / worktree per workspace kind, where a session whose isolated worktree is still being created (`ISession.worktreePending`) already shows the worktree icon — plus the workspace label, and a hover showing the working-directory path and git branch (replaced by a "Creating worktree…" note while the worktree is pending, since the reported folder and branch are still those of the checkout the session was started from), registered from `contrib/files/browser/workspaceFolderActions.ts`) that, when activated, opens the Files view. The changes view contributes the diff stats as a clickable menu item (order 0, gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's **Branch Changes** changeset, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the diff-multiple icon, a `{n} files` label, and the live `+insertions -deletions` counts, registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The pill always reflects the **Branch Changes** changeset (the branch-vs-base diff) — located in `IActiveSession.changesets` by the shared `BRANCH_CHANGES_CHANGESET_ID` (`services/sessions/common/session.ts`), falling back to `IActiveSession.changes` when absent — so it is independent of whichever changeset the Changes view currently has selected. While a session's isolated worktree is still being created (`ISession.worktreePending`) the key stays `false`, so the checkout's own changes are never attributed to the session. The GitHub contribution similarly contributes a pull request button (order 1, so it follows the changes button) showing the PR icon + `#` (gated by the per-view `SessionHasPullRequestContext` key, which `SessionView` sets from the session's GitHub info, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the live `#` as its label, registered from `contrib/github/browser/pullRequestActions.ts`) that, when activated, opens the pull request on GitHub; its leading icon reads `gitHubInfo.pullRequest.icon` and renders its themed color (set as an inline `color` with `!important` priority) so the glyph reflects the live PR state; its hover is owned by the GitHub contribution and shows the repository link/date, PR title, up to three lines of description, and target/source branch pills. Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. +- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (the contributed workspace folder / changes / pull request buttons), and the session toolbars (Run, Open in VS Code, New Chat). The status icon ([browser/sessionStatusIcon.ts](src/vs/sessions/browser/sessionStatusIcon.ts)) shows the live spinner/status glyph for in-progress / needs-input / error states; in terminal/default states the title shows the read/unread **dot indicator** (filled link-colored dot when unread, small muted dot when read) — neither the session type icon nor the PR icon is shown in the title, since the pull request is surfaced in the meta row instead. (The status icon's `completedStateIcon` argument is generic: the header passes nothing so it falls back to the dot indicator, while the sessions list still passes the PR icon.) The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; by default each contributed action renders as a consistent compact secondary `Button` with an inline `icon title` label via `SessionHeaderMetaActionViewItem` ([browser/parts/sessionHeaderMetaActionViewItem.ts](src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts)) unless it registers its own action view item (spacing between the pills comes from the meta row's `gap`, no separator dot). The files view contributes the workspace folder pill (order -10, so it leads the row, gated by the per-view `SessionHasWorkspaceContext` key which `SessionView` sets when the session has a workspace label, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the workspace icon — cloud / folder / worktree per workspace kind, where a session whose isolated worktree is still being created (`ISession.worktreePending`) already shows the worktree icon — plus the workspace label, and a hover showing the working-directory path and git branch (replaced by a "Creating worktree…" note while the worktree is pending, since the reported folder and branch are still those of the checkout the session was started from), registered from `contrib/files/browser/workspaceFolderActions.ts`) that, when activated, opens the Files view. The changes view contributes the diff stats as a clickable menu item (order 0, gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's **Branch Changes** changeset, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the diff-multiple icon, a `{n} files` label, and the live `+insertions -deletions` counts, registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The pill always reflects the **Branch Changes** changeset (the branch-vs-base diff) — located in `IActiveSession.changesets` by the shared `BRANCH_CHANGES_CHANGESET_ID` (`services/sessions/common/session.ts`), falling back to `IActiveSession.changes` when absent — so it is independent of whichever changeset the Changes view currently has selected. While a session's isolated worktree is still being created (`ISession.worktreePending`) the key stays `false`, so the checkout's own changes are never attributed to the session. The GitHub contribution similarly contributes a pull request button (order 1, so it follows the changes button) showing the PR icon + `#` (gated by the per-view `SessionHasPullRequestContext` key, which `SessionView` sets from the session's GitHub info, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the live `#` as its label, registered from `contrib/github/browser/pullRequestActions.ts`) that, when activated, opens the pull request on GitHub; its leading icon reads `gitHubInfo.pullRequest.icon` and renders its themed color (set as an inline `color` with `!important` priority) so the glyph reflects the live PR state; its hover is owned by the GitHub contribution and shows the repository link/date, PR title, up to three lines of description, and target/source branch pills. The same contribution adds an issue button (order 2, so it follows the pull request button) for the GitHub issues the session's user messages referenced (gated by the per-view `SessionHasIssuesContext` key, registered from `contrib/github/browser/issueActions.ts`): a single issue renders as `#` and hovers to the issue title/description, while several render as ` issues` and open a sticky picker listing each issue on click; the leading icon reflects the aggregate live issue state (open green, closed-as-completed purple, closed as not planned/duplicate muted). Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. - A **chat composite bar** below the header ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) — the chat tab strip. Visibility tracks the number of **visible tabs** (`IActiveSession.visibleChatTabs`): it is shown only when the session has **more than one chat actually showing as a tab**, and always hidden when there is just one visible tab — even if other chats are **closed**, the single chat's **title diverged** from the session title, or the session has unopened subagents. User-created peer chats, including `/btw` side chats, participate in this ordinary tab model; tool-origin subagents stay hidden until explicitly opened. This rule is a single shared observable `IActiveSession.shouldShowChatTabs` ([services/sessions/browser/visibleSessions.ts](src/vs/sessions/services/sessions/browser/visibleSessions.ts)), read by both the composite bar and the `SessionShouldShowChatTabsContext` context key. The strip's own trailing **New Chat** action follows this visibility. The header's **New Chat** action is shown while the tab strip is hidden (a single visible tab); once the strip is shown the strip's trailing **New Chat** action offers it instead. The **Chats** (Conversations) menu is always rendered in the session header **meta row**, at the end of the pills (`Menus.SessionHeaderMeta`, order 100), independent of the tab strip's visibility — it appears once the session has more than one **committed (non-draft)** chat, or when the active chat has subagents. It renders as the meta toolbar's default submenu **icon** (the comment-discussion glyph), and clicking it opens the submenu as a dropdown. While the tab strip is shown the chat tabs are keyboard-navigable from the active session: `Ctrl/Cmd+Shift+]` / `Ctrl/Cmd+Shift+[` go to the next / previous chat (wrapping), `Ctrl/Cmd+W` closes the active chat tab (deleting an in-composer draft, hiding a committed chat) instead of the session — the same command (`sessions.chatCompositeBar.closeChat`) is contributed to the per-tab `Menus.SessionChatTab`, which the chat tab strip renders as each non-main tab's close button (forwarding the tab's chat as the action argument), and `Ctrl+Tab` / `Ctrl+Shift+Tab` open a **chat switcher** — a no-input, editor-switcher (MRU) quick pick over the session's **open** chats (skipping in-composer drafts), each shown with a chat icon (hold the modifier, press `Tab` to cycle, release to select), winning over the session-history secondary on that chord while the session has multiple open chats and falling back to session navigation otherwise (and to the editor's own `Ctrl+Tab` switcher while a quick pick is already open, since the open chords are gated on `inQuickOpen` negated); the **Go to Chat in Session** palette command (`sessions.showChatsPicker`, `Ctrl/Cmd+Shift+O`, gated on more than one committed chat) opens a **searchable** variant that additionally lists **Closed** chats in a separate group (selecting one reopens it) — these commands (`sessions.chatCompositeBar.navigateNextChat` / `navigatePreviousChat` / `closeChat` and `sessions.showChatsPicker` in `contrib/sessions/browser/sessionsActions.ts`) outrank the session-level navigation/close chords via a higher keybinding weight. Chat-to-chat navigation (next/previous chat and the `Ctrl+Tab` switcher) is gated on `SessionHasMultipleOpenChatsContext` (more than one **open** tab) — distinct from the broader `SessionShouldShowChatTabsContext` that drives strip visibility — so it stays a no-op when only a single open chat remains (e.g. one open + one closed chat); `closeChat` is gated on `SessionActiveChatIsClosableContext`, and the searchable palette command on `SessionHasMultipleCommittedChatsContext`. - A **chat view** below the bars, swapped in/out based on session state. - A floating toolbar overlay ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts), `SessionViewFloatingToolbar`) shown for not-yet-created sessions in place of the header. @@ -227,7 +250,7 @@ The main editor part can be explicitly revealed for workflows that target it dir The entire third-pane redesign is gated behind the experimental setting `sessions.layout.singlePaneDetailPanel`, read **once at startup** (a window reload applies a change). When the setting is **off** (default) the Agents window renders exactly as documented above (auxiliary bar as its own grid column with its composite tab strip + title, the standard multi-diff Changes editor). When **on**, the third pane becomes a **single pane with one full-width tab bar**: - The auxiliary bar is removed from the workbench grid and **docked inside the editor part** (absolutely positioned on the right, below the editor tab strip); the grid's top-right row becomes `Sessions | Editor`, and the editor part spans the editor + detail-panel width. -- The editor group's **title/tab strip spans the full width** while its content is inset on the right by the detail-panel width, via the concrete `EditorPart.setContentRightInset(px)` method (`EditorPart`/`EditorGroupView`; not on the `IEditorPart` interface; `0` = no-op for all other layouts). +- The editor group's **tab strip spans the full width** while its breadcrumbs and editor content are inset on the right by the detail-panel width, via the concrete `EditorPart.setContentRightInset(px)` method (`EditorPart`/`EditorGroupView`; not on the `IEditorPart` interface; `0` = no-op for all other layouts). The detail panel is always docked on the right, so no left margin is needed. - A **full-width header** sits below the tab bar, spanning the editor content and the docked detail panel, and hosts contributed actions. **The header menus are a group-level configuration; opting in is per-editor.** An editor part configures its groups with optional menu ids via `IEditorGroupViewOptions.menuIds` (`{ headerPrimary, headerSecondary, editorActions, tabsBarContext }`) — the core `EditorGroupView` never references any concrete menu point, it just renders whatever menu ids it was constructed with. `EditorPart.getGroupViewOptions()` is a protected hook (default `undefined`) that supplies these options to every group the part creates; `SinglePaneMainEditorPart` overrides it to return `Menus.SessionsEditorHeaderPrimary` / `Menus.SessionsEditorHeaderSecondary` / `Menus.SessionsEditorTitle` / `Menus.SessionsEditorTabsBarContext` (all defined in the sessions layer's shared menu registry, `browser/menus.ts`, not in core `platform/actions`). A header only renders while the **active editor opts in** via `IEditorPane.getHeaderActions()`, which returns just `{ instantiationService }` (the editor-scoped instantiation service so the header actions' `when` clauses evaluate in the editor's context) or `undefined` for no header; `EditorGroupView._renderEditorHeader` (run on every active-editor change) renders the group's configured menus as leading/trailing `MenuWorkbenchToolBar`s (`.editor-group-header-primary` / `.editor-group-header-secondary`, wrap-reversed so trailing actions float up) using that scoped service, hiding the whole header while both menus are empty. The header is a **real flow row inside the editor group** — `EditorGroupView` renders an optional `.editor-group-header` between its `.title` (tabs) and `.editor-container`, and **owns the header rendering and sizing**: the internal `setHeaderContent(render)` creates the inner content element, runs the render callback, and keeps the row **auto-sized to the content** via a `ResizeObserver` (wrapping and growing as needed, firing `onDidChangeHeaderHeight`); `headerHeight` exposes the reserved height. The group lays it out in flow (no absolute positioning) and shifts the editor pane down by its height. `SinglePaneMainEditorPart` renders no header DOM; it only offsets the docked auxiliary bar + sash down by `group.headerHeight` (`IDockedAuxiliaryBarHost.getHeaderHeight()`, re-applied on `onDidChangeHeaderHeight` via `_registerGroupHeader()`). The **Changes editor** (`SessionChangesEditor`) implements `getHeaderActions()` in single-pane (returning its scoped instantiation service), so the group renders `Menus.SessionsEditorHeaderPrimary` (to the left, `navigation` group: the *Branch Changes* dropdown, then the diff-stats action — the same clickable "+X -Y" pill (`VIEW_SESSION_CHANGES_COMMAND_ID`, rendered by `ChangesDiffStatsActionItem`) used by the classic Changes view header, always shown regardless of whether the editor area is visible or collapsed and opening/re-opening the Changes editor on click — then a separate `1_codeReview` group (separator before it) with *Run Code Review*, shown only when `SessionHasChangesContext` is true) and `Menus.SessionsEditorHeaderSecondary` (to the right, all inline unless overflowed: a `1_diff` group with collapse/expand + *Show Side by Side Diff* / *Show Inline Diff* (mutually exclusive by render mode); the sentinel `secondary` group — *View as List/Tree* — falls into the toolbar's overflow "…" menu) for the Changes tab only. The **Create Pull Request** button bar (`ChangesActionsBar`) is hosted in the editor tabs title: a header anchor action (`CHANGES_HEADER_ACTIONS_ID`, registered in `changesViewActions.ts`, contributed to `Menus.SessionsEditorTitle` group `navigation` order 5 and gated on the active Changes editor, top-right editor group, main window, dock-detail-panel setting, and `SessionHasChangesContext`) is rendered by the editor group's title actions. Its custom view item is supplied by `SessionChangesEditor.getActionViewItem()` as `ChangesActionsBarActionViewItem`, and the CSS makes the editor-actions side shrink to 50px before the tab scroller shrinks; split-button labels ellipsize while the dropdown segment stays visible. It hides entirely when its `AgentsChangesToolbar` menu has no actions. (In the classic non-single-pane layout the same `ChangesActionsBar` is still rendered inside `SessionChangesEditor`'s internal header.) The header-primary custom action view items (picker, diff-stats pill) are registered globally by `(menuId, actionId)` via `IActionViewItemService` in `ChangesEditorHeaderContribution` (`contrib/changes/browser/changesView.ts`), so the group's generic menu toolbars resolve them. The same *Branch Changes* picker and diff-stats actions are also contributed to the classic aux-bar Changes view menus (`ChatEditingSessionChangesFileHeaderToolbar` / `…RightToolbar`), which that view renders with its own action view items — so the two surfaces stay independent. - A vertical **sash** on the left edge of the docked panel resizes it (`DockedAuxiliaryBarController` in `browser/dockedAuxiliaryBarController.ts` owns `layout()` / `_ensureSash()`, created/driven by `SinglePaneMainEditorPart`). The preferred first-open width is 300px; explicit user resizes persist via the part-sizes snapshot. While the panel is visible it clamps to `[220px, editorWidth - 300px]`; dragging the raw sash width down to ~0 hides the docked detail panel, leaving the editor content visible. Temporary width growth from collapsing the sessions list is restored before persistence and must not become the user's detail width. - Collapsing the sessions list transfers the freed sidebar width to the editor grid node when the editor content is **visible**, and to the **detail panel** (`_dockedAuxiliaryBarWidth`, with the editor node kept equal to it) when the editor content is **hidden** (detail-only). Reopening the sessions list restores the pre-collapse editor-node width / detail width. Keeping the hidden-editor node equal to the detail width ensures the width-based reveal-sync never mistakes a wide detail-only node for a revealed editor. diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index cd8c20f783a..84540432467 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -167,7 +167,7 @@ In the agent host, the real producer of read-only chats is **subagent (worker) c Subagent chats **persist** in the session catalog after the subagent completes (completion only marks the chat's turn complete; the chat is removed only when the whole session is disposed), so the read-only tab stays reviewable for the lifetime of the session. -**Opening a subagent chat from the transcript.** The inline subagent block (`ChatSubagentContentPart`) renders a small pill (`OpenSubagentChatActionViewItem`) that reveals the subagent's read-only tab. The provider action stays disabled and out of visual and accessibility layout until its resource resolves to a surfaced peer chat, preventing a transient generic action while the chat catalog hydrates. `ChatSubagentContentPart` switches to `chat-subagent-open-chat-only` mode only while `MenuId.ChatSubagentContent` contains an enabled action; an unresolved or stale action therefore restores the normal collapsible subagent surface rather than leaving a blank row. It re-tracks the toolbar action when either the menu or its custom action-view registration changes, because late registration replaces the action instance whose enabled state drives this mode. The custom action view mirrors its resolved enabled state onto both its rendering proxy and the original menu action, which is the typed signal the shared subagent part observes to suppress the legacy surface. The pill is a control-tier chip, not a fully rounded capsule, and is a sibling of the shared collapse button at the start of the header row. Do not rely on Agents-window CSS ancestry for this switch: the shared chat widget can be hosted through different DOM roots. The wider pill gives the subagent chat's own title priority as the leading label, with quiet, width-capped inline model metadata that is shown by default and hidden only when the child turn's model matches the parent chat's selected model; canonical ids and registered display names are treated as equivalent, and an unresolved parent model still shows the metadata (a match cannot be established, so the model is surfaced). No duplicate agent-name phrase appears beside it. While the subagent is active, one single-line row attached below the pill shows the newest child tool with the same registered or inferred compact icon used by shared thinking-tool rendering. The row subtracts its left inset from its available width so its margin box remains inside the pill. Terminal tools prefer the protocol's dedicated intention over their raw command invocation message; other tools use the SDK/provider-authored invocation message (falling back to the display name in the Agent Host adapter). The row uses shared chat markdown and file-widget rendering, so file references and inline commands retain the same rich tool presentation as editor chat; it reserves a fixed minimum line slot so swapping among text, code, and file chips does not shift surrounding content. Newer tool intents replace it with the rotating-placeholder wipe/shimmer transition whose phases follow the actual CSS animation lifecycle rather than duplicated delays; changes that cancel the animation or environments without animation support settle immediately. The effective `workbench.reduceMotion` preference controls both this transition and the pending-confirmation pulse, so forced motion overrides are honored and a preference change during a transition swaps immediately. Status uses the shared pixel spinner: the grid/dropper variant for `InProgress`, the ring variant for `NeedsInput`, and the conversation icon when complete. Normal progress remains neutral—the spinner is sufficient. A subagent with a queued confirmation gets the subtle sessions-list warning background pulse; the subagent whose confirmation is currently active above the input gets the stronger warning border/background. A numeric warning badge is shown only for two or more pending confirmations; one confirmation keeps the warning state without a redundant `1`. The carousel publishes its active subagent id for this presentation state only; confirmation ownership/routing is unchanged. The carousel's subagent reference remains scroll-to-context in regular/editor chat, but in the Agents window it invokes `workbench.action.chat.openAgentHostChat` to open the related read-only chat. A quiet italic duration sits outside the border: `Working for 10s` while active and `Worked for 10s` after completion. It uses tabular figures so once-per-second digit changes do not shift the surrounding label. Timing starts from the child chat's actual first `activeTurn.startedAt`, updates once per second while active, and freezes from the completed turn duration. Those values are copied onto serialized subagent tool data so stopping or reloading cannot reset the display. `ChatSubagentContentPart` publishes timing, confirmation count, active-confirmation state, model name, and latest active tool label/icon through the toolbar action context. The subagent chat resource is carried to the widget on `IChatSubagentToolInvocationData.chatResource` (populated in `stateToProgressAdapter` from `ToolResultSubagentContent.resource`). Because the chat widget is provider-agnostic and lower-layer, the link invokes `workbench.action.chat.openAgentHostChat` with the subagent chat URI; the sessions layer handler derives the chat id, finds the matching surfaced peer across visible sessions, and calls `sessionsService.openChat` to activate the existing tab. +**Opening a subagent chat from the transcript.** The inline subagent block (`ChatSubagentContentPart`) renders a small pill (`OpenSubagentChatActionViewItem`) that reveals the subagent's read-only tab. The provider action stays disabled and out of visual and accessibility layout until its resource resolves to a surfaced peer chat, preventing a transient generic action while the chat catalog hydrates. `ChatSubagentContentPart` switches to `chat-subagent-open-chat-only` mode only while `MenuId.ChatSubagentContent` contains an enabled action; an unresolved or stale action therefore restores the normal collapsible subagent surface rather than leaving a blank row. Each subagent resolves the static menu action with a one-shot menu snapshot and creates a plain toolbar only after the custom action-view factory is available; if registration is late, it waits with a one-time listener rather than keeping a menu/context-key listener per transcript item. The custom action view mirrors its resolved enabled state onto both its rendering proxy and the original menu action, which is the typed signal the shared subagent part observes to suppress the legacy surface. The pill is a control-tier chip, not a fully rounded capsule, and is a sibling of the shared collapse button at the start of the header row. Do not rely on Agents-window CSS ancestry for this switch: the shared chat widget can be hosted through different DOM roots. The wider pill gives the subagent chat's own title priority as the leading label, with quiet, width-capped inline model metadata that is shown by default and hidden only when the child turn's model matches the parent chat's selected model; canonical ids and registered display names are treated as equivalent, and an unresolved parent model still shows the metadata (a match cannot be established, so the model is surfaced). No duplicate agent-name phrase appears beside it. While the subagent is active, one single-line row attached below the pill shows the newest child tool with the same registered or inferred compact icon used by shared thinking-tool rendering. The row subtracts its left inset from its available width so its margin box remains inside the pill. Terminal tools prefer the protocol's dedicated intention over their raw command invocation message; other tools use the SDK/provider-authored invocation message (falling back to the display name in the Agent Host adapter). The row uses shared chat markdown and file-widget rendering, so file references and inline commands retain the same rich tool presentation as editor chat; it reserves a fixed minimum line slot so swapping among text, code, and file chips does not shift surrounding content. Newer tool intents replace it with the rotating-placeholder wipe/shimmer transition whose phases follow the actual CSS animation lifecycle rather than duplicated delays; changes that cancel the animation or environments without animation support settle immediately. The effective `workbench.reduceMotion` preference controls both this transition and the pending-confirmation pulse, so forced motion overrides are honored and a preference change during a transition swaps immediately. Status uses the shared pixel spinner: the grid/dropper variant for `InProgress`, the ring variant for `NeedsInput`, and the conversation icon when complete. Normal progress remains neutral—the spinner is sufficient. A subagent with a queued confirmation gets the subtle sessions-list warning background pulse; the subagent whose confirmation is currently active above the input gets the stronger warning border/background. A numeric warning badge is shown only for two or more pending confirmations; one confirmation keeps the warning state without a redundant `1`. The carousel publishes its active subagent id for this presentation state only; confirmation ownership/routing is unchanged. The carousel's subagent reference remains scroll-to-context in regular/editor chat, but in the Agents window it invokes `workbench.action.chat.openAgentHostChat` to open the related read-only chat. A quiet italic duration sits outside the border: `Working for 10s` while active and `Worked for 10s` after completion. It uses tabular figures so once-per-second digit changes do not shift the surrounding label. Timing starts from the child chat's actual first `activeTurn.startedAt`, updates once per second while active, and freezes from the completed turn duration. Those values are copied onto serialized subagent tool data so stopping or reloading cannot reset the display. `ChatSubagentContentPart` publishes timing, confirmation count, active-confirmation state, model name, and latest active tool label/icon through the toolbar action context. The subagent chat resource is carried to the widget on `IChatSubagentToolInvocationData.chatResource` (populated in `stateToProgressAdapter` from `ToolResultSubagentContent.resource`). Because the chat widget is provider-agnostic and lower-layer, the link invokes `workbench.action.chat.openAgentHostChat` with the subagent chat URI; the sessions layer handler derives the chat id, finds the matching surfaced peer across visible sessions, and calls `sessionsService.openChat` to activate the existing tab. **Confirmations in read-only subagent chats.** Read-only hides the composer, but the tool-confirmation carousel remains visible and keeps the input part in layout. This lets multi-chat/side-chat subagent views resolve their own confirmations without making the chat message composer interactive. @@ -181,7 +181,7 @@ History restoration must also repair parent tool calls whose persisted `_meta`/s **Subagents in the Chats menu.** Subagents spawned by the **currently-active** chat are shown as a separate group (`2_subagents`) at the bottom of the **Chats** (Conversations) submenu, below the session's regular chats (`1_chats`); a separator divides the two groups. Per-chat association uses `IChatOrigin.parentChat` — the sessions-layer origin carries the spawning chat's resource (mapped from the protocol `ChatOrigin.chat` by the agent host provider's `_resolveParentChatResource`) — so the group changes as the active chat changes. Selecting a subagent entry toggles its read-only tab open/closed like any other chat entry. The entries are populated per session by `SessionConversationsMenuContribution` (only when the active chat has subagents). Subagents on their own do **not** show the chat tab strip: `IActiveSession.shouldShowChatTabs` is shown only when there is more than one visible tab (e.g. a subagent explicitly opened as a tab alongside the main chat) — a subagent that has not been opened as a tab is ignored. The **Chats** menu is always surfaced in the **session header meta row** (at the end of the pills), independent of the strip's visibility, kept available by `SessionActiveChatHasSubagentsContext` even when the parent is the only committed chat. -**Background activities above the chat input.** `SessionChatInputToolbar` combines live integrated browsers and active subagents into one background-activities pill. Browsers come from `IBrowserViewWorkbenchService.getKnownBrowserViews()` and belong to the viewed chat when their `IBrowserViewOwner.sessionId` matches that chat or one of its direct tool-origin subagents; subagents come from the owning session's tool-origin chats whose `origin.parentChat` is the viewed chat and whose status is active (`InProgress` or `NeedsInput`). Keeping `NeedsInput` visible is important because a pending tool or input confirmation does not end the subagent's active turn. A single activity shows its kind icon and label (browser page title, falling back to "Browser"; subagent title truncated after 30 characters with `...`). Multiple activities of one kind show **N Active Browsers/Subagents**; mixed kinds show **N Background Activities** with the session-in-progress icon. Any multi-item pill opens `IActionWidgetService` with categorized **Browsers** and **Subagents** sections (browser section first), where every selectable row has its kind icon and label. Opening a browser activity prefers a contextual browser page already **Sharing with Agent** for the same destination (exact URL first, then the browser tools' same-host rule), so the user sees the page the agent is driving; when no shared match exists, it opens the activity's normal browser input. The boolean `chat.turnStatusPills` setting gates the entire status-pills surface; for compatibility, any `true` member in the former per-pill object form enables the whole surface. When enabled, completed-turn pills replace the older checkpoint file-changes summary. `ChatView` mounts the toolbar in `ChatInputPart.persistentContentContainerElement`, which remains in layout when `ChatWidget.setReadOnly(true)` hides the rest of the composer, so these pills also remain available on read-only chats. +**Browsers and background activities above the chat input.** `SessionChatInputToolbar` mounts two independent activity pills, both rendered by the shared `SessionActivityPill` widget (which owns only the button, picker, and visibility — each control supplies its own activities, category titles, icons, and multi-activity summary): a **browsers** pill (`SessionBrowsersControl`) for live integrated browsers, and a **background activities** pill (`SessionBackgroundActivitiesControl`) for the viewed chat's active subagents — the latter is the extension point for further background-activity kinds. Browsers come from `IBrowserViewWorkbenchService.getKnownBrowserViews()` and belong to the viewed chat when their `IBrowserViewOwner.sessionId` matches that chat or one of its direct tool-origin subagents; subagents come from the owning session's tool-origin chats whose `origin.parentChat` is the viewed chat and whose status is active (`InProgress` or `NeedsInput`). Keeping `NeedsInput` visible is important because a pending tool or input confirmation does not end the subagent's active turn. A pill with a single activity shows its kind icon and label (browser page title, falling back to "Browser"; subagent title truncated after 30 characters with `...`). Multiple activities of one kind show **N Active Browsers/Subagents**; a pill holding mixed kinds shows **N Background Activities** with the session-in-progress icon. Any multi-item pill opens `IActionWidgetService` with categorized **Browsers** and **Subagents** sections (browser section first), where every selectable row has its kind icon and label. Opening a browser activity prefers a contextual browser page already **Sharing with Agent** for the same destination (exact URL first, then the browser tools' same-host rule), so the user sees the page the agent is driving; when no shared match exists, it opens the activity's normal browser input. The boolean `chat.turnStatusPills` setting gates the entire status-pills surface; for compatibility, any `true` member in the former per-pill object form enables the whole surface. When enabled, completed-turn pills replace the older checkpoint file-changes summary. `ChatView` mounts the toolbar in `ChatInputPart.persistentContentContainerElement`, which remains in layout when `ChatWidget.setReadOnly(true)` hides the rest of the composer, so these pills also remain available on read-only chats. **Debugging chat input UI without a live session.** Outside stable quality, the Developer command **Configure Fake Session Chat UI** is contributed to the Command Palette only while the active concrete session view is `ChatView` (not the new-session or new-chat composer). `SessionChatPillsDebugService` owns the command, active-view registration, and modal form. The form accepts non-negative files/insertions/deletions counts, failed/pending CI check counts, PR/agent feedback-to-address counts, plus comma- or newline-separated Markdown file names, subagent names, and browser labels. Its changes section also offers an auto-increment checkbox: while enabled, a disposable two-second interval independently increases insertions and deletions by values from 0 through 15. Each increment is the minimum of two uniform samples, giving strictly decreasing probabilities (0 most likely, 15 least likely). **Apply** forces the active toolbar and `SessionInputBanners` host to render those values independently of provider state, dismissal state, and `chat.turnStatusPills`; **Clear** removes the override; **Cancel** leaves it unchanged. Fake banner actions and dismiss controls are inert so they cannot invoke real CI or feedback operations. Applying again replaces the previous interval; Clear, active chat/view changes, and service disposal cancel it through the service-owned `MutableDisposable`. All debug-only coordination is isolated in `sessionChatInputToolbarDebug.ts`; the production widgets expose only the small override seams consumed by that service. @@ -332,12 +332,17 @@ replacement. The new-session view mounts the aquarium action outside `.new-chat-widget-content`. Its surrounding surface has checked **Aquarium** and -**Pet** context-menu items. `AquariumService` owns the application-scoped action -visibility preference; `IChatPetService` owns the same persisted pet state used -by `/vscode-pet`. Context-menu events from inside `.new-chat-widget-content` are -left untouched so the composer retains its own context-menu behavior. The -aquarium preference is also keyboard-accessible through the **Developer: Toggle -Aquarium Action Visibility** command. +**Pet (/vscode-pet)** context-menu items. `AquariumService` owns the +application-scoped action visibility preference; `IChatPetService` owns the same +persisted pet state used by `/vscode-pet`. Context-menu events from inside +`.new-chat-widget-content` are left untouched so the composer retains its own +context-menu behavior. The aquarium preference is also keyboard-accessible +through the **Developer: Toggle Aquarium Action Visibility** command. +`NewChatView` forwards its effective grid visibility to the aquarium mount so a +hidden composer cannot leave the aquarium rendering behind the visible chat +surface. Since `NewChatView` also hosts the peer-chat composer, +aquarium-specific lifecycle calls must first narrow the wrapped widget to +`NewChatWidget`. Agent feedback created while the active session is undefined or uncreated uses one shared new-session feedback scope, so it follows every undefined/uncreated diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index 95693e620be..dcfdc18120e 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -66,6 +66,8 @@ User groups are **fully user-managed**: their order is owned by `ISessionSection Archived sessions always go to the "Done" section regardless of grouping mode. Archive wins over pin — an archived session is never shown in Pinned — and archiving removes the session from any user-created group. This cleanup also applies when an archived session is added by a provider and when persisted group state loads. Restoring the session does not restore its former group membership. +Group membership, pin state, and manual sort keys are **durable user intent**: they are discarded only when a session is *definitively deleted* (`ISessionsManagementService.onDidDeleteSession`) or archived — never when a session merely drops out of a provider's list. A provider can evict sessions transiently (an agent host aggregates one listing across several agents, and an agent whose auth token or SDK is still loading contributes an empty list), so treating `onDidChangeSessions.removed` as a deletion would permanently destroy grouping and pins for sessions that return on the next refresh. The trade-off is that a session deleted from another window leaves a stale membership/pin entry behind; those entries match no session and are inert. + The experimental `chat.experimental.sessionArchiveActionWording` setting keeps archive actions consistent across the regular workbench and Agents window. The `archive` variant uses **Archive**, **Archive All**, **Unarchive**, and **Unarchive All** with `Codicon.archive`/`Codicon.unarchive`; the `done` variant uses **Mark as Done**, **Mark All as Done**, **Restore**, and **Restore All** with `Codicon.check`/`Codicon.checkAll`/`Codicon.redo`. Confirmation copy follows the selected vocabulary, while the underlying archived state and the "Done" section remain unchanged. ### Sorting diff --git a/src/vs/sessions/browser/dockedAuxiliaryBarController.ts b/src/vs/sessions/browser/dockedAuxiliaryBarController.ts index a982515717a..0e6c06cf339 100644 --- a/src/vs/sessions/browser/dockedAuxiliaryBarController.ts +++ b/src/vs/sessions/browser/dockedAuxiliaryBarController.ts @@ -20,11 +20,7 @@ export interface IDockedAuxiliaryBarHost { isAuxiliaryBarVisible(): boolean; /** Hide the docked auxiliary bar via the workbench part-visibility API. */ hideAuxiliaryBar(): void; - /** - * Reserves an inset (px) on the right of the editor content while the editor - * tab bar keeps the full width, so the docked panel can sit beside it. `0` - * restores full-width content. - */ + /** Reserves space on the right of the breadcrumbs and editor pane while tabs remain full-width. */ setEditorContentRightInset(px: number): void; /** Extra top offset (px) below the tab bar, e.g. reserved by the full-width header. */ getHeaderHeight(): number; diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts index 6323b922a1f..8ea43786422 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -16,7 +16,7 @@ import { KeybindingWeight } from '../../platform/keybinding/common/keybindingsRe import { registerIcon } from '../../platform/theme/common/iconRegistry.js'; import { AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, IsWindowAlwaysOnTopContext, SideBarVisibleContext } from '../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; -import { SessionsWelcomeVisibleContext, SinglePaneLayoutEnabledContext } from '../common/contextkeys.js'; +import { SessionsWelcomeVisibleContext, SinglePaneLayoutEnabledContext, CustomViewVisibleContext } from '../common/contextkeys.js'; // Register Icons const panelCloseIcon = registerIcon('agent-panel-close', Codicon.close, localize('agentPanelCloseIcon', "Icon to close the panel.")); @@ -80,6 +80,7 @@ registerAction2(ToggleSidebarVisibilityAction); const editorTitleAuxiliaryBarWhen = ContextKeyExpr.and( IsSessionsWindowContext, IsAuxiliaryWindowContext.toNegated(), + CustomViewVisibleContext.negate(), IsTopRightEditorGroupContext); const isSinglePaneDetailPanelDisabled = SinglePaneLayoutEnabledContext.negate(); diff --git a/src/vs/sessions/browser/media/workbench.css b/src/vs/sessions/browser/media/workbench.css index 71476d86020..4659249feaa 100644 --- a/src/vs/sessions/browser/media/workbench.css +++ b/src/vs/sessions/browser/media/workbench.css @@ -141,7 +141,9 @@ border-bottom-right-radius: 8px; } -.agent-sessions-workbench .part.sessionspart { +/* Floating content card. Shared by every full-surface content part (the + sessions grid and the custom view grid) so they are visually identical. */ +.agent-sessions-workbench .agents-part-card { margin: 0 var(--vscode-agents-layout-floatingPanelGap) 0 0; background: var(--part-background); border: 1px solid var(--part-border-color, transparent); @@ -149,7 +151,7 @@ box-sizing: border-box; } -.agent-sessions-workbench.noeditorpane .part.sessionspart { +.agent-sessions-workbench.noeditorpane .agents-part-card { margin-right: 0; } @@ -183,7 +185,7 @@ box-sizing: border-box; } -.monaco-workbench.vs.agent-sessions-workbench .part.sessionspart, +.monaco-workbench.vs.agent-sessions-workbench .agents-part-card, .monaco-workbench.vs.agent-sessions-workbench .part.auxiliarybar, .monaco-workbench.vs.agent-sessions-workbench .part.panel { border-color: var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent)); @@ -328,7 +330,7 @@ .agent-sessions-workbench .part.auxiliarybar, .agent-sessions-workbench .part.panel, -.agent-sessions-workbench .part.sessionspart { +.agent-sessions-workbench .agents-part-card { transition: opacity 250ms ease-out, margin-top 250ms ease-out, @@ -346,7 +348,7 @@ .agent-sessions-workbench .part.auxiliarybar, .agent-sessions-workbench .part.panel, - .agent-sessions-workbench .part.sessionspart { + .agent-sessions-workbench .agents-part-card { opacity: 0; border-color: transparent; background: color-mix(in srgb, var(--part-background) 60%, var(--vscode-sideBar-background)); @@ -365,7 +367,7 @@ margin: 0 16px 0 6px; } - .agent-sessions-workbench .part.sessionspart { + .agent-sessions-workbench .agents-part-card { margin: 6px 16px 0 16px; } } @@ -374,7 +376,7 @@ .agent-sessions-workbench .part.auxiliarybar, .agent-sessions-workbench .part.panel, - .agent-sessions-workbench .part.sessionspart, + .agent-sessions-workbench .agents-part-card, .agent-sessions-workbench .part.sidebar > .content { transition: none; } diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 896dc61248c..8b094c543c9 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -33,6 +33,9 @@ export const Menus = { GoMenu: new MenuId('SessionsGoMenu'), AgentFeedbackEditorContent: new MenuId('AgentFeedbackEditorContent'), + /** Header actions of the test custom view. */ + CustomViewTest: new MenuId('SessionsCustomViewTest'), + NewSessionConfig: new MenuId('NewSessions.SessionConfigMenu'), NewSessionControl: new MenuId('NewSessions.SessionControlMenu'), NewSessionRepositoryConfig: new MenuId('NewSessions.RepositoryConfigMenu'), diff --git a/src/vs/sessions/browser/mobileNavigationStack.ts b/src/vs/sessions/browser/mobileNavigationStack.ts index 020022bf65c..3f8975f9471 100644 --- a/src/vs/sessions/browser/mobileNavigationStack.ts +++ b/src/vs/sessions/browser/mobileNavigationStack.ts @@ -7,7 +7,7 @@ import { Disposable } from '../../base/common/lifecycle.js'; import { Emitter, Event } from '../../base/common/event.js'; import { mainWindow } from '../../base/browser/window.js'; -export type MobileNavigationLayer = 'sidebar' | 'editor' | 'panel' | 'auxbar'; +export type MobileNavigationLayer = 'sidebar' | 'editor' | 'panel' | 'auxbar' | 'customView'; interface MobileNavigationEntry { readonly layer: MobileNavigationLayer; diff --git a/src/vs/sessions/browser/parts/agentsPartCard.ts b/src/vs/sessions/browser/parts/agentsPartCard.ts new file mode 100644 index 00000000000..baa8fde67ae --- /dev/null +++ b/src/vs/sessions/browser/parts/agentsPartCard.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IColorTheme } from '../../../platform/theme/common/themeService.js'; +import { agentsPanelBackground, agentsPanelBorder, agentsPanelForeground } from '../../common/theme.js'; +import { AGENTS_FLOATING_PANEL_GAP } from '../../common/layoutConstants.js'; + +/** + * Marks a part as a floating content card. Carries the shared background, + * border, corner radius and outer margin (see `media/workbench.css`) so every + * content part in the Agents window is styled from one place. + */ +export const AGENTS_PART_CARD_CLASS = 'agents-part-card'; + +/** Visual metrics of a card part, kept in sync with the CSS in `media/workbench.css`. */ +export const AgentsPartCard = { + MARGIN_TOP: 0, + MARGIN_LEFT: 0, + MARGIN_RIGHT: AGENTS_FLOATING_PANEL_GAP, + MARGIN_RIGHT_NO_EDITOR_PANE: 0, + MARGIN_BOTTOM: 0, + BORDER_WIDTH: 1, +} as const; + +/** + * Content box of a card part, i.e. its grid-allocated size minus the card's + * visual margins and border. + */ +export function getAgentsPartCardContentSize(width: number, height: number, editorPaneVisible: boolean): { readonly width: number; readonly height: number } { + const borderTotal = AgentsPartCard.BORDER_WIDTH * 2; + const marginRight = editorPaneVisible ? AgentsPartCard.MARGIN_RIGHT : AgentsPartCard.MARGIN_RIGHT_NO_EDITOR_PANE; + + return { + width: width - AgentsPartCard.MARGIN_LEFT - marginRight - borderTotal, + height: height - AgentsPartCard.MARGIN_TOP - AgentsPartCard.MARGIN_BOTTOM - borderTotal + }; +} + +/** Publishes the themed card colors that `media/workbench.css` draws the card from. */ +export function applyAgentsPartCardStyles(container: HTMLElement, theme: IColorTheme): void { + container.style.setProperty('--part-background', theme.getColor(agentsPanelBackground)?.toString() ?? ''); + container.style.setProperty('--part-border-color', theme.getColor(agentsPanelBorder)?.toString() ?? 'transparent'); + container.style.setProperty('--part-foreground', theme.getColor(agentsPanelForeground)?.toString() ?? ''); + container.style.backgroundColor = theme.getColor(agentsPanelBackground)?.toString() ?? ''; +} + +/** Clears the inline card colors so CSS can take over (phone layout). */ +export function clearAgentsPartCardStyles(container: HTMLElement): void { + container.style.backgroundColor = ''; + container.style.removeProperty('--part-background'); + container.style.removeProperty('--part-border-color'); + container.style.color = ''; +} diff --git a/src/vs/sessions/browser/parts/chatView.ts b/src/vs/sessions/browser/parts/chatView.ts index 7a0f59cfb38..f1a50dc79c8 100644 --- a/src/vs/sessions/browser/parts/chatView.ts +++ b/src/vs/sessions/browser/parts/chatView.ts @@ -129,6 +129,14 @@ export abstract class AbstractChatView extends Disposable implements ISerializab // no-op by default } + /** + * Notifies the view whether it is currently shown. Unlike {@link setActive}, + * inactive sessions displayed side by side are still visible. + */ + setVisible(_visible: boolean): void { + // no-op by default + } + /** * Shows an indeterminate progress bar at the top of this leaf while the * given promise is pending, mirroring how each editor group surfaces diff --git a/src/vs/sessions/browser/parts/customViewGridPart.ts b/src/vs/sessions/browser/parts/customViewGridPart.ts new file mode 100644 index 00000000000..ae1df26b6c1 --- /dev/null +++ b/src/vs/sessions/browser/parts/customViewGridPart.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/customViewGridPart.css'; +import { $, size } from '../../../base/browser/dom.js'; +import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js'; +import { assertReturnsDefined } from '../../../base/common/types.js'; +import { MutableDisposable } from '../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { IStorageService } from '../../../platform/storage/common/storage.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { Part } from '../../../workbench/browser/part.js'; +import { Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { ICustomViewDescriptor } from '../../services/customView/browser/customView.js'; +import { applyAgentsPartCardStyles, getAgentsPartCardContentSize } from './agentsPartCard.js'; +import { CustomViewNode } from './customViewNode.js'; +import { IAgentWorkbenchLayoutService } from '../workbench.js'; + +/** + * Hosts the custom views that replace the sessions grid while one is shown. It + * is a passive renderer: the workbench drives it from + * `ICustomViewService.activeCustomView` via {@link setView}. + * + * Only a single view can be shown today; the part is structured as a grid of + * {@link CustomViewNode} leaves so more can be added later. + */ +export class CustomViewGridPart extends Part { + + override readonly minimumWidth: number = 300; + override readonly maximumWidth: number = Number.POSITIVE_INFINITY; + override readonly minimumHeight: number = 0; + override readonly maximumHeight: number = Number.POSITIVE_INFINITY; + get snap(): boolean { return false; } + + readonly priority = LayoutPriority.High; + + private _contentArea: HTMLElement | undefined; + private readonly _node = this._register(new MutableDisposable()); + private _descriptor: ICustomViewDescriptor | undefined; + protected _lastContentSize: { readonly width: number; readonly height: number } | undefined; + + constructor( + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IAgentWorkbenchLayoutService protected readonly agentWorkbenchLayoutService: IAgentWorkbenchLayoutService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super( + Parts.CUSTOM_VIEW_GRID_PART, + { hasTitle: false, borderWidth: () => 0 }, + themeService, + storageService, + agentWorkbenchLayoutService + ); + } + + override create(parent: HTMLElement): void { + this.element = parent; + + super.create(parent); + } + + protected override createContentArea(parent: HTMLElement): HTMLElement { + const contentArea = $('.custom-view-grid'); + parent.appendChild(contentArea); + this._contentArea = contentArea; + + this._renderView(); + + return contentArea; + } + + /** Renders the given custom view, replacing (and disposing) the previous one. */ + setView(descriptor: ICustomViewDescriptor | undefined): void { + if (this._descriptor === descriptor) { + return; + } + + this._descriptor = descriptor; + this._renderView(); + } + + private _renderView(): void { + if (!this._contentArea) { + return; + } + + this._node.clear(); + + if (!this._descriptor) { + return; + } + + const node = this.instantiationService.createInstance(CustomViewNode, this._descriptor); + this._node.value = node; + this._contentArea.appendChild(node.element); + + if (this._lastContentSize) { + this._layoutNode(this._lastContentSize.width, this._lastContentSize.height); + } + } + + focus(): void { + this._node.value?.focus(); + } + + override updateStyles(): void { + super.updateStyles(); + + applyAgentsPartCardStyles(assertReturnsDefined(this.getContainer()), this.theme); + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!this.layoutService.isVisible(Parts.CUSTOM_VIEW_GRID_PART)) { + return; + } + + const cardSize = getAgentsPartCardContentSize(width, height, this.agentWorkbenchLayoutService.isEditorPaneVisible()); + const { contentSize } = this.layoutContents(cardSize.width, cardSize.height); + this._layoutNode(contentSize.width, contentSize.height); + + super.layout(width, height, top, left); + } + + protected _layoutNode(width: number, height: number): void { + this._lastContentSize = { width, height }; + + const node = this._node.value; + if (!node) { + return; + } + + size(node.element, width, height); + node.layout(width, height); + } + + toJSON(): object { + return { + type: Parts.CUSTOM_VIEW_GRID_PART + }; + } +} diff --git a/src/vs/sessions/browser/parts/customViewGridParts.ts b/src/vs/sessions/browser/parts/customViewGridParts.ts new file mode 100644 index 00000000000..54db89cad79 --- /dev/null +++ b/src/vs/sessions/browser/parts/customViewGridParts.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../base/common/lifecycle.js'; +import { getClientArea } from '../../../base/browser/dom.js'; +import { mainWindow } from '../../../base/browser/window.js'; +import { InstantiationType, registerSingleton } from '../../../platform/instantiation/common/extensions.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { ICustomViewDescriptor } from '../../services/customView/browser/customView.js'; +import { ICustomViewGridPartService } from '../../services/customView/browser/customViewGridPartService.js'; +import { CustomViewGridPart } from './customViewGridPart.js'; +import { MobileCustomViewGridPart } from './mobile/mobileCustomViewGridPart.js'; + +/** + * Owns the lifecycle of the {@link CustomViewGridPart}. Selects the mobile vs. + * desktop variant based on viewport width at construction time. Registered as an + * eager singleton so the part registers itself with the workbench layout service + * before the workbench starts laying out parts. + */ +export class CustomViewGridParts extends Disposable implements ICustomViewGridPartService { + + declare readonly _serviceBrand: undefined; + + private readonly _mainPart: CustomViewGridPart; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const { width } = getClientArea(mainWindow.document.body); + const isPhoneLayout = width < 640; + + this._mainPart = this._register(instantiationService.createInstance(isPhoneLayout ? MobileCustomViewGridPart : CustomViewGridPart)); + } + + setView(descriptor: ICustomViewDescriptor | undefined): void { + this._mainPart.setView(descriptor); + } + + focusActiveView(): void { + this._mainPart.focus(); + } +} + +registerSingleton(ICustomViewGridPartService, CustomViewGridParts, InstantiationType.Eager); diff --git a/src/vs/sessions/browser/parts/customViewNode.ts b/src/vs/sessions/browser/parts/customViewNode.ts new file mode 100644 index 00000000000..918c10cf301 --- /dev/null +++ b/src/vs/sessions/browser/parts/customViewNode.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/customViewGridPart.css'; +import { $, isAncestorOfActiveElement } from '../../../base/browser/dom.js'; +import { DomScrollableElement } from '../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { autorun } from '../../../base/common/observable.js'; +import { ScrollbarVisibility } from '../../../base/common/scrollable.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; +import { MenuItemAction } from '../../../platform/actions/common/actions.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { asCssVariable } from '../../../platform/theme/common/colorUtils.js'; +import { AGENTS_CENTERED_CONTENT_MAX_WIDTH } from '../../common/layoutConstants.js'; +import { activeSessionViewBackground, activeSessionViewForeground } from '../../common/theme.js'; +import { AbstractCustomView, ICustomViewDescriptor } from '../../services/customView/browser/customView.js'; +import { SessionHeaderMetaActionViewItem } from './sessionHeaderMetaActionViewItem.js'; + +/** + * A leaf of the custom view grid. Owns the shared chrome — a header with the + * title, an optional description and the contributed actions, above a scroll + * container — and hosts one {@link AbstractCustomView} inside it. The header + * stays put while the content scrolls beneath it and grows a bottom border as + * soon as the content is scrolled. + */ +export class CustomViewNode extends Disposable { + + readonly element: HTMLElement = $('.custom-view-node'); + + private readonly _headerEl: HTMLElement; + private readonly _headerBandEl: HTMLElement; + private readonly _titleEl: HTMLElement; + private readonly _descriptionEl: HTMLElement; + private readonly _contentEl: HTMLElement; + private readonly _scrollable: DomScrollableElement; + private readonly _view: AbstractCustomView; + private readonly _maxWidth: number; + + private _lastLayout: { readonly width: number; readonly height: number } | undefined; + + constructor( + descriptor: ICustomViewDescriptor, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + this._view = this._register(instantiationService.createInstance(descriptor.ctor)); + this._maxWidth = this._view.maxWidth ?? AGENTS_CENTERED_CONTENT_MAX_WIDTH; + + // Mirror the active session view's surface colors so a custom view is + // visually indistinguishable from a session view. + this.element.style.setProperty('--session-view-background', asCssVariable(activeSessionViewBackground)); + this.element.style.setProperty('--session-view-foreground', asCssVariable(activeSessionViewForeground)); + this.element.setAttribute('role', 'region'); + + this._headerEl = $('.custom-view-header'); + this.element.appendChild(this._headerEl); + + this._headerBandEl = $('.custom-view-header-band'); + this._headerEl.appendChild(this._headerBandEl); + + const titleRow = $('.custom-view-header-title-row'); + this._headerBandEl.appendChild(titleRow); + + this._titleEl = $('.custom-view-header-title'); + titleRow.appendChild(this._titleEl); + + this._descriptionEl = $('.custom-view-header-description'); + this._headerBandEl.appendChild(this._descriptionEl); + + if (descriptor.actions) { + const buttonBar = descriptor.actions.style === 'buttonBar'; + const actionsContainer = $('.custom-view-header-actions'); + actionsContainer.classList.toggle('custom-view-header-actions-buttons', buttonBar); + titleRow.appendChild(actionsContainer); + + const toolbar = this._register(instantiationService.createInstance(MenuWorkbenchToolBar, actionsContainer, descriptor.actions.menuId, { + hiddenItemStrategy: HiddenItemStrategy.Ignore, + menuOptions: { shouldForwardArgs: true }, + toolbarOptions: { primaryGroup: () => true }, + actionViewItemProvider: buttonBar + ? (action, options) => action instanceof MenuItemAction + ? instantiationService.createInstance(SessionHeaderMetaActionViewItem, undefined, action, options) + : undefined + : undefined, + })); + this._register(toolbar.onDidChangeMenuItems(() => this._layoutChildren())); + } + + const scrollContent = $('.custom-view-scroll-content'); + this._contentEl = $('.custom-view-content'); + this._contentEl.tabIndex = -1; + scrollContent.appendChild(this._contentEl); + + this._scrollable = this._register(new DomScrollableElement(scrollContent, { + horizontal: ScrollbarVisibility.Hidden, + vertical: ScrollbarVisibility.Auto, + useShadows: false, + })); + this._scrollable.getDomNode().classList.add('custom-view-body'); + this.element.appendChild(this._scrollable.getDomNode()); + this._register(this._scrollable.onScroll(e => { + this._headerEl.classList.toggle('scrolled', e.scrollTop > 0); + })); + + this._view.render(this._contentEl); + + // The content grows and shrinks as the view loads, so keep the scrollbar in sync with it. + const resizeObserver = new ResizeObserver(() => this._scrollable.scanDomNode()); + resizeObserver.observe(this._contentEl); + this._register(toDisposable(() => resizeObserver.disconnect())); + + this._register(autorun(reader => { + const title = this._view.title.read(reader); + this._titleEl.textContent = title; + this.element.setAttribute('aria-label', title); + this._layoutChildren(); + })); + + this._register(autorun(reader => { + const description = this._view.description.read(reader); + this._descriptionEl.textContent = description ?? ''; + this._descriptionEl.classList.toggle('hidden', !description); + this._layoutChildren(); + })); + + this._register(toDisposable(() => this.element.remove())); + } + + layout(width: number, height: number): void { + this._lastLayout = { width, height }; + this._layoutChildren(); + } + + focus(): void { + this._view.focus(); + if (!isAncestorOfActiveElement(this.element)) { + this._contentEl.focus(); + } + } + + private _layoutChildren(): void { + if (!this._lastLayout) { + return; + } + + const { width, height } = this._lastLayout; + const bandWidth = Math.min(width, this._maxWidth); + this._headerBandEl.style.width = `${bandWidth}px`; + this._contentEl.style.width = `${bandWidth}px`; + + // The scroll container is sized by flex, so only the view needs to be told + // how much room is left below the header. + this._view.layout(bandWidth, Math.max(0, height - this._headerEl.offsetHeight)); + this._scrollable.scanDomNode(); + } +} diff --git a/src/vs/sessions/browser/parts/media/customViewGridPart.css b/src/vs/sessions/browser/parts/media/customViewGridPart.css new file mode 100644 index 00000000000..556516e140a --- /dev/null +++ b/src/vs/sessions/browser/parts/media/customViewGridPart.css @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-workbench.nocustomviewgrid .part.customviewgridpart { + display: none !important; + visibility: hidden !important; +} + +.monaco-workbench .part.customviewgridpart > .content { + display: flex; +} + +.custom-view-grid { + display: flex; + flex-direction: row; + width: 100%; + height: 100%; + overflow: hidden; +} + +.custom-view-node { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + position: relative; + background-color: var(--session-view-background); + color: var(--session-view-foreground); +} + +/* Header: a centered, width-capped band matching the session header's measure + and side padding, so a custom view and a session view align. */ +.custom-view-header { + flex-shrink: 0; +} + +.custom-view-header-band { + box-sizing: border-box; + margin: 0 auto; + padding: 6px 10px; + border-bottom: 1px solid transparent; +} + +.custom-view-header.scrolled .custom-view-header-band { + border-bottom-color: color-mix(in srgb, var(--session-view-foreground) 12%, transparent); +} + +.custom-view-header-title-row { + display: flex; + align-items: center; + gap: 6px; + min-height: 26px; +} + +.custom-view-header-title { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--vscode-agents-fontSize-heading3, 13px); + font-weight: var(--vscode-agents-fontWeight-semiBold, 600); +} + +.custom-view-header-actions { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.custom-view-header-actions.custom-view-header-actions-buttons .monaco-action-bar .actions-container { + gap: var(--vscode-spacing-size60, 6px); +} + +.custom-view-header-description { + margin-top: 2px; + font-size: var(--vscode-agents-fontSize-body2, 12px); + opacity: 0.8; +} + +.custom-view-header-description.hidden { + display: none; +} + +/* The scroll container is scrolled natively by DomScrollableElement, so it must + fill the body and clip; the content keeps its natural height. */ +.custom-view-body { + flex: 1 1 auto; + min-height: 0; +} + +.custom-view-scroll-content { + width: 100%; + height: 100%; + min-height: 100%; + overflow: hidden; +} + +.custom-view-content { + box-sizing: border-box; + margin: 0 auto; + padding: 10px; +} + +/* The content is the focus fallback when a view does not focus a child of its own. */ +.custom-view-content:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css index 14d562f4fe5..81bc56f8e3f 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileChatShell.css +++ b/src/vs/sessions/browser/parts/mobile/mobileChatShell.css @@ -203,7 +203,7 @@ /* Remove card appearance from ALL parts on phone. Specificity wins over the desktop card rule in style.css without !important; width/height match what the mobile Part.layout() already inlines. */ -.agent-sessions-workbench.phone-layout .part.sessionspart, +.agent-sessions-workbench.phone-layout .agents-part-card, .agent-sessions-workbench.phone-layout .part.sidebar, .agent-sessions-workbench.phone-layout .part.auxiliarybar, .agent-sessions-workbench.phone-layout .part.panel { @@ -222,7 +222,7 @@ policy above). Without this, opening the sidebar — which makes the splitview share space between sidebar and sessions part — would shrink the sessions part's content during the drawer slide animation. */ -.agent-sessions-workbench.phone-layout .part.sessionspart > .content, +.agent-sessions-workbench.phone-layout .agents-part-card > .content, .agent-sessions-workbench.phone-layout .part.sidebar > .content, .agent-sessions-workbench.phone-layout .part.auxiliarybar > .content, .agent-sessions-workbench.phone-layout .part.panel > .content { diff --git a/src/vs/sessions/browser/parts/mobile/mobileCustomViewGridPart.ts b/src/vs/sessions/browser/parts/mobile/mobileCustomViewGridPart.ts new file mode 100644 index 00000000000..08d61c706cc --- /dev/null +++ b/src/vs/sessions/browser/parts/mobile/mobileCustomViewGridPart.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { Part } from '../../../../workbench/browser/part.js'; +import { clearAgentsPartCardStyles } from '../agentsPartCard.js'; +import { CustomViewGridPart } from '../customViewGridPart.js'; +import { isPhoneLayout } from './mobileLayout.js'; + +/** + * Mobile variant of {@link CustomViewGridPart}. + * + * On phone-sized viewports the part fills the full grid cell without card + * margins or border insets. When the viewport transitions to tablet/desktop + * (e.g. device rotation crossing the phone breakpoint) this delegates to the + * desktop implementation so layout math stays correct. + */ +export class MobileCustomViewGridPart extends CustomViewGridPart { + + override updateStyles(): void { + // Always run the desktop implementation first so inline styles are set on + // tablet/desktop transitions; clear them again in phone mode so CSS takes over. + super.updateStyles(); + + if (!isPhoneLayout(this.layoutService)) { + return; + } + + const container = this.getContainer(); + if (container) { + clearAgentsPartCardStyles(container); + } + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!isPhoneLayout(this.layoutService)) { + super.layout(width, height, top, left); + return; + } + + if (!this.layoutService.isVisible(Parts.CUSTOM_VIEW_GRID_PART)) { + return; + } + + const { contentSize } = this.layoutContents(width, height); + this._layoutNode(contentSize.width, contentSize.height); + Part.prototype.layout.call(this, width, height, top, left); + } +} diff --git a/src/vs/sessions/browser/parts/mobile/mobileSessionsPart.ts b/src/vs/sessions/browser/parts/mobile/mobileSessionsPart.ts index c04360bb7a4..6b5c9549435 100644 --- a/src/vs/sessions/browser/parts/mobile/mobileSessionsPart.ts +++ b/src/vs/sessions/browser/parts/mobile/mobileSessionsPart.ts @@ -6,6 +6,7 @@ import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; import { Part } from '../../../../workbench/browser/part.js'; import { SessionsPart } from '../sessionsPart.js'; +import { clearAgentsPartCardStyles } from '../agentsPartCard.js'; import { isPhoneLayout } from './mobileLayout.js'; /** @@ -31,10 +32,7 @@ export class MobileSessionsPart extends SessionsPart { const container = this.getContainer(); if (container) { - container.style.backgroundColor = ''; - container.style.removeProperty('--part-background'); - container.style.removeProperty('--part-border-color'); - container.style.color = ''; + clearAgentsPartCardStyles(container); } } diff --git a/src/vs/sessions/browser/parts/panelPart.ts b/src/vs/sessions/browser/parts/panelPart.ts index 776e9341e26..67aefd4dcd1 100644 --- a/src/vs/sessions/browser/parts/panelPart.ts +++ b/src/vs/sessions/browser/parts/panelPart.ts @@ -16,7 +16,7 @@ import { IInstantiationService } from '../../../platform/instantiation/common/in import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { PANEL_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_INACTIVE_TITLE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_DRAG_AND_DROP_BORDER } from '../../../workbench/common/theme.js'; import { agentsBadgeBackground, agentsBadgeForeground, agentsPanelBackground, agentsPanelBorder, agentsPanelForeground } from '../../common/theme.js'; -import { AGENTS_FLOATING_PANEL_GAP } from '../../common/sizes.js'; +import { AGENTS_FLOATING_PANEL_GAP } from '../../common/layoutConstants.js'; import { INotificationService } from '../../../platform/notification/common/notification.js'; import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { assertReturnsDefined } from '../../../base/common/types.js'; diff --git a/src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts b/src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts index 42d43a2fb1e..3e3333742ce 100644 --- a/src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts +++ b/src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts @@ -35,7 +35,7 @@ export class SessionHeaderMetaActionViewItem extends BaseActionViewItem { button.element.classList.add('monaco-text-button', 'chat-composite-bar-meta-item-button'); this._register(button.onDidClick(() => { if (this._action.enabled) { - this.actionRunner.run(this._action, this._context); + this.onDidClickButton(); } })); @@ -44,6 +44,15 @@ export class SessionHeaderMetaActionViewItem extends BaseActionViewItem { this.updateTooltip(); } + /** + * Invoked when the pill is activated. Runs the action by default; subclasses can + * override to present their own affordance (e.g. a picker when the pill stands + * for several items). + */ + protected onDidClickButton(): void { + this.actionRunner.run(this._action, this._context); + } + override focus(): void { this.button?.focus(); } diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index 510b82869ed..feaafbce6e0 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -26,6 +26,7 @@ import { ISessionContext, SessionContext } from '../../services/sessions/browser import { autorun, observableFromEvent, observableValue } from '../../../base/common/observable.js'; import { SessionIsMaximizedContext } from '../../common/contextkeys.js'; import { UNARCHIVE_SESSION_COMMAND_ID } from '../../common/sessionCommands.js'; +import { AGENTS_CENTERED_CONTENT_MAX_WIDTH } from '../../common/layoutConstants.js'; import { setActiveSessionContextKeys } from '../../services/sessions/common/sessionContextKeys.js'; import { activeSessionViewBackground, activeSessionViewForeground, inactiveSessionViewBackground, inactiveSessionViewForeground } from '../../common/theme.js'; import { ChatInteractivity, SessionStatus } from '../../services/sessions/common/session.js'; @@ -49,7 +50,7 @@ export interface ISessionViewOptions extends IChatViewOptions { } export class SessionView extends Disposable implements ISerializableView { static readonly TYPE = 'sessions.sessionView'; - private static readonly CENTERED_CONTENT_MAX_WIDTH = 950; + private static readonly CENTERED_CONTENT_MAX_WIDTH = AGENTS_CENTERED_CONTENT_MAX_WIDTH; private static readonly ACTIVE_BACKGROUND = asCssVariable(activeSessionViewBackground); private static readonly ACTIVE_FOREGROUND = asCssVariable(activeSessionViewForeground); private static readonly INACTIVE_BACKGROUND = asCssVariable(inactiveSessionViewBackground); @@ -85,6 +86,12 @@ export class SessionView extends Disposable implements ISerializableView { /** Whether this view currently hosts the active session in the grid. */ private _isActive = true; + /** Whether the owning {@link SessionsPart} is visible in the workbench grid. */ + private _isPartVisible = true; + + /** Whether this leaf is visible within the part's internal grid. */ + private _isLeafVisible = true; + private readonly _sessionObs = observableValue(this, undefined); constructor( @@ -214,6 +221,7 @@ export class SessionView extends Disposable implements ISerializableView { this._contentContainer.replaceChildren(view.element); this._currentView.value = view; view.setActive(this._isActive); + view.setVisible(this._isVisible); } if (session) { @@ -246,7 +254,12 @@ export class SessionView extends Disposable implements ISerializableView { if (!this._lastLayout) { return; } + + // A hidden or zero-sized leaf would report invalid geometry to the chat widget. const { width, height, top, left } = this._lastLayout; + if (!this._isVisible || width === 0 || height === 0) { + return; + } // Apply the centered band's width first so the header and tabs wrap to // their final layout before we measure their combined height. Measuring @@ -326,6 +339,53 @@ export class SessionView extends Disposable implements ISerializableView { this._currentView.value?.setActive(active); } + /** + * Grid hook invoked by the part's internal split view when this leaf is + * hidden or shown (e.g. when a sibling session is maximized). + */ + setVisible(visible: boolean): void { + if (this._isLeafVisible === visible) { + return; + } + const wasVisible = this._isVisible; + this._isLeafVisible = visible; + this._updateVisibility(wasVisible); + } + + /** + * Called by the owning {@link SessionsPart} when the part itself is hidden or + * shown in the workbench grid. Combined with this leaf's own visibility to + * form the view's effective visibility. + */ + setPartVisible(visible: boolean): void { + if (this._isPartVisible === visible) { + return; + } + const wasVisible = this._isVisible; + this._isPartVisible = visible; + this._updateVisibility(wasVisible); + } + + /** + * Whether this view is actually shown. Unrelated to {@link setActive}: + * inactive sessions shown side by side are still visible. + */ + private get _isVisible(): boolean { + return this._isPartVisible && this._isLeafVisible; + } + + private _updateVisibility(wasVisible: boolean): void { + const visible = this._isVisible; + if (visible === wasVisible) { + return; + } + this._currentView.value?.setVisible(visible); + if (visible) { + // Catch up on the layout passes that were skipped while hidden. + this._layoutChildren(); + } + } + private _applyActiveSessionStyles(): void { const background = this._isActive ? SessionView.ACTIVE_BACKGROUND : SessionView.INACTIVE_BACKGROUND; const foreground = this._isActive ? SessionView.ACTIVE_FOREGROUND : SessionView.INACTIVE_FOREGROUND; diff --git a/src/vs/sessions/browser/parts/sessionsPart.ts b/src/vs/sessions/browser/parts/sessionsPart.ts index 31234fb42f9..236dafbc105 100644 --- a/src/vs/sessions/browser/parts/sessionsPart.ts +++ b/src/vs/sessions/browser/parts/sessionsPart.ts @@ -8,8 +8,7 @@ import { IContextKey, IContextKeyService } from '../../../platform/contextkey/co import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { agentsPanelBackground, agentsPanelBorder, agentsPanelForeground } from '../../common/theme.js'; -import { AGENTS_FLOATING_PANEL_GAP } from '../../common/sizes.js'; +import { agentsPanelBorder } from '../../common/theme.js'; import { Parts } from '../../../workbench/services/layout/browser/layoutService.js'; import { assertReturnsDefined } from '../../../base/common/types.js'; import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js'; @@ -31,6 +30,7 @@ import { AbstractProgressScope, ScopedProgressIndicator } from '../../../workben import { observableValue } from '../../../base/common/observable.js'; import { IWorkbenchAssignmentService } from '../../../workbench/services/assignment/common/assignmentService.js'; import { IAgentWorkbenchLayoutService } from '../workbench.js'; +import { applyAgentsPartCardStyles, getAgentsPartCardContentSize } from './agentsPartCard.js'; /** * ExP treatment that, when enabled, moves the session type ("harness") picker @@ -56,13 +56,6 @@ export class SessionsPart extends Part { override readonly maximumHeight: number = Number.POSITIVE_INFINITY; get snap(): boolean { return false; } - /** Visual margin values for the card-like appearance */ - static readonly MARGIN_TOP = 0; - static readonly MARGIN_LEFT = 0; - static readonly MARGIN_RIGHT = AGENTS_FLOATING_PANEL_GAP; - static readonly MARGIN_RIGHT_NO_EDITOR_PANE = 0; - static readonly MARGIN_BOTTOM = 0; - /** Border width on the card (1px each side) */ static readonly BORDER_WIDTH = 1; @@ -92,6 +85,12 @@ export class SessionsPart extends Part { private readonly _multipleSessionsVisibleKey: IContextKey; private readonly _sessionsFocusKey: IContextKey; + /** + * Whether the part itself is visible in the workbench grid. Starts `true` + * because the workbench grid only calls {@link setVisible} on change. + */ + private _isPartVisible = true; + /** * Whether the session type ("harness") picker should be rendered below the * input (in the controls) instead of next to the workspace picker. Backed @@ -381,6 +380,7 @@ export class SessionsPart extends Part { private _createSlot(): IGridSlot { const disposables = new DisposableStore(); const view = disposables.add(this.instantiationService.createInstance(SessionView)); + view.setPartVisible(this._isPartVisible); const slot: IGridSlot = { view, disposables, boundSessionId: undefined }; // Promote a visible session to the active session when its view receives // focus or is clicked. Pointer-down covers clicks on non-focusable chrome @@ -406,15 +406,23 @@ export class SessionsPart extends Part { const container = assertReturnsDefined(this.getContainer()); - // Store background and border as CSS variables for the card styling on .part - container.style.setProperty('--part-background', this.getColor(agentsPanelBackground) || ''); - container.style.setProperty('--part-border-color', this.getColor(agentsPanelBorder) || 'transparent'); - container.style.setProperty('--part-foreground', this.getColor(agentsPanelForeground) || ''); - container.style.backgroundColor = this.getColor(agentsPanelBackground) || ''; + applyAgentsPartCardStyles(container, this.theme); this._gridWidget?.style({ separatorBorder: this._gridSeparatorBorder }); } + override setVisible(visible: boolean): void { + if (this._isPartVisible !== visible) { + // Update before `super`, whose event re-enters this method. + this._isPartVisible = visible; + for (const slot of this._slots) { + slot.view.setPartVisible(visible); + } + } + + super.setVisible(visible); + } + override layout(width: number, height: number, top: number, left: number): void { if (!this.layoutService.isVisible(Parts.SESSIONS_PART)) { return; @@ -422,17 +430,10 @@ export class SessionsPart extends Part { this._lastLayout = { width, height, top, left }; - // Compute content dimensions accounting for visual margins and border. - const borderTotal = SessionsPart.BORDER_WIDTH * 2; - const marginLeft = SessionsPart.MARGIN_LEFT; - const marginBottom = SessionsPart.MARGIN_BOTTOM; - const marginRight = this.agentWorkbenchLayoutService.isEditorPaneVisible() ? SessionsPart.MARGIN_RIGHT : SessionsPart.MARGIN_RIGHT_NO_EDITOR_PANE; + const cardSize = getAgentsPartCardContentSize(width, height, this.agentWorkbenchLayoutService.isEditorPaneVisible()); // Size the content area with the reduced dimensions. - const { contentSize } = this.layoutContents( - width - marginLeft - marginRight - borderTotal, - height - SessionsPart.MARGIN_TOP - marginBottom - borderTotal - ); + const { contentSize } = this.layoutContents(cardSize.width, cardSize.height); // Layout the internal grid widget within the content area. this._gridWidget?.layout(contentSize.width, contentSize.height, top, left); diff --git a/src/vs/sessions/browser/singlePaneWorkbench.ts b/src/vs/sessions/browser/singlePaneWorkbench.ts index 4f6a029e276..bf4db60f198 100644 --- a/src/vs/sessions/browser/singlePaneWorkbench.ts +++ b/src/vs/sessions/browser/singlePaneWorkbench.ts @@ -186,15 +186,22 @@ export class SinglePaneWorkbench extends Workbench { return editorVisible || auxBarVisible; } - protected override _topRightSectionChildren(sessionsNode: ISerializedNode, editorNode: ISerializedNode, _auxiliaryBarNode: ISerializedNode): ISerializedNode[] { + protected override _topRightSectionChildren(sessionsNode: ISerializedNode, editorNode: ISerializedNode, _auxiliaryBarNode: ISerializedNode, customViewGridNode: ISerializedNode): ISerializedNode[] { // The auxiliary bar is inside the editor part and omitted from the grid. - return [sessionsNode, editorNode]; + return [sessionsNode, editorNode, customViewGridNode]; } protected override _layoutSidePane(): void { this._layoutDockedAuxBar(); } + protected override _applyEditorAreaVisibility(): void { + // The auxiliary bar is docked inside the editor node rather than being a + // grid view of its own, so the node covers both. + this.workbenchGrid.setViewVisible(this.editorPartView, this._editorNodeShouldBeVisible()); + this._layoutDockedAuxBar(); + } + protected override _onGridDidChange(): void { this._syncEditorVisibility(this.workbenchGrid.getViewSize(this.editorPartView).width); } @@ -292,7 +299,7 @@ export class SinglePaneWorkbench extends Workbench { const shouldRestoreSavedWidth = !hidden && !shouldRestoreDockedEditorSize && canRestoreSavedWidth; const shouldApplyEvenSplit = !hidden && !shouldRestoreDockedEditorSize && !shouldRestoreSavedWidth; - this.workbenchGrid.setViewVisible(this.editorPartView, this.partVisibility.editor || this.partVisibility.auxiliaryBar); + this.workbenchGrid.setViewVisible(this.editorPartView, this._editorNodeShouldBeVisible()); if (hidden) { // Only "Hide Editor" (detail still visible) keeps the editor grid node @@ -373,7 +380,7 @@ export class SinglePaneWorkbench extends Workbench { if (this.workbenchGrid) { this.workbenchGrid.setViewVisible( this.editorPartView, - this.partVisibility.editor || this.partVisibility.auxiliaryBar + this._editorNodeShouldBeVisible() ); if (!hidden && !this.partVisibility.editor) { this._syncingEditorVisibility = true; diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 69438ed9c74..1ee6deedd6a 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -66,20 +66,24 @@ import { EditorMarkdownCodeBlockRenderer } from '../../editor/browser/widget/mar import { SyncDescriptor } from '../../platform/instantiation/common/descriptors.js'; import { TitleService } from './parts/titlebarPart.js'; import { EDITOR_PART_DEFAULT_WIDTH, EDITOR_PART_MINIMUM_WIDTH } from './parts/editorPartSizing.js'; -import { IContextKeyService } from '../../platform/contextkey/common/contextkey.js'; -import { EditorMaximizedContext, IsPhoneLayoutContext, SinglePaneLayoutEnabledContext } from '../common/contextkeys.js'; +import { IContextKey, IContextKeyService } from '../../platform/contextkey/common/contextkey.js'; +import { CustomViewVisibleContext, EditorMaximizedContext, IsPhoneLayoutContext, SinglePaneLayoutEnabledContext } from '../common/contextkeys.js'; import { NotificationsPosition, NotificationsSettings, getNotificationsPosition } from '../../workbench/common/notifications.js'; import { SessionsLayoutPolicy } from './layoutPolicy.js'; +import { AGENTS_PART_CARD_CLASS } from './parts/agentsPartCard.js'; import { MobileNavigationStack } from './mobileNavigationStack.js'; import { MobileTitlebarPart } from './parts/mobile/mobileTitlebarPart.js'; import { IMobileVisualViewport } from './parts/mobile/mobileVisualViewport.js'; import { autorun } from '../../base/common/observable.js'; import { ISessionsService } from '../services/sessions/browser/sessionsService.js'; import { ISessionsPartService } from '../services/sessions/browser/sessionsPartService.js'; +import { ICustomViewService } from '../services/customView/browser/customViewService.js'; +import { ICustomViewGridPartService } from '../services/customView/browser/customViewGridPartService.js'; +import { ICustomViewDescriptor } from '../services/customView/browser/customView.js'; import { ISessionsSetUpService } from './sessionsSetUpService.js'; //#region Workbench Options @@ -102,6 +106,7 @@ enum LayoutClasses { AUXILIARYBAR_HIDDEN = 'noauxiliarybar', EDITOR_PANE_HIDDEN = 'noeditorpane', SESSIONS_HIDDEN = 'nosessionspart', + CUSTOM_VIEW_GRID_HIDDEN = 'nocustomviewgrid', STATUSBAR_HIDDEN = 'nostatusbar', SHELL_GRADIENT_BACKGROUND = 'shell-gradient-background', FULLSCREEN = 'fullscreen', @@ -120,6 +125,7 @@ export interface IPartVisibilityState { editor: boolean; panel: boolean; sessions: boolean; + customViewGrid: boolean; } interface IPartSizesState { @@ -354,6 +360,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic protected editorPartView!: ISerializableView; protected sessionsPartView!: ISerializableView; + protected customViewGridPartView!: ISerializableView; /** The editor part container; the auxiliary bar is docked inside it. */ protected _editorPartContainer: HTMLElement | undefined; @@ -369,7 +376,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic auxiliaryBar: true, editor: false, panel: false, - sessions: true + sessions: true, + customViewGrid: false }; private mainWindowFullscreen = false; @@ -380,6 +388,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private readonly mobileTopBarDisposables = this._register(new DisposableStore()); private _editorMaximized = false; + private _customViewVisibleKey!: IContextKey; + /** Guards the grid updates that show/hide the custom view from feeding back into the desired part visibility. */ + private _applyingCustomViewGridVisibility = false; private _editorLastNonMaximizedVisibility: IPartVisibilityState | undefined; private _editorLastNonMaximizedSize: IViewSize | undefined; private _restoreAttachedEditorMaximizedOnShow = false; @@ -407,6 +418,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private viewDescriptorService!: IViewDescriptorService; private sessionsService!: ISessionsService; private sessionsPartService!: ISessionsPartService; + private customViewService!: ICustomViewService; + private customViewGridPartService!: ICustomViewGridPartService; private instantiationService!: IInstantiationService; private storageService!: IStorageService; @@ -785,7 +798,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // size (wide) here would restore a wide node on reload and flicker the editor // open via the width-based reveal-sync. Classic layout is unaffected // (`_editorNodeVisible` returns `partVisibility.editor` there). - const editorNodeVisible = this._editorNodeVisible(this.partVisibility.editor, this.partVisibility.auxiliaryBar); + const editorNodeVisible = this._editorNodeShouldBeVisible(); const editorGridWidth = this._persistedGridViewSize(this.editorPartView, 'width', editorNodeVisible); let editorWidth = this._persistedEditorWidth(editorGridWidth); @@ -808,10 +821,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic const sizes: IPartSizesState = { sidebar: this._persistedGridViewSize(this.sideBarPartView, 'width', this.partVisibility.sidebar), - auxiliaryBar: this._persistedGridViewSize(this.auxiliaryBarPartView, 'width', this.partVisibility.auxiliaryBar), - sessions: this._persistedGridViewSize(this.sessionsPartView, 'width', this.partVisibility.sessions), + auxiliaryBar: this._persistedGridViewSize(this.auxiliaryBarPartView, 'width', this._effectiveVisible(Parts.AUXILIARYBAR_PART)), + sessions: this._persistedGridViewSize(this.sessionsPartView, 'width', this._effectiveVisible(Parts.SESSIONS_PART)), editor: editorWidth, - panel: this._persistedGridViewSize(this.panelPartView, 'height', this.partVisibility.panel), + panel: this._persistedGridViewSize(this.panelPartView, 'height', this._effectiveVisible(Parts.PANEL_PART)), }; this.storageService.store(Workbench._PART_SIZES_KEY, JSON.stringify(sizes), StorageScope.WORKSPACE, StorageTarget.MACHINE); @@ -886,6 +899,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Create Sessions Part this.createSessionsPart(); + // Create Custom View Grid Part (hidden by default) + this.createCustomViewGridPart(); + // Notification Handlers this.createNotificationsHandlers(instantiationService, notificationService, configurationService); @@ -1051,7 +1067,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private createSessionsPart(): void { const sessionsPartContainer = document.createElement('div'); - sessionsPartContainer.classList.add('part', 'sessionspart', 'basepanel', 'right'); + sessionsPartContainer.classList.add('part', 'sessionspart', 'basepanel', 'right', AGENTS_PART_CARD_CLASS); sessionsPartContainer.id = Parts.SESSIONS_PART; sessionsPartContainer.setAttribute('role', 'main'); @@ -1062,6 +1078,19 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.mainContainer.appendChild(sessionsPartContainer); } + private createCustomViewGridPart(): void { + const customViewGridPartContainer = document.createElement('div'); + customViewGridPartContainer.classList.add('part', 'customviewgridpart', 'basepanel', 'right', AGENTS_PART_CARD_CLASS); + customViewGridPartContainer.id = Parts.CUSTOM_VIEW_GRID_PART; + customViewGridPartContainer.setAttribute('role', 'main'); + + mark(`code/willCreatePart/${Parts.CUSTOM_VIEW_GRID_PART}`); + this.getPart(Parts.CUSTOM_VIEW_GRID_PART).create(customViewGridPartContainer); + mark(`code/didCreatePart/${Parts.CUSTOM_VIEW_GRID_PART}`); + + this.mainContainer.appendChild(customViewGridPartContainer); + } + private restore(lifecycleService: ILifecycleService): void { // Update perf marks mark('code/didStartWorkbench'); @@ -1121,6 +1150,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Forces eager creation of the sessions part so it registers itself with the // layout service before renderWorkbench() looks it up via getPart(). this.sessionsPartService = accessor.get(ISessionsPartService); + this.customViewService = accessor.get(ICustomViewService); + // Same for the custom view grid part. + this.customViewGridPartService = accessor.get(ICustomViewGridPartService); this.instantiationService = accessor.get(IInstantiationService); this.storageService = accessor.get(IStorageService); accessor.get(ITitleService); @@ -1131,6 +1163,13 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Register layout listeners this.registerLayoutListeners(); + // A custom view replaces the sessions grid (and the editor, side panel and + // bottom panel) for as long as it is shown. + this._customViewVisibleKey = CustomViewVisibleContext.bindTo(accessor.get(IContextKeyService)); + this._register(autorun(reader => { + this._applyCustomViewGridVisibility(this.customViewService.activeCustomView.read(reader)); + })); + // Editor opens should only affect the main editor part when // they actually target one of the main editor groups. Modal // opens stay neutral. Programmatic opens that suppress auto @@ -1318,8 +1357,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return editorVisible; } - protected _topRightSectionChildren(sessionsNode: ISerializedNode, editorNode: ISerializedNode, auxiliaryBarNode: ISerializedNode): ISerializedNode[] { - return [sessionsNode, editorNode, auxiliaryBarNode]; + protected _topRightSectionChildren(sessionsNode: ISerializedNode, editorNode: ISerializedNode, auxiliaryBarNode: ISerializedNode, customViewGridNode: ISerializedNode): ISerializedNode[] { + return [sessionsNode, editorNode, auxiliaryBarNode, customViewGridNode]; } /** Attach any per-layout controllers once the editor part container exists. */ @@ -1343,7 +1382,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // editor is hidden) before revealing, so the even split can halve it. const mainAreaWidth = this.workbenchGrid.getViewSize(this.sessionsPartView).width; - this.workbenchGrid.setViewVisible(this.editorPartView, !hidden); + this.workbenchGrid.setViewVisible(this.editorPartView, this._editorNodeShouldBeVisible()); if (shouldApplyEvenSplit) { this._hasAppliedInitialEditorSplit = true; @@ -1358,7 +1397,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // BlockRestore contribution) runs before createWorkbenchLayout(), so the // visibility is recorded in partVisibility and applied when the grid is built. if (this.workbenchGrid) { - this.workbenchGrid.setViewVisible(this.auxiliaryBarPartView, !hidden); + this.workbenchGrid.setViewVisible(this.auxiliaryBarPartView, this._effectiveVisible(Parts.AUXILIARYBAR_PART)); } } @@ -1417,6 +1456,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic const auxiliaryBarPart = this.getPart(Parts.AUXILIARYBAR_PART); const sideBar = this.getPart(Parts.SIDEBAR_PART); const sessionsPart = this.getPart(Parts.SESSIONS_PART); + const customViewGridPart = this.getPart(Parts.CUSTOM_VIEW_GRID_PART); // View references for parts in the grid this.titleBarPartView = titleBar; @@ -1424,6 +1464,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.panelPartView = panelPart; this.auxiliaryBarPartView = auxiliaryBarPart; this.sessionsPartView = sessionsPart; + this.customViewGridPartView = customViewGridPart; this.editorPartView = editorPart; const viewMap: { [key: string]: ISerializableView } = { @@ -1432,6 +1473,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic [Parts.SIDEBAR_PART]: this.sideBarPartView, [Parts.AUXILIARYBAR_PART]: this.auxiliaryBarPartView, [Parts.SESSIONS_PART]: this.sessionsPartView, + [Parts.CUSTOM_VIEW_GRID_PART]: this.customViewGridPartView, [Parts.EDITOR_PART]: this.editorPartView }; @@ -1457,6 +1499,13 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Listen for part visibility changes (for parts in grid) for (const part of [titleBar, panelPart, sideBar, auxiliaryBarPart, sessionsPart, editorPart]) { this._register(part.onDidVisibilityChange(visible => { + // A custom view renders over these parts without changing what the layout + // wants them to be, so its grid updates must not feed back into the + // desired state — otherwise there is nothing left to restore. + if (this._applyingCustomViewGridVisibility) { + return; + } + // The editor part's grid-view visibility is fully owned by // `_onEditorPartGridVisibilityChange`: in the classic layout it maps to // the editor visibility and raises the part-visibility event; single-pane @@ -1496,6 +1545,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic case 'auxbar': this.setAuxiliaryBarHidden(true); break; + case 'customView': + this.customViewService.hideCustomView(); + break; case 'editor': // Editor modal close is handled by the editor service break; @@ -1591,36 +1643,45 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic type: 'leaf', data: { type: Parts.SESSIONS_PART }, size: sessionsWidth, - visible: this.partVisibility.sessions + visible: this._effectiveVisible(Parts.SESSIONS_PART) + }; + + // Mutually exclusive with the sessions part (and the editor / auxiliary bar / + // panel), so it always claims the full row when it is visible. + const customViewGridNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.CUSTOM_VIEW_GRID_PART }, + size: rightSectionWidth, + visible: this.partVisibility.customViewGrid }; const editorNode: ISerializedLeafNode = { type: 'leaf', data: { type: Parts.EDITOR_PART }, size: this._editorNodeSize(effectiveEditorWidth, effectiveAuxBarWidth), - visible: this._editorNodeVisible(this.partVisibility.editor, this.partVisibility.auxiliaryBar) + visible: this._editorNodeShouldBeVisible() }; const auxiliaryBarNode: ISerializedLeafNode = { type: 'leaf', data: { type: Parts.AUXILIARYBAR_PART }, size: auxiliaryBarSize, - visible: this.partVisibility.auxiliaryBar + visible: this._effectiveVisible(Parts.AUXILIARYBAR_PART) }; const panelNode: ISerializedLeafNode = { type: 'leaf', data: { type: Parts.PANEL_PART }, size: panelSize, - visible: this.partVisibility.panel + visible: this._effectiveVisible(Parts.PANEL_PART) }; - // Top right section: Chat Bar | Editor [| Auxiliary Bar] (horizontal). + // Top right section: Chat Bar | Editor [| Auxiliary Bar] | Custom View Grid (horizontal). // When docked, the auxiliary bar is inside the editor part and // omitted from the grid; otherwise it is its own trailing grid column. const topRightSection: ISerializedNode = { type: 'branch', - data: this._topRightSectionChildren(sessionsNode, editorNode, auxiliaryBarNode), + data: this._topRightSectionChildren(sessionsNode, editorNode, auxiliaryBarNode, customViewGridNode), size: topRightHeight }; @@ -1715,9 +1776,11 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Re-run updateStyles() on pane composite parts so that // mobile Part subclasses can re-apply or clear card-chrome // inline styles based on the new `.phone-layout` class. - for (const partId of [Parts.SESSIONS_PART, Parts.SIDEBAR_PART, Parts.AUXILIARYBAR_PART, Parts.PANEL_PART]) { + for (const partId of [Parts.SESSIONS_PART, Parts.CUSTOM_VIEW_GRID_PART, Parts.SIDEBAR_PART, Parts.AUXILIARYBAR_PART, Parts.PANEL_PART]) { this.parts.get(partId)?.updateStyles(); } + + this._updateMobileCustomViewNavigation(); } this._previousViewportClass = currentClass; @@ -1821,11 +1884,12 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic getLayoutClasses(): string[] { return coalesce([ !this.partVisibility.sidebar ? LayoutClasses.SIDEBAR_HIDDEN : undefined, - !this.partVisibility.editor ? LayoutClasses.MAIN_EDITOR_AREA_HIDDEN : undefined, - !this.partVisibility.panel ? LayoutClasses.PANEL_HIDDEN : undefined, - !this.partVisibility.auxiliaryBar ? LayoutClasses.AUXILIARYBAR_HIDDEN : undefined, + !this._effectiveVisible(Parts.EDITOR_PART) ? LayoutClasses.MAIN_EDITOR_AREA_HIDDEN : undefined, + !this._effectiveVisible(Parts.PANEL_PART) ? LayoutClasses.PANEL_HIDDEN : undefined, + !this._effectiveVisible(Parts.AUXILIARYBAR_PART) ? LayoutClasses.AUXILIARYBAR_HIDDEN : undefined, !this.isEditorPaneVisible() ? LayoutClasses.EDITOR_PANE_HIDDEN : undefined, - !this.partVisibility.sessions ? LayoutClasses.SESSIONS_HIDDEN : undefined, + !this._effectiveVisible(Parts.SESSIONS_PART) ? LayoutClasses.SESSIONS_HIDDEN : undefined, + !this.partVisibility.customViewGrid ? LayoutClasses.CUSTOM_VIEW_GRID_HIDDEN : undefined, LayoutClasses.STATUSBAR_HIDDEN, // agents window never has a status bar this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined, this.layoutPolicy.viewportClass.get() === 'phone' ? LayoutClasses.PHONE_LAYOUT : undefined, @@ -1833,7 +1897,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } isEditorPaneVisible(): boolean { - return this.partVisibility.editor || this.partVisibility.auxiliaryBar; + return this._effectiveVisible(Parts.EDITOR_PART) || this._effectiveVisible(Parts.AUXILIARYBAR_PART); } private _updateEditorPaneVisibilityClass(): void { @@ -1892,6 +1956,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // TODO: focus chat bar content once it is wired up this.getPart(Parts.SESSIONS_PART).getContainer()?.focus(); break; + case Parts.CUSTOM_VIEW_GRID_PART: + this.customViewGridPartService.focusActiveView(); + break; default: { const container = this.getContainer(targetWindow, part); container?.focus(); @@ -1942,6 +2009,48 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return true; // No activity bar in this layout } + /** + * Parts a visible custom view replaces. While the custom view grid is shown + * these keep their desired (per-session) visibility state but are not + * rendered, so hiding the custom view restores whatever the layout + * controller last asked for — including changes made while it was shown. + */ + private static readonly _CUSTOM_VIEW_EXCLUSIVE_PARTS = [ + Parts.SESSIONS_PART, + Parts.EDITOR_PART, + Parts.AUXILIARYBAR_PART, + Parts.PANEL_PART + ] as const; + + /** The desired visibility of a part, ignoring any custom view showing over it. */ + private _desiredVisible(part: Parts): boolean { + switch (part) { + case Parts.SESSIONS_PART: + return this.partVisibility.sessions; + case Parts.EDITOR_PART: + return this.partVisibility.editor; + case Parts.AUXILIARYBAR_PART: + return this.partVisibility.auxiliaryBar; + case Parts.PANEL_PART: + return this.partVisibility.panel; + default: + return false; + } + } + + /** Whether a part is actually rendered right now. */ + protected _effectiveVisible(part: Parts): boolean { + return this._desiredVisible(part) && !this.partVisibility.customViewGrid; + } + + /** + * Whether the editor grid node should be shown. In the single-pane layout the + * node also hosts the docked auxiliary bar, so it follows both parts. + */ + protected _editorNodeShouldBeVisible(): boolean { + return this._editorNodeVisible(this._effectiveVisible(Parts.EDITOR_PART), this._effectiveVisible(Parts.AUXILIARYBAR_PART)); + } + isVisible(part: SINGLE_WINDOW_PARTS): boolean; isVisible(part: MULTI_WINDOW_PARTS, targetWindow: Window): boolean; isVisible(part: Parts, targetWindow?: Window): boolean { @@ -1952,13 +2061,12 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic case Parts.SIDEBAR_PART: return this.partVisibility.sidebar; case Parts.AUXILIARYBAR_PART: - return this.partVisibility.auxiliaryBar; case Parts.EDITOR_PART: - return this.partVisibility.editor; case Parts.PANEL_PART: - return this.partVisibility.panel; case Parts.SESSIONS_PART: - return this.partVisibility.sessions; + return this._effectiveVisible(part); + case Parts.CUSTOM_VIEW_GRID_PART: + return this.partVisibility.customViewGrid; case Parts.ACTIVITYBAR_PART: case Parts.STATUSBAR_PART: case Parts.BANNER_PART: @@ -1988,6 +2096,11 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } toggleSecondarySideBar(): void { + // The side panel is replaced by the custom view grid while one is shown. + if (this.partVisibility.customViewGrid) { + return; + } + const visible = !this.isSecondarySideBarVisible(); this.setAuxiliaryBarHidden(!visible); alert(visible @@ -2058,7 +2171,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this._onWillHideAuxiliaryBar(hidden); this.partVisibility.auxiliaryBar = !hidden; - this.mainContainer.classList.toggle(LayoutClasses.AUXILIARYBAR_HIDDEN, hidden); + this.mainContainer.classList.toggle(LayoutClasses.AUXILIARYBAR_HIDDEN, !this._effectiveVisible(Parts.AUXILIARYBAR_PART)); this._applyAuxiliaryBarVisibility(hidden, source); this._updateEditorPaneVisibilityClass(); @@ -2121,7 +2234,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } this.partVisibility.editor = !hidden; - this.mainContainer.classList.toggle(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN, hidden); + this.mainContainer.classList.toggle(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN, !this._effectiveVisible(Parts.EDITOR_PART)); if (this.editorPartView) { this._applyEditorVisibility(hidden); @@ -2172,12 +2285,12 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic const panelHadFocus = !hidden || this.hasFocus(Parts.PANEL_PART); this.partVisibility.panel = !hidden; - this.mainContainer.classList.toggle(LayoutClasses.PANEL_HIDDEN, hidden); + this.mainContainer.classList.toggle(LayoutClasses.PANEL_HIDDEN, !this._effectiveVisible(Parts.PANEL_PART)); // Propagate to grid this.workbenchGrid.setViewVisible( this.panelPartView, - !hidden, + this._effectiveVisible(Parts.PANEL_PART), ); // If panel becomes hidden, also hide the current active pane composite @@ -2200,7 +2313,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } } - this.focusPart(Parts.PANEL_PART); + // A custom view is showing over the panel, so it must not take focus. + if (this._effectiveVisible(Parts.PANEL_PART)) { + this.focusPart(Parts.PANEL_PART); + } } } @@ -2210,10 +2326,121 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } this.partVisibility.sessions = !hidden; - this.mainContainer.classList.toggle(LayoutClasses.SESSIONS_HIDDEN, hidden); + this.mainContainer.classList.toggle(LayoutClasses.SESSIONS_HIDDEN, !this._effectiveVisible(Parts.SESSIONS_PART)); // Propagate to grid - this.workbenchGrid.setViewVisible(this.sessionsPartView, !hidden); + this.workbenchGrid.setViewVisible(this.sessionsPartView, this._effectiveVisible(Parts.SESSIONS_PART)); + } + + /** + * Shows or hides the custom view grid. The custom view grid and the sessions + * grid are mutually exclusive and exactly one of them owns the row, so hiding + * the custom view always brings the sessions grid back (together with the side + * panel and panel state the layout wants for the active session). The parts it + * covers keep their desired visibility while it is shown, so the restore + * reflects whatever the layout controller last asked for. + */ + private _applyCustomViewGridVisibility(descriptor: ICustomViewDescriptor | undefined): void { + const visible = !!descriptor; + if (this.partVisibility.customViewGrid === visible) { + // Swapping one custom view for another only changes what is rendered. + this.customViewGridPartService.setView(descriptor); + return; + } + + const wasVisible = Workbench._CUSTOM_VIEW_EXCLUSIVE_PARTS.map(part => this._effectiveVisible(part)); + + // A maximized editor owns the row instead of the sessions grid, which would + // leave the row without an owner once the custom view goes away. + if (visible && this._editorMaximized) { + this.setEditorMaximized(false); + } + + this.customViewGridPartService.setView(descriptor); + this.partVisibility.customViewGrid = visible; + this._customViewVisibleKey.set(visible); + + if (!this.workbenchGrid) { + return; // still starting up; the grid descriptor picks this state up + } + + this._applyingCustomViewGridVisibility = true; + try { + // Suspended so the single-pane width sync cannot read the transient node + // widths as a sash drag and write back the desired visibility. + this._runWithEditorResizeSyncSuspended(() => { + // One pass, revealing before hiding so the row never goes empty in between. + if (visible) { + this.workbenchGrid.setViewVisible(this.customViewGridPartView, true); + this._applyExclusivePartVisibility(); + } else { + this._applyExclusivePartVisibility(); + this.workbenchGrid.setViewVisible(this.customViewGridPartView, false); + } + }); + } finally { + this._applyingCustomViewGridVisibility = false; + } + + this._updateExclusiveLayoutClasses(); + this.mainContainer.classList.toggle(LayoutClasses.CUSTOM_VIEW_GRID_HIDDEN, !visible); + this._updateMobileCustomViewNavigation(); + + // Mirror the reveal-before-hide order of the grid updates. + if (visible) { + this._fireDidChangePartVisibility(Parts.CUSTOM_VIEW_GRID_PART, true); + } + Workbench._CUSTOM_VIEW_EXCLUSIVE_PARTS.forEach((part, index) => { + const nowVisible = this._effectiveVisible(part); + if (nowVisible !== wasVisible[index]) { + this._fireDidChangePartVisibility(part, nowVisible); + } + }); + if (!visible) { + this._fireDidChangePartVisibility(Parts.CUSTOM_VIEW_GRID_PART, false); + } + + this.layout(); + + if (visible) { + this.focusPart(Parts.CUSTOM_VIEW_GRID_PART); + } else { + this.sessionsPartService.focusSession(this.sessionsService.activeSession.get()); + } + } + + private _applyExclusivePartVisibility(): void { + this.workbenchGrid.setViewVisible(this.sessionsPartView, this._effectiveVisible(Parts.SESSIONS_PART)); + this.workbenchGrid.setViewVisible(this.panelPartView, this._effectiveVisible(Parts.PANEL_PART)); + this._applyEditorAreaVisibility(); + } + + /** Pushes the editor and auxiliary bar node visibility into the grid. */ + protected _applyEditorAreaVisibility(): void { + this.workbenchGrid.setViewVisible(this.editorPartView, this._editorNodeShouldBeVisible()); + this.workbenchGrid.setViewVisible(this.auxiliaryBarPartView, this._effectiveVisible(Parts.AUXILIARYBAR_PART)); + } + + private _updateExclusiveLayoutClasses(): void { + this.mainContainer.classList.toggle(LayoutClasses.SESSIONS_HIDDEN, !this._effectiveVisible(Parts.SESSIONS_PART)); + this.mainContainer.classList.toggle(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN, !this._effectiveVisible(Parts.EDITOR_PART)); + this.mainContainer.classList.toggle(LayoutClasses.AUXILIARYBAR_HIDDEN, !this._effectiveVisible(Parts.AUXILIARYBAR_PART)); + this.mainContainer.classList.toggle(LayoutClasses.PANEL_HIDDEN, !this._effectiveVisible(Parts.PANEL_PART)); + this._updateEditorPaneVisibilityClass(); + } + + /** Keeps the Android back button in sync with a shown custom view. */ + private _updateMobileCustomViewNavigation(): void { + const tracked = this.layoutPolicy.viewportClass.get() === 'phone' && this.partVisibility.customViewGrid; + if (tracked === this.mobileNavStack.has('customView')) { + return; + } + + if (tracked) { + this.mobileNavStack.push('customView'); + } else { + this.mobileNavStack.popSilently('customView'); + } } //#endregion @@ -2297,6 +2524,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return this.panelPartView; case Parts.SESSIONS_PART: return this.sessionsPartView; + case Parts.CUSTOM_VIEW_GRID_PART: + return this.customViewGridPartView; default: return undefined; } @@ -2370,6 +2599,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic editor: this.partVisibility.editor, panel: this.partVisibility.panel, sessions: this.partVisibility.sessions, + customViewGrid: this.partVisibility.customViewGrid, }; // Save the editor part size so it can be restored on un-maximize. diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index e69b6640739..25faa5fb5d8 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -39,6 +39,7 @@ export const SessionIsReadContext = new RawContextKey('sessionIsRead', export const SessionIsArchivedContext = new RawContextKey('sessionIsArchived', false, localize('sessionIsArchived', "Whether the session in scope is archived/marked as done (the active session globally, or a specific session within an isolated component such as the session view or a context menu overlay)")); export const SessionHasChangesContext = new RawContextKey('sessionHasChanges', false, localize('sessionHasChanges', "Whether the session view's session has pending changes (insertions or deletions)")); export const SessionHasPullRequestContext = new RawContextKey('sessionHasPullRequest', false, localize('sessionHasPullRequest', "Whether the session view's session is associated with a GitHub pull request")); +export const SessionHasIssuesContext = new RawContextKey('sessionHasIssues', false, localize('sessionHasIssues', "Whether the session view's session references at least one GitHub issue")); export const SessionHasWorkspaceContext = new RawContextKey('sessionHasWorkspace', false, localize('sessionHasWorkspace', "Whether the session view's session has an associated workspace folder")); export const IsQuickChatSessionContext = new RawContextKey('isQuickChatSession', false, localize('isQuickChatSession', "Whether the session in scope is a workspace-less quick chat")); @@ -53,6 +54,12 @@ export const MultipleSessionsVisibleContext = new RawContextKey('multip //#endregion +//#region < --- Custom View Grid --- > + +export const CustomViewVisibleContext = new RawContextKey('customViewVisible', false, localize('customViewVisible', "Whether a custom view is shown in place of the sessions grid. The side panel and the panel are hidden while it is.")); + +//#endregion + //#region < --- Welcome --- > export const SessionsWelcomeVisibleContext = new RawContextKey('sessionsWelcomeVisible', false, localize('sessionsWelcomeVisible', "Whether the sessions welcome overlay is visible")); diff --git a/src/vs/sessions/common/layoutConstants.ts b/src/vs/sessions/common/layoutConstants.ts new file mode 100644 index 00000000000..b89a90efcd8 --- /dev/null +++ b/src/vs/sessions/common/layoutConstants.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const AGENTS_FLOATING_PANEL_GAP = 5; +export const AGENTS_CENTERED_CONTENT_MAX_WIDTH = 950; diff --git a/src/vs/sessions/common/sizes.ts b/src/vs/sessions/common/sizes.ts index 7043df8510e..1691e52ed6a 100644 --- a/src/vs/sessions/common/sizes.ts +++ b/src/vs/sessions/common/sizes.ts @@ -12,20 +12,18 @@ import { localize } from '../../nls.js'; import { registerSize, sizeForAllThemes } from '../../platform/theme/common/sizeUtils.js'; +import { AGENTS_FLOATING_PANEL_GAP } from './layoutConstants.js'; // ============================================================================ // Agents window — layout // ============================================================================ -export const AGENTS_FLOATING_PANEL_GAP = 5; - /** Gap between floating panels in the Agents window. */ export const agentsLayoutFloatingPanelGap = registerSize( 'agents.layout.floatingPanelGap', sizeForAllThemes(AGENTS_FLOATING_PANEL_GAP, 'px'), localize('agents.layout.floatingPanelGap', "Gap between floating panels in the Agents window.") ); - // ============================================================================ // Agents window — font ramp // ============================================================================ diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts index 699ef2b94f9..97960448aaf 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts @@ -182,19 +182,26 @@ export class AgentFeedbackOverlayWidget extends Disposable { } } -class AgentFeedbackOverlayController { +export interface IAgentFeedbackOverlayEditorGroup extends IEditorGroup { + readonly editorPaneContainer: HTMLElement; +} + +export class AgentFeedbackOverlayController { private readonly _store = new DisposableStore(); private readonly _domNode = document.createElement('div'); constructor( - container: HTMLElement, - group: IEditorGroup, + group: IAgentFeedbackOverlayEditorGroup, @IAgentFeedbackService agentFeedbackService: IAgentFeedbackService, @IInstantiationService instaService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, @ICodeReviewService codeReviewService: ICodeReviewService, ) { + const container = group.editorPaneContainer; + container.classList.add('agent-feedback-editor-overlay-host'); + this._store.add(toDisposable(() => container.classList.remove('agent-feedback-editor-overlay-host'))); + this._domNode.classList.add('agent-feedback-editor-overlay'); this._domNode.style.position = 'absolute'; this._domNode.style.bottom = '24px'; @@ -305,7 +312,7 @@ export class AgentFeedbackEditorOverlay implements IWorkbenchContribution { new ServiceCollection([IContextKeyService, group.scopedContextKeyService]) ); - const ctrl = scopedInstaService.createInstance(AgentFeedbackOverlayController, group.element, group); + const ctrl = scopedInstaService.createInstance(AgentFeedbackOverlayController, group); overlayWidgets.set(group, combinedDisposable(ctrl, scopedInstaService)); } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 8e8b89ae0a3..bd3753c9c6a 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { DeferredPromise, raceTimeout } from '../../../../base/common/async.js'; +import { createSingleCallFunction } from '../../../../base/common/functional.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { derived, IObservable, runOnChange } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; @@ -20,7 +22,7 @@ import { ISessionsService } from '../../../services/sessions/browser/sessionsSer import { editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js'; import { changeMatchesResource, getActiveResourceCandidates, IAgentFeedbackContext } from './agentFeedbackEditorUtils.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; -import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatWidget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; import { ISession, ISessionFileChange, ISessionWorkspace, SessionStatus } from '../../../services/sessions/common/session.js'; @@ -42,6 +44,57 @@ export { AgentFeedbackKind, AgentFeedbackState, type IAgentFeedback }; /** Shared feedback scope for every undefined or uncreated active session. */ export const AGENT_FEEDBACK_NEW_SESSION_RESOURCE = URI.from({ scheme: 'agent-feedback', path: '/new-session' }); +/** + * How long submitting feedback waits for the session's chat model to be loaded into a chat widget + * before giving up. + */ +const WIDGET_LOAD_TIMEOUT_MS = 10_000; + +/** + * Resolves the chat widget that has the session loaded, waiting for it to appear when the session's + * model has not been loaded into a widget yet. + * + * Feedback can be submitted (e.g. from the Changes editor or the comments input banner) while the + * session is still being restored into its chat widget. `getWidgetBySessionResource` matches on the + * widget's *loaded* view model, so it returns `undefined` until the model arrives — submitting then + * would silently drop the feedback. Resolves `undefined` if no widget loads the session in time. + * + * Exported for tests. + */ +export async function whenWidgetForSession(chatWidgetService: IChatWidgetService, sessionResource: URI, timeoutMs: number = WIDGET_LOAD_TIMEOUT_MS): Promise { + const existing = chatWidgetService.getWidgetBySessionResource(sessionResource); + if (existing) { + return existing; + } + + const store = new DisposableStore(); + try { + const loaded = new Promise(resolve => { + const check = () => { + const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); + if (widget) { + resolve(widget); + } + }; + + const observe = (candidate: IChatWidget) => store.add(candidate.onDidChangeViewModel(check)); + + chatWidgetService.getAllWidgets().forEach(observe); + store.add(chatWidgetService.onDidAddWidget(added => { + observe(added); + check(); + })); + + // A widget may have loaded the session while the listeners were being wired up. + check(); + }); + + return await raceTimeout(loaded, timeoutMs); + } finally { + store.dispose(); + } +} + export interface INavigableSessionComment { readonly id: string; } @@ -254,7 +307,10 @@ export interface IAgentFeedbackService { /** * Submit the currently accumulated accepted feedback for the session to the - * agent and mark those items as submitted. Returns whether the feedback was submitted. + * agent and mark those items as submitted. Waits for the session's chat model to be loaded + * into a chat widget, then resolves once the request has been accepted by that widget — which, + * while another request is in progress, means it was queued rather than sent. Returns whether + * the feedback was submitted. */ submitFeedback(sessionResource: URI): Promise; @@ -803,9 +859,9 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe if (!this._isAgentHostSession(sessionResource)) { // Wait for the attachment contribution to update the chat widget's attachment model - const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource); + const widget = await whenWidgetForSession(this._chatWidgetService, sessionResource); if (widget) { - const attachmentId = 'agentFeedback:' + sessionResource.toString(); + const attachmentId = ATTACHMENT_ID_PREFIX + sessionResource.toString(); const hasAttachment = () => widget.attachmentModel.attachments.some(a => a.id === attachmentId); if (!hasAttachment()) { @@ -815,7 +871,6 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe } } else { this._logService.error('[AgentFeedback] addFeedbackAndSubmit: no chat widget found for session, feedback may not be submitted correctly', sessionResource.toString()); - await new Promise(resolve => setTimeout(resolve, 100)); } } @@ -835,7 +890,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe return this._sessionsService.submitNewSessionInput(); } - const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource); + const widget = await whenWidgetForSession(this._chatWidgetService, sessionResource); if (!widget) { this._logService.error('[AgentFeedback] submitFeedback: no chat widget found for session', sessionResource.toString()); return false; @@ -846,7 +901,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe // submitted via the "Submit Feedback" button). Attach the accepted // items — which are about to become submitted — to this single request // so the agent receives the comments, then remove the transient - // attachment again once the request has been sent. + // attachment again once the request has been accepted. if (this._isAgentHostSession(sessionResource)) { const acceptedItems = this.getFeedback(sessionResource).filter(item => item.state === AgentFeedbackState.Accepted); const attachmentId = ATTACHMENT_ID_PREFIX + sessionResource.toString(); @@ -856,32 +911,43 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe widget.attachmentModel.addContext(createAgentFeedbackVariableEntry(sessionResource, acceptedItems, annotationsResource)); } - try { - await widget.acceptInput('/act-on-feedback'); - } catch (err) { - this._logService.error('[AgentFeedback] Failed to submit feedback', err); - return false; - } finally { - widget.attachmentModel.delete(attachmentId); + return this._sendActOnFeedbackRequest(widget, sessionResource, () => widget.attachmentModel.delete(attachmentId)); + } + + // For non-agent-host sessions the reactive attachment contribution also + // marks submission on send; marking from the helper is idempotent and + // covers sessions without that contribution. + return this._sendActOnFeedbackRequest(widget, sessionResource); + } + + /** + * Sends the `/act-on-feedback` request and marks the accepted feedback as + * submitted as soon as the request has been accepted by the chat widget. + * The request is queued when the agent is still working on another request, + * in which case awaiting {@link IChatWidget.acceptInput} would only resolve + * once that queued request eventually runs — the feedback items must move to + * the submitted state right away. + */ + private _sendActOnFeedbackRequest(widget: IChatWidget, sessionResource: URI, cleanup?: () => void): Promise { + const submitted = new DeferredPromise(); + const cleanupOnce = cleanup && createSingleCallFunction(cleanup); + + widget.acceptInput('/act-on-feedback', { + onRequestAccepted: () => { + cleanupOnce?.(); + this.markFeedbackSubmitted(sessionResource); + submitted.complete(true); } - - this.markFeedbackSubmitted(sessionResource); - return true; - } - - // Send first so the accepted feedback is still attached to the request, - // then mark the items as submitted. For non-agent-host sessions the - // attachment contribution also marks submission on send; marking here is - // idempotent and covers sessions without that contribution. - try { - await widget.acceptInput('/act-on-feedback'); - } catch (err) { + }).then(() => { + cleanupOnce?.(); + submitted.complete(false); + }, err => { this._logService.error('[AgentFeedback] Failed to submit feedback', err); - return false; - } + cleanupOnce?.(); + submitted.complete(false); + }); - this.markFeedbackSubmitted(sessionResource); - return true; + return submitted.p; } markFeedbackSubmitted(sessionResource: URI): void { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css index ff76c10c317..c13c09b21e0 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css @@ -3,6 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.agent-feedback-editor-overlay-host { + position: relative; +} + .agent-feedback-editor-overlay-widget { padding: 2px 4px; color: var(--vscode-foreground); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlay.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlay.test.ts new file mode 100644 index 00000000000..17807640893 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlay.test.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { EditorGroupView } from '../../../../../workbench/browser/parts/editor/editorGroupView.js'; +import { IEditorGroupsService } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; +import { createEditorPart, workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; +import { ICodeReviewService } from '../../../codeReview/browser/codeReviewService.js'; +import { AgentFeedbackEditorOverlay } from '../../browser/agentFeedbackEditorOverlay.js'; +import { IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; + +suite('AgentFeedbackEditorOverlay', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('anchors the overlay host to the editor pane container', async () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + const editorPart = await createEditorPart(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, editorPart); + instantiationService.stub(IAgentFeedbackService, new class extends mock() { + override readonly onDidChangeFeedback = Event.None; + override readonly onDidChangeNavigation = Event.None; + override readonly onDidChangeFeedbackScope = Event.None; + }); + instantiationService.stub(ICodeReviewService, new class extends mock() { }); + + const group = editorPart.activeGroup; + assert.ok(group instanceof EditorGroupView); + const fullEditorWidth = Number.parseInt(group.editorPaneContainer.style.width, 10); + group.setContentRightInset(300); + + const contribution = instantiationService.createInstance(AgentFeedbackEditorOverlay); + assert.deepStrictEqual({ + editorPaneWidthReduction: fullEditorWidth - Number.parseInt(group.editorPaneContainer.style.width, 10), + editorPaneIsHost: group.editorPaneContainer.classList.contains('agent-feedback-editor-overlay-host'), + editorGroupIsHost: group.element.classList.contains('agent-feedback-editor-overlay-host'), + }, { + editorPaneWidthReduction: 300, + editorPaneIsHost: true, + editorGroupIsHost: false, + }); + + contribution.dispose(); + assert.strictEqual(group.editorPaneContainer.classList.contains('agent-feedback-editor-overlay-host'), false); + }); +}); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts index 7a345cefcec..adfde814666 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts @@ -11,12 +11,13 @@ import { Range } from '../../../../../editor/common/core/range.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { mock } from '../../../../../base/test/common/mock.js'; -import { AGENT_FEEDBACK_NEW_SESSION_RESOURCE, AgentFeedbackKind, AgentFeedbackService, AgentFeedbackState, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; +import { AGENT_FEEDBACK_NEW_SESSION_RESOURCE, AgentFeedbackKind, AgentFeedbackService, AgentFeedbackState, IAgentFeedbackService, whenWidgetForSession } from '../../browser/agentFeedbackService.js'; import { getSessionEditorComments } from '../../browser/sessionEditorComments.js'; import { IChatEditingService } from '../../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { IChatWidget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatWidget, IChatWidgetService, IChatAcceptInputOptions, IChatWidgetViewModelChangeEvent } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IAgentFeedbackVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IEditorService, IVisibleEditorsChangeEvent } from '../../../../../workbench/services/editor/common/editorService.js'; @@ -656,10 +657,21 @@ suite('AgentFeedbackService - Submit (agent host)', () => { let fileA: URI; let widgetOps: string[]; let addedEntries: IAgentFeedbackVariableEntry[]; + /** Resolves when the (possibly queued) request is actually sent, i.e. when `acceptInput` resolves. */ + let acceptInputSent: DeferredPromise; + /** Whether the widget hands the request over to the chat service. */ + let acceptsRequest: boolean; + /** Whether the widget has the session's chat model loaded. */ + let sessionLoaded: boolean; + /** Simulates the widget loading the session's chat model. */ + let loadSession: () => void; setup(() => { widgetOps = []; addedEntries = []; + acceptInputSent = new DeferredPromise(); + acceptsRequest = true; + sessionLoaded = true; const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(IChatEditingService, new class extends mock() { }); instantiationService.stub(ITelemetryService, NullTelemetryService); @@ -678,7 +690,9 @@ suite('AgentFeedbackService - Submit (agent host)', () => { }); instantiationService.stub(ISessionsService, { activeSession: observableValue('activeSession', undefined) } as unknown as ISessionsService); + const onDidChangeViewModel = store.add(new Emitter()); const widget = { + onDidChangeViewModel: onDidChangeViewModel.event, attachmentModel: { attachments: [], delete: (id: string) => widgetOps.push(`delete:${id}`), @@ -687,10 +701,26 @@ suite('AgentFeedbackService - Submit (agent host)', () => { widgetOps.push(`add:${entries[0]?.id}`); }, }, - acceptInput: async (query: string) => { widgetOps.push(`accept:${query}`); return undefined; }, + acceptInput: async (query: string, options?: IChatAcceptInputOptions) => { + widgetOps.push(`accept:${query}`); + if (acceptsRequest) { + options?.onRequestAccepted?.(); + } + await acceptInputSent.p; + widgetOps.push(`sent:${query}`); + return undefined; + }, } as unknown as IChatWidget; + loadSession = () => { + sessionLoaded = true; + onDidChangeViewModel.fire({ previousSessionResource: undefined, currentSessionResource: session }); + }; instantiationService.stub(IChatWidgetService, new class extends mock() { - override getWidgetBySessionResource(_resource: URI): IChatWidget { return widget; } + override onDidAddWidget = Event.None; + override getAllWidgets(): readonly IChatWidget[] { return [widget]; } + override getWidgetBySessionResource(_resource: URI): IChatWidget | undefined { + return sessionLoaded ? widget : undefined; + } }); service = store.add(instantiationService.createInstance(AgentFeedbackService)); @@ -726,4 +756,140 @@ suite('AgentFeedbackService - Submit (agent host)', () => { state: AgentFeedbackState.Submitted, }); }); + + test('marks feedback as submitted once the request is queued behind an in-progress request', async () => { + service.addFeedback(session, fileA, r(10), 'Please simplify'); + + // `acceptInputSent` is still pending: the request was queued and only runs + // once the in-progress request completes. + const submitted = await service.submitFeedback(session); + + assert.deepStrictEqual({ + submitted, + state: service.getFeedback(session)[0].state, + sent: widgetOps.includes('sent:/act-on-feedback'), + }, { + submitted: true, + state: AgentFeedbackState.Submitted, + sent: false, + }); + }); + + test('keeps feedback accepted when the request is not accepted by the widget', async () => { + acceptsRequest = false; + acceptInputSent.complete(); + service.addFeedback(session, fileA, r(10), 'Please simplify'); + + const submitted = await service.submitFeedback(session); + + assert.deepStrictEqual({ + submitted, + state: service.getFeedback(session)[0].state, + }, { + submitted: false, + state: AgentFeedbackState.Accepted, + }); + }); + + test('waits for the session model to load into the widget before submitting', async () => { + sessionLoaded = false; + service.addFeedback(session, fileA, r(10), 'Please simplify'); + + const pending = service.submitFeedback(session); + await timeout(0); + const submittedBeforeLoad = widgetOps.length > 0; + + loadSession(); + + assert.deepStrictEqual({ + submittedBeforeLoad, + submitted: await pending, + state: service.getFeedback(session)[0].state, + accepted: widgetOps.includes('accept:/act-on-feedback'), + }, { + submittedBeforeLoad: false, + submitted: true, + state: AgentFeedbackState.Submitted, + accepted: true, + }); + }); +}); + +suite('AgentFeedbackService - whenWidgetForSession', () => { + + const store = new DisposableStore(); + const session = URI.parse('test://session/1'); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + /** + * Builds a widget service whose single widget only reports the session once `load` is + * called, mirroring a chat widget that has not loaded its model yet. + */ + function createWidgetHost(): { widget: IChatWidget; service: IChatWidgetService; load: () => void } { + const onDidChangeViewModel = store.add(new Emitter()); + const widget = { onDidChangeViewModel: onDidChangeViewModel.event } as unknown as IChatWidget; + let loaded = false; + + const service = new class extends mock() { + override onDidAddWidget = Event.None; + override getAllWidgets(): readonly IChatWidget[] { return [widget]; } + override getWidgetBySessionResource(_resource: URI): IChatWidget | undefined { + return loaded ? widget : undefined; + } + }; + + return { + widget, + service, + load: () => { + loaded = true; + onDidChangeViewModel.fire({ previousSessionResource: undefined, currentSessionResource: session }); + }, + }; + } + + test('resolves immediately when the session is already loaded', async () => { + const host = createWidgetHost(); + host.load(); + + assert.strictEqual(await whenWidgetForSession(host.service, session, 0), host.widget); + }); + + test('resolves once a widget loads the session', async () => { + const host = createWidgetHost(); + + const pending = whenWidgetForSession(host.service, session, 5000); + await timeout(0); + host.load(); + + assert.strictEqual(await pending, host.widget); + }); + + test('resolves undefined when no widget loads the session in time', async () => { + const host = createWidgetHost(); + + assert.strictEqual(await whenWidgetForSession(host.service, session, 1), undefined); + }); + + test('resolves when a widget that already has the session is added later', async () => { + const onDidAddWidget = store.add(new Emitter()); + const widget = { onDidChangeViewModel: Event.None } as unknown as IChatWidget; + let widgets: IChatWidget[] = []; + + const service = new class extends mock() { + override onDidAddWidget = onDidAddWidget.event; + override getAllWidgets(): readonly IChatWidget[] { return widgets; } + override getWidgetBySessionResource(_resource: URI): IChatWidget | undefined { return widgets[0]; } + }; + + const pending = whenWidgetForSession(service, session, 5000); + await timeout(0); + widgets = [widget]; + onDidAddWidget.fire(widget); + + assert.strictEqual(await pending, widget); + }); }); diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts index a0a01cc5d8f..be6e3246c54 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialog.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -774,7 +774,7 @@ export function renderForm( // The picker is authoritative for the session type const isolationModel = new AutomationIsolationModel(state); const workspaceControlsVisible = derived(reader => !isolationModel.isQuickChatObs.read(reader)); - const sessionTypePicker = disposables.add(instantiationService.createInstance(MobileSessionTypePicker, constObservable(undefined), { persistSelection: false, telemetrySource: 'AutomationSessionTypePicker' })); + const sessionTypePicker = disposables.add(instantiationService.createInstance(MobileSessionTypePicker, constObservable(undefined), { persistSelection: false, telemetrySource: 'AutomationSessionTypePicker', showChevron: false })); sessionTypePicker.setQuickChatSource(isolationModel.isQuickChatObs); sessionTypePicker.setFolderSource(isolationModel.folderUriObs, { initialPick: state.sessionTypeId diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts index c93d3edfde1..fb1623d0b28 100644 --- a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -160,7 +160,7 @@ export class AutomationDialogService implements IAutomationDialogService { const description = DOM.append(container, $('.automation-description')); description.textContent = isEdit ? localize('automation.dialog.editDescription', "Update the schedule, prompt, or run target for this automation.") - : localize('automation.dialog.createDescription', "Define a prompt that Copilot will run on a schedule against the selected target."); + : localize('automation.dialog.createDescription', "Define a prompt that will run on a schedule against the selected target."); const formPane = DOM.append(container, $('.automation-form-pane')); const form = DOM.append(formPane, $('.automation-form')); diff --git a/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts b/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts index 357894aac08..8cfd20b525c 100644 --- a/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts +++ b/src/vs/sessions/contrib/changes/test/browser/agentsDiffEditor.fixture.ts @@ -7,9 +7,11 @@ import '../../browser/media/multiFileDiffEditor.css'; import '../../../agentFeedback/browser/media/agentFeedbackEditorInput.css'; import '../../../../../base/browser/ui/codicons/codiconStyles.js'; import { $, Dimension, getWindow } from '../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { Event, ValueWithChangeEvent } from '../../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable } from '../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { MultiDiffEditorWidget } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; @@ -18,25 +20,84 @@ import { RefCounted } from '../../../../../editor/browser/widget/diffEditor/util import { IDocumentDiffItem } from '../../../../../editor/browser/widget/multiDiffEditor/model.js'; import { IResourceLabel, IWorkbenchUIElementFactory } from '../../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { TestDiffProviderFactoryService } from '../../../../../editor/test/browser/diff/testDiffProviderFactoryService.js'; +import { IMenu, IMenuActionOptions, IMenuService, MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { ResourceLabel } from '../../../../../workbench/browser/labels.js'; +import { IVisibleEditorPane } from '../../../../../workbench/common/editor.js'; import { IDecorationsService } from '../../../../../workbench/services/decorations/common/decorations.js'; +import { IEditorGroup } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorProgressService } from '../../../../../platform/progress/common/progress.js'; import { INotebookDocumentService } from '../../../../../workbench/services/notebook/common/notebookDocumentService.js'; import { ITextFileService } from '../../../../../workbench/services/textfile/common/textfiles.js'; import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { TestEditorInput } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { AgentFeedbackEditorInputContribution } from '../../../agentFeedback/browser/agentFeedbackEditorInputContribution.js'; -import { IAgentFeedbackService } from '../../../agentFeedback/browser/agentFeedbackService.js'; +import { AgentFeedbackOverlayController, IAgentFeedbackOverlayEditorGroup } from '../../../agentFeedback/browser/agentFeedbackEditorOverlay.js'; +import { clearAllFeedbackActionId, navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from '../../../agentFeedback/browser/agentFeedbackEditorActions.js'; +import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedback, IAgentFeedbackService } from '../../../agentFeedback/browser/agentFeedbackService.js'; +import { Menus } from '../../../../browser/menus.js'; import { ISession } from '../../../../services/sessions/common/session.js'; const SESSION_RESOURCE = URI.parse('fixture-session://agents-diff'); const MODIFIED_FIRST_RESOURCE = URI.file('/workspace/src/first.ts'); +const OVERLAY_RESOURCE = URI.file('/workspace/changes.diff'); +const FIXTURE_WIDTH = 860; +const FIXTURE_HEIGHT = 620; +const DETAIL_WIDTH = 280; const UNCHANGED_LINES = Array.from({ length: 18 }, (_, index) => `const unchanged${index} = ${index};`).join('\n'); +class FixtureAgentFeedbackMenuService implements IMenuService { + + declare readonly _serviceBrand: undefined; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { } + + createMenu(id: MenuId): IMenu { + if (id !== Menus.AgentFeedbackEditorContent) { + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => [], + }; + } + const createAction = (actionId: string, title: string, icon: ThemeIcon) => this.instantiationService.createInstance( + MenuItemAction, + { id: actionId, title, icon }, + undefined, + { renderShortTitle: true }, + undefined, + undefined, + ); + const navigateActions = [ + createAction(navigationBearingFakeActionId, 'Navigation Status', Codicon.commentDiscussion), + createAction(navigatePreviousFeedbackActionId, 'Previous', Codicon.arrowUp), + createAction(navigateNextFeedbackActionId, 'Next', Codicon.arrowDown), + ]; + const submitActions = [ + createAction(submitFeedbackActionId, 'Submit', Codicon.send), + createAction(clearAllFeedbackActionId, 'Clear', Codicon.clearAll), + ]; + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => [ + ['navigate', navigateActions], + ['a_submit', submitActions], + ], + }; + } + + getMenuActions(_id: MenuId, _contextKeyService: IContextKeyService, _options?: IMenuActionOptions) { return []; } + getMenuContexts() { return new Set(); } + resetHiddenStates() { } +} + class AgentsDiffUIElementFactory implements IWorkbenchUIElementFactory { constructor( @@ -65,7 +126,7 @@ function createFixtureSession(): ISession { }(); } -function createAgentFeedbackService(): IAgentFeedbackService { +function createAgentFeedbackService(feedback: readonly IAgentFeedback[] = [], feedbackScopeResource: URI = MODIFIED_FIRST_RESOURCE): IAgentFeedbackService { const session = createFixtureSession(); return new class extends mock() { override readonly onDidChangeFeedback = Event.None; @@ -75,38 +136,97 @@ function createAgentFeedbackService(): IAgentFeedbackService { return resource.toString() === MODIFIED_FIRST_RESOURCE.toString() ? session : undefined; } override getFeedbackSessionResource(resource: URI): URI | undefined { - return resource.toString() === MODIFIED_FIRST_RESOURCE.toString() ? SESSION_RESOURCE : undefined; + return resource.toString() === feedbackScopeResource.toString() ? SESSION_RESOURCE : undefined; } override getFeedback() { - return []; + return feedback; } override getNavigationBearing() { - return { activeIdx: -1, totalCount: 0 }; + return { activeIdx: feedback.length > 0 ? 0 : -1, totalCount: feedback.length }; } }(); } +class FixtureOverlayEditorGroup extends mock() implements IAgentFeedbackOverlayEditorGroup { + + override readonly onDidActiveEditorChange = Event.None; + override readonly onDidModelChange = Event.None; + override readonly activeEditor: TestEditorInput; + override readonly activeEditorPane: IVisibleEditorPane; + + constructor( + readonly editorPaneContainer: HTMLElement, + input: TestEditorInput, + ) { + super(); + this.activeEditor = input; + this.activeEditorPane = new class extends mock() { + override readonly input = input; + }(); + } + + override async closeEditor(): Promise { + return true; + } +} + function createContextKeyService(): IContextKeyService { return new class extends MockContextKeyService { override contextMatchesRules(): boolean { return true; } }(); } -async function renderAgentsDiffEditor({ container, disposableStore, disposableStackStore, theme }: ComponentFixtureContext): Promise { - container.classList.add('agent-sessions-workbench'); - container.style.width = '520px'; - container.style.height = '620px'; +interface IAgentsDiffFixtureOptions { + readonly showSubmitOverlay?: boolean; +} + +async function renderAgentsDiffEditor({ container, disposableStore, disposableStackStore, theme }: ComponentFixtureContext, options: IAgentsDiffFixtureOptions = {}): Promise { + const editorWidth = options.showSubmitOverlay ? FIXTURE_WIDTH - DETAIL_WIDTH : 520; + const fixtureWidth = options.showSubmitOverlay ? FIXTURE_WIDTH : editorWidth; + container.classList.add('agent-sessions-workbench', 'dock-detail-panel'); + container.style.width = `${fixtureWidth}px`; + container.style.height = `${FIXTURE_HEIGHT}px`; container.style.background = 'var(--vscode-agentsPanel-background)'; const editorPart = container.appendChild($('.part.editor')); + editorPart.style.position = 'relative'; + editorPart.style.width = '100%'; editorPart.style.height = '100%'; - const agentFeedbackService = createAgentFeedbackService(); + const editorContent = editorPart.appendChild($('.content')); + editorContent.style.width = '100%'; + editorContent.style.height = '100%'; + + const editorGroup = editorContent.appendChild($('.editor-group-container')); + editorGroup.style.position = 'relative'; + editorGroup.style.width = '100%'; + editorGroup.style.height = '100%'; + + const editorPane = editorGroup.appendChild($('.editor-container')); + editorPane.style.width = `${editorWidth}px`; + editorPane.style.height = '100%'; + + const editorInstance = editorPane.appendChild($('.editor-instance')); + editorInstance.style.width = '100%'; + editorInstance.style.height = '100%'; + + const feedback: readonly IAgentFeedback[] = options.showSubmitOverlay ? [{ + id: 'feedback-1', + text: 'Keep the submit control with the diff.', + resourceUri: MODIFIED_FIRST_RESOURCE, + range: { startLineNumber: 19, startColumn: 1, endLineNumber: 19, endColumn: 1 }, + sessionResource: SESSION_RESOURCE, + kind: AgentFeedbackKind.UserReview, + state: AgentFeedbackState.Accepted, + }] : []; + const agentFeedbackService = createAgentFeedbackService(feedback, options.showSubmitOverlay ? OVERLAY_RESOURCE : MODIFIED_FIRST_RESOURCE); const instantiationService = createEditorServices(disposableStore, { colorTheme: theme, additionalServices: reg => { + registerWorkbenchServices(reg); reg.defineInstance(IAgentFeedbackService, agentFeedbackService); reg.defineInstance(IContextKeyService, createContextKeyService()); + reg.define(IMenuService, FixtureAgentFeedbackMenuService); reg.defineInstance(IDecorationsService, new class extends mock() { override onDidChangeDecorations = Event.None; }()); reg.defineInstance(ITextFileService, new class extends mock() { override readonly untitled = new class extends mock() { override readonly onDidChangeLabel = Event.None; }(); }()); reg.defineInstance(IWorkspaceContextService, new class extends mock() { override onDidChangeWorkspaceFolders = Event.None; override getWorkspace(): IWorkspace { return { id: '', folders: [], configuration: undefined }; } }()); @@ -115,7 +235,6 @@ async function renderAgentsDiffEditor({ container, disposableStore, disposableSt show: () => ({ total: () => { }, worked: () => { }, done: () => { } }), }); reg.defineInstance(IDiffProviderFactoryService, new TestDiffProviderFactoryService()); - registerWorkbenchServices(reg); }, }); @@ -129,7 +248,7 @@ async function renderAgentsDiffEditor({ container, disposableStore, disposableSt const second = RefCounted.createOfNonDisposable({ original: secondOriginal, modified: secondModified }, { dispose() { } }); const widget = disposableStackStore.add(instantiationService.createInstance( MultiDiffEditorWidget, - editorPart, + editorInstance, instantiationService.createInstance(AgentsDiffUIElementFactory), { hideOriginalLineNumbers: true, @@ -144,9 +263,17 @@ async function renderAgentsDiffEditor({ container, disposableStore, disposableSt documents: ValueWithChangeEvent.const([first, second]), })); widget.setViewModel(viewModel); - widget.layout(new Dimension(520, 620)); + widget.layout(new Dimension(editorWidth, FIXTURE_HEIGHT)); disposableStackStore.add(toDisposable(() => widget.setViewModel(undefined))); + if (options.showSubmitOverlay) { + renderDockedDetailPanel(editorPart); + const input = disposableStackStore.add(new TestEditorInput(OVERLAY_RESOURCE, 'fixture.agentsDiff')); + const group = new FixtureOverlayEditorGroup(editorPane, input); + disposableStackStore.add(instantiationService.createInstance(AgentFeedbackOverlayController, group)); + return; + } + const targetWindow = getWindow(container); await new Promise(resolve => targetWindow.requestAnimationFrame(() => targetWindow.requestAnimationFrame(() => resolve()))); @@ -156,13 +283,49 @@ async function renderAgentsDiffEditor({ container, disposableStore, disposableSt } const lineNumber = editor?.getDomNode()?.querySelector('.line-numbers'); lineNumber?.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: lineNumber.getBoundingClientRect().left + 1, clientY: lineNumber.getBoundingClientRect().top + 1 })); - await new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())); } +function renderDockedDetailPanel(editorPart: HTMLElement): void { + const detail = editorPart.appendChild($('.part.auxiliarybar.docked-auxiliarybar')); + detail.style.position = 'absolute'; + detail.style.top = '0'; + detail.style.right = '0'; + detail.style.width = `${DETAIL_WIDTH}px`; + detail.style.height = '100%'; + detail.style.boxSizing = 'border-box'; + detail.style.background = 'var(--vscode-sideBar-background)'; + detail.style.borderLeft = 'var(--vscode-strokeThickness) solid var(--vscode-sideBar-border)'; + + const title = detail.appendChild($('.fixture-docked-detail-title')); + title.textContent = 'Files'; + title.style.height = '35px'; + title.style.boxSizing = 'border-box'; + title.style.padding = '8px 12px'; + title.style.fontWeight = 'var(--vscode-fontWeight-semiBold)'; + title.style.borderBottom = 'var(--vscode-strokeThickness) solid var(--vscode-sideBar-border)'; + + const files = detail.appendChild($('.fixture-docked-detail-files')); + files.style.padding = '8px 12px'; + for (const [name, stats] of [['first.ts', '+2 -1'], ['second.ts', '+1 -1'], ['README.md', '+4 -0']]) { + const row = files.appendChild($('.fixture-docked-detail-file')); + row.style.display = 'flex'; + row.style.justifyContent = 'space-between'; + row.style.padding = '6px 0'; + row.appendChild(document.createTextNode(name)); + const count = row.appendChild($('span')); + count.textContent = stats; + count.style.color = 'var(--vscode-descriptionForeground)'; + } +} + export default defineThemedFixtureGroup({ path: 'sessions/changes/' }, { CompactDiffWithFeedback: defineComponentFixture({ labels: { kind: 'screenshot' }, render: renderAgentsDiffEditor, }), + CompactDiffWithSubmitOverlay: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderAgentsDiffEditor(context, { showSubmitOverlay: true }), + }), }); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 768f73f31c4..e9d95f1715f 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -105,6 +105,12 @@ export class NewChatView extends AbstractChatView { override attach(uris: URI[]): void { this._widget.attach(uris); } + + override setVisible(visible: boolean): void { + if (this._widget instanceof NewChatWidget) { + this._widget.setHostVisible(visible); + } + } } /** @@ -145,6 +151,9 @@ export class ChatView extends AbstractChatView { /** Observable mirror of {@link _isActive} so the voice overlay can react. */ private readonly _isActiveObs = observableValue(this, true); + /** Whether this view is currently visible. `undefined` so the first push always reaches the widget. */ + private _isVisible: boolean | undefined; + /** * Per-view mirror of `agentsVoiceInitiatedHere`, scoped above the chat widget. * Keeps post-connect voice controls anchored to the active session view. @@ -198,7 +207,6 @@ export class ChatView extends AbstractChatView { this._buildStyles(this._isActive) )); this._widget.render(this.element); - this._widget.setVisible(true); this._selectionSideChatController = this._register(scopedInstantiationService.createInstance(ResponseSelectionSideChatController, this._widget)); @@ -418,6 +426,14 @@ export class ChatView extends AbstractChatView { this._banners.setActive(active); this._widget.setStyles(this._buildStyles(active)); } + + override setVisible(visible: boolean): void { + if (this._isVisible === visible) { + return; + } + this._isVisible = visible; + this._widget.setVisible(visible); + } } /** diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionBackgroundActivitiesControl.css b/src/vs/sessions/contrib/chat/browser/media/sessionActivityPill.css similarity index 56% rename from src/vs/sessions/contrib/chat/browser/media/sessionBackgroundActivitiesControl.css rename to src/vs/sessions/contrib/chat/browser/media/sessionActivityPill.css index d2634639720..56cd36a24ae 100644 --- a/src/vs/sessions/contrib/chat/browser/media/sessionBackgroundActivitiesControl.css +++ b/src/vs/sessions/contrib/chat/browser/media/sessionActivityPill.css @@ -3,16 +3,21 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.session-background-activities { +/* Several pills can share the row above the input (browsers, background + activities, turn status), so a pill shrinks below its content and ellipsizes + its label rather than pushing its neighbours out of the row. The cap keeps a + single long label from crowding out the other pills when there is room. */ +.session-activity-pill { display: inline-flex; + flex: 0 1 auto; min-width: 0; } -.session-background-activities.hidden { +.session-activity-pill.hidden { display: none; } -.session-background-activities .session-background-activities-button { +.session-activity-pill .session-activity-pill-button { display: inline-flex; width: fit-content; min-width: 0; @@ -23,14 +28,14 @@ touch-action: manipulation; } -.session-background-activities .session-background-activities-button > span:not(.codicon) { +.session-activity-pill .session-activity-pill-button > span:not(.codicon) { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } -.session-background-activities .session-background-activities-button .codicon { +.session-activity-pill .session-activity-pill-button .codicon { font-size: var(--vscode-codiconFontSize-compact); flex-shrink: 0; } diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css b/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css index e71832c3a7d..431c40ba336 100644 --- a/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css +++ b/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ /* Floating status pills centered above the chat input. The pills themselves are - the shared `.chat-turn-pills` widget (styled in chatTurnPills.css); this file - only positions and centers that widget above the input. */ + the shared `.chat-turn-pills` widget (styled in chatTurnPills.css) and the + session activity pills (sessionActivityPill.css); this file only positions and + centers them above the input. */ .session-chat-input-toolbar { display: flex; @@ -16,6 +17,14 @@ padding: var(--vscode-spacing-size20) 0 var(--vscode-spacing-size60) 0; } +/* The turn pills size to their content and deliberately don't shrink internally, + so squeezing them would spill their pills over the activity pills next to + them. Keep them at their natural width and let the activity pills, which + ellipsize their labels, absorb the shrinking instead. */ +.session-chat-input-toolbar > .chat-turn-pills { + flex-shrink: 0; +} + .session-chat-input-toolbar.hidden { display: none; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 36ed8bf1241..8908de43c48 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -491,7 +491,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this._createEditor(inputArea, editorOverflowWidgetsDomNode); const inputHasContent = observableFromEvent(this, this._editor.onDidChangeModelContent, () => this._editor.getValue().length > 0); - this._register(this.instantiationService.createInstance(ChatPetWidget, inputAreaWrapper, inputArea, constObservable(undefined), inputHasContent, this._editor.onDidChangeModelContent)); + this._register(this.instantiationService.createInstance(ChatPetWidget, inputAreaWrapper, inputArea, constObservable(undefined), inputHasContent, constObservable(true), this._editor.onDidChangeModelContent)); this._createInputToolbar(inputArea); const newChatBottomContainer = dom.append(parent, dom.$('.new-chat-bottom-container')); diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 0f2a65d7f2c..9d68f990c8e 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -287,7 +287,7 @@ export class NewChatWidget extends Disposable { )); const petAction = this._register(new Action( 'sessions.chatPet.toggle', - localize('petAction', "Pet"), + localize('petAction', "Pet (/vscode-pet)"), undefined, true, () => this.chatPetService.toggle() @@ -524,7 +524,21 @@ export class NewChatWidget extends Disposable { private async _createNewSession(folderUri: URI): Promise { this._pendingPreferredUpgrade.clear(); const userPick = this._newChatInput.sessionTypePicker.getUserPickedSessionType(); - const result = await this._createSessionNow(folderUri, userPick); + // Session creation is async, so a provider can start serving the folder + // (e.g. the local agent host finishing its handshake) between the call + // below and the listener installed after it. That change would land in + // the gap and be lost, leaving the composer without a draft — and with + // the harness picker hidden — until the user re-picks the workspace. + // Record it here so the listener can replay it. + const pendingChange = new DisposableStore(); + let changedWhilePending = false; + pendingChange.add(this.sessionsManagementService.onDidChangeSessionTypes(() => changedWhilePending = true)); + let result: IOpenNewSessionResult; + try { + result = await this._createSessionNow(folderUri, userPick); + } finally { + pendingChange.dispose(); + } if (result.trustDeclined) { // The user explicitly declined trust: don't schedule a retry, which // would silently recreate (and possibly re-prompt) the draft once a @@ -541,7 +555,7 @@ export class NewChatWidget extends Disposable { // (first) type, which can change as the folder's session-type list // grows. if (!result.session || !userPick || !this._isPreferredServable(folderUri, userPick)) { - this._scheduleRecreateOnProviderChange(folderUri, userPick, result.session); + this._scheduleRecreateOnProviderChange(folderUri, userPick, result.session, changedWhilePending); } return result; } @@ -568,30 +582,35 @@ export class NewChatWidget extends Disposable { } } - private _scheduleRecreateOnProviderChange(folderUri: URI, userPick: IPreferredSessionType | undefined, created: ISession | undefined): void { + private _scheduleRecreateOnProviderChange(folderUri: URI, userPick: IPreferredSessionType | undefined, created: ISession | undefined, replayMissedChange: boolean): void { const store = new DisposableStore(); - store.add(this.sessionsManagementService.onDidChangeSessionTypes(() => { - if (created) { - const active = this._session.get(); - if (active?.sessionId !== created.sessionId || active.isCreated.get()) { - return; // the draft was sent or is no longer the active session + store.add(this.sessionsManagementService.onDidChangeSessionTypes(() => this._recreateOnProviderChange(folderUri, userPick, created))); + this._pendingPreferredUpgrade.value = store; + if (replayMissedChange) { + this._recreateOnProviderChange(folderUri, userPick, created); + } + } + + private _recreateOnProviderChange(folderUri: URI, userPick: IPreferredSessionType | undefined, created: ISession | undefined): void { + if (created) { + const active = this._session.get(); + if (active?.sessionId !== created.sessionId || active.isCreated.get()) { + return; // the draft was sent or is no longer the active session + } + if (userPick) { + if (!this._isPreferredServable(folderUri, userPick)) { + return; // the preferred provider still cannot serve the folder } - if (userPick) { - if (!this._isPreferredServable(folderUri, userPick)) { - return; // the preferred provider still cannot serve the folder - } - } else { - // No explicit pick: keep the draft on the preferred (first) - // type. Recreate only when that preferred actually changed. - const preferred = this._newChatInput.sessionTypePicker.getPreferredSessionType(folderUri); - if (!preferred || (preferred.providerId === active.providerId && preferred.sessionTypeId === active.sessionType)) { - return; - } + } else { + // No explicit pick: keep the draft on the preferred (first) + // type. Recreate only when that preferred actually changed. + const preferred = this._newChatInput.sessionTypePicker.getPreferredSessionType(folderUri); + if (!preferred || (preferred.providerId === active.providerId && preferred.sessionTypeId === active.sessionType)) { + return; } } - void this._createNewSession(folderUri); - })); - this._pendingPreferredUpgrade.value = store; + } + void this._createNewSession(folderUri); } /** diff --git a/src/vs/sessions/contrib/chat/browser/sessionActivityPill.ts b/src/vs/sessions/contrib/chat/browser/sessionActivityPill.ts new file mode 100644 index 00000000000..03ef6d2f233 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionActivityPill.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import './media/sessionActivityPill.css'; + +/** One entry of a pill, rendered as the button label or as a picker row. */ +export interface ISessionActivity { + readonly label: string; + readonly icon: ThemeIcon; +} + +/** A named section of the picker; sections without activities are skipped. */ +export interface ISessionActivityCategory { + readonly title: string; + readonly activities: readonly T[]; +} + +/** The button content when a pill stands for more than one activity. */ +export interface ISessionActivitySummary { + readonly label: string; + readonly icon: ThemeIcon; + readonly ariaLabel: string; +} + +export interface ISessionActivityPillOptions { + /** Extra class on the pill root, for fixtures and per-pill styling. */ + readonly className: string; + /** Identifies the pill's picker to the action widget service. */ + readonly widgetId: string; + /** Accessible name of the picker shown for more than one activity. */ + readonly getWidgetAriaLabel: () => string; + /** Button content for more than one activity; a single activity renders itself. */ + readonly getSummary: (activities: readonly T[]) => ISessionActivitySummary; + readonly openActivity: (activity: T) => void | Promise; +} + +/** + * A compact button standing for a set of activities. A single activity is shown + * with its own icon and label and is opened directly; more than one shows the + * consumer's summary and opens a picker grouped by category. The widget owns + * only the presentation — which activities exist, how they are grouped, and how + * they are labelled is up to the consumer. + */ +export class SessionActivityPill extends Disposable { + + readonly element: HTMLElement; + readonly isVisible: IObservable; + + private readonly _button: Button; + private readonly _isVisible = observableValue(this, false); + private _categories: readonly ISessionActivityCategory[] = []; + private _activities: readonly T[] = []; + + constructor( + private readonly _options: ISessionActivityPillOptions, + private readonly _actionWidgetService: IActionWidgetService, + ) { + super(); + + this.element = $(`.session-activity-pill.${_options.className}.hidden`); + this.isVisible = this._isVisible; + this._button = this._register(new Button(this.element, { secondary: true, small: true, supportIcons: true, ...defaultButtonStyles })); + this._button.element.classList.add('session-activity-pill-button'); + this._register(this._button.onDidClick(() => this._onDidClick())); + } + + setCategories(categories: readonly ISessionActivityCategory[]): void { + this._categories = categories.filter(category => category.activities.length > 0); + this._activities = this._categories.flatMap(category => category.activities); + this._render(); + } + + private _render(): void { + const count = this._activities.length; + this._isVisible.set(count > 0, undefined); + this.element.classList.toggle('hidden', count === 0); + if (count === 0) { + return; + } + + let label: string; + let accessibleLabel: string; + if (count === 1) { + const activity = this._activities[0]; + label = `$(${activity.icon.id}) ${activity.label}`; + accessibleLabel = localize('sessionActivityPill.open', "Open {0}", activity.label); + } else { + const summary = this._options.getSummary(this._activities); + label = `$(${summary.icon.id}) ${summary.label} $(${Codicon.chevronDown.id})`; + accessibleLabel = summary.ariaLabel; + } + + this._button.label = label; + this._button.setTitle(accessibleLabel); + this._button.setAriaLabel(accessibleLabel); + } + + private _onDidClick(): void { + if (this._activities.length === 1) { + this._openActivity(this._activities[0]); + return; + } + if (this._activities.length > 1) { + this._showPicker(); + } + } + + private _openActivity(activity: T): void { + Promise.resolve(this._options.openActivity(activity)).catch(onUnexpectedError); + } + + private _showPicker(): void { + if (this._actionWidgetService.isVisible) { + return; + } + + const items: IActionListItem[] = []; + for (const category of this._categories) { + if (items.length > 0) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + items.push({ kind: ActionListItemKind.Header, label: category.title, group: { title: category.title } }); + for (const activity of category.activities) { + items.push({ + kind: ActionListItemKind.Action, + label: activity.label, + group: { title: '', icon: activity.icon }, + item: activity, + }); + } + } + + const triggerElement = this._button.element; + const delegate: IActionListDelegate = { + onSelect: activity => { + this._actionWidgetService.hide(); + this._openActivity(activity); + }, + onHide: () => triggerElement.focus(), + }; + this._actionWidgetService.show( + this._options.widgetId, + false, + items, + delegate, + triggerElement, + undefined, + [], + { + getAriaLabel: item => item.label ?? '', + getWidgetAriaLabel: () => this._options.getWidgetAriaLabel(), + }, + { minWidth: 220, maxWidth: 420 }, + ); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts b/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts index 71f8b51bd1f..982539f7d57 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionBackgroundActivitiesControl.ts @@ -3,94 +3,81 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $ } from '../../../../base/browser/dom.js'; -import { Button } from '../../../../base/browser/ui/button/button.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable, IReader } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; -import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; -import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { BrowserEditorInput } from '../../../../workbench/contrib/browserView/common/browserEditorInput.js'; -import { browserViewUrlMatches, BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js'; -import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, IChat, isActiveSessionStatus } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionActivity, ISessionActivitySummary, SessionActivityPill } from './sessionActivityPill.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; -import './media/sessionBackgroundActivitiesControl.css'; const SUBAGENT_LABEL_MAX_LENGTH = 30; -interface IBackgroundBrowserActivity { - readonly source: 'browser'; - readonly kind: 'browser'; - readonly input: BrowserEditorInput; - readonly label: string; +interface ISubagentActivity extends ISessionActivity { + /** The subagent chat to open, or `undefined` for a fake activity from debug data. */ + readonly chat: IChat | undefined; } -interface IBackgroundSubagentActivity { - readonly source: 'subagent'; - readonly kind: 'subagent'; - readonly chat: IChat; - readonly label: string; -} +/** + * The activities this pill lists. Further kinds join this union; once more than + * one kind can be listed at once, the summary needs a generic mixed-kind label. + */ +type IBackgroundActivity = ISubagentActivity; -interface IDebugBackgroundActivity { - readonly source: 'debug'; - readonly kind: 'browser' | 'subagent'; - readonly label: string; -} - -type IBackgroundActivity = IBackgroundBrowserActivity | IBackgroundSubagentActivity | IDebugBackgroundActivity; - -/** Combines live browsers and running subagents for the viewed chat into one compact control. */ +/** + * Lists the background activities of the viewed chat as one compact pill. Today + * those are the chat's running subagents. Browsers have their own pill, see + * `SessionBrowsersControl`. + */ export class SessionBackgroundActivitiesControl extends Disposable { readonly element: HTMLElement; readonly isVisible: IObservable; - private readonly _button: Button; - private readonly _browserListeners = this._register(new MutableDisposable()); - private readonly _isVisible = observableValue(this, false); + private readonly _pill: SessionActivityPill; private _currentSession: IActiveSession | undefined; - private _runningSubagents: readonly IBackgroundSubagentActivity[] = []; - private _activities: readonly IBackgroundActivity[] = []; - private _activitiesEnabled = false; + private _runningSubagents: readonly ISubagentActivity[] = []; private _debugData: ISessionChatPillsDebugData | undefined; constructor( private readonly _session: IObservable, private readonly _chat: IObservable, private readonly _enabled: IObservable, - @IBrowserViewWorkbenchService private readonly _browserViewService: IBrowserViewWorkbenchService, - @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, - @IEditorService private readonly _editorService: IEditorService, + @IActionWidgetService actionWidgetService: IActionWidgetService, @ISessionsService private readonly _sessionsService: ISessionsService, ) { super(); - this.element = $('.session-background-activities.hidden'); - this.isVisible = this._isVisible; - this._button = this._register(new Button(this.element, { secondary: true, small: true, supportIcons: true, ...defaultButtonStyles })); - this._button.element.classList.add('session-background-activities-button'); - this._register(this._button.onDidClick(() => this._onDidClick())); + this._pill = this._register(new SessionActivityPill({ + className: 'session-background-activities', + widgetId: 'sessionBackgroundActivities', + getWidgetAriaLabel: () => localize('backgroundActivities.ariaLabel', "Background Activities"), + getSummary: activities => this._summary(activities), + openActivity: activity => this._openActivity(activity), + }, actionWidgetService)); + this.element = this._pill.element; + this.isVisible = this._pill.isVisible; this._register(autorun(reader => { const session = this._session.read(reader); const chat = this._chat.read(reader); + const enabled = this._enabled.read(reader); this._currentSession = session; - this._activitiesEnabled = this._enabled.read(reader); - this._runningSubagents = this._activitiesEnabled && session && chat ? this._collectRunningSubagents(session, chat, reader) : []; + this._runningSubagents = enabled && session && chat ? this._collectRunningSubagents(session, chat, reader) : []; this._refresh(); })); - this._register(this._browserViewService.onDidChangeBrowserViews(() => this._refreshBrowserListeners())); - this._refreshBrowserListeners(); } - private _collectRunningSubagents(session: IActiveSession, parentChat: IChat, reader: IReader): IBackgroundSubagentActivity[] { + setDebugData(data: ISessionChatPillsDebugData | undefined): void { + this._debugData = data; + this._refresh(); + } + + private _collectRunningSubagents(session: IActiveSession, parentChat: IChat, reader: IReader): ISubagentActivity[] { return session.chats.read(reader) .filter(chat => chat.origin?.kind === ChatOriginKind.Tool && @@ -98,9 +85,8 @@ export class SessionBackgroundActivitiesControl extends Disposable { isEqual(chat.origin.parentChat, parentChat.resource) && isActiveSessionStatus(chat.status.read(reader))) .map(chat => ({ - source: 'subagent', - kind: 'subagent', chat, + icon: Codicon.agent, label: this._subagentLabel(chat.title.read(reader)), })); } @@ -110,181 +96,24 @@ export class SessionBackgroundActivitiesControl extends Disposable { return label.length > SUBAGENT_LABEL_MAX_LENGTH ? `${label.slice(0, SUBAGENT_LABEL_MAX_LENGTH)}...` : label; } - private _refreshBrowserListeners(): void { - const store = new DisposableStore(); - this._browserListeners.value = store; - for (const input of this._browserViewService.getKnownBrowserViews().values()) { - store.add(input.onDidChangeLabel(() => this._refresh())); - } - this._refresh(); - } - private _refresh(): void { - if (this._debugData) { - this._activities = [ - ...this._debugData.browsers.map(label => ({ source: 'debug', kind: 'browser', label }) as const), - ...this._debugData.subagents.map(label => ({ source: 'debug', kind: 'subagent', label }) as const), - ]; - this._render(); - return; - } - const browserActivities = this._activitiesEnabled ? this._collectBrowserActivities() : []; - this._activities = [...browserActivities, ...this._runningSubagents]; - this._render(); + const subagents: readonly ISubagentActivity[] = this._debugData + ? this._debugData.subagents.map(label => ({ label, icon: Codicon.agent, chat: undefined })) + : this._runningSubagents; + this._pill.setCategories([{ title: localize('backgroundActivities.subagents', "Subagents"), activities: subagents }]); } - private _collectBrowserActivities(): IBackgroundBrowserActivity[] { - const session = this._currentSession; - const chat = this._chat.get(); - if (!session || !chat) { - return []; - } - - const ownerIds = new Set([chat.resource.toString()]); - for (const candidate of session.chats.get()) { - if (candidate.origin?.kind === ChatOriginKind.Tool && candidate.origin.parentChat && isEqual(candidate.origin.parentChat, chat.resource)) { - ownerIds.add(candidate.resource.toString()); - } - } - - const activities: IBackgroundBrowserActivity[] = []; - for (const input of this._browserViewService.getKnownBrowserViews().values()) { - const ownerId = input.model?.owner.sessionId; - if (ownerId && ownerIds.has(ownerId)) { - activities.push({ - source: 'browser', - kind: 'browser', - input, - label: input.title?.trim() || localize('backgroundActivities.browser', "Browser"), - }); - } - } - return activities; - } - - private _render(): void { - const count = this._activities.length; - this._isVisible.set(count > 0, undefined); - this.element.classList.toggle('hidden', count === 0); - if (count === 0) { - return; - } - - let label: string; - if (count === 1) { - const activity = this._activities[0]; - const icon = activity.kind === 'browser' ? Codicon.globe : Codicon.agent; - label = `$(${icon.id}) ${activity.label}`; - } else if (this._activities.every(activity => activity.kind === 'browser')) { - label = `$(${Codicon.globe.id}) ${localize('backgroundActivities.activeBrowsers', "{0} Active Browsers", count)} $(${Codicon.chevronDown.id})`; - } else if (this._activities.every(activity => activity.kind === 'subagent')) { - label = `$(${Codicon.agent.id}) ${localize('backgroundActivities.activeSubagents', "{0} Active Subagents", count)} $(${Codicon.chevronDown.id})`; - } else { - label = `$(${Codicon.sessionInProgress.id}) ${localize('backgroundActivities.mixed', "{0} Background Activities", count)} $(${Codicon.chevronDown.id})`; - } - - this._button.label = label; - const accessibleLabel = count === 1 - ? localize('backgroundActivities.open', "Open {0}", this._activities[0].label) - : localize('backgroundActivities.show', "Show {0} background activities", count); - this._button.setTitle(accessibleLabel); - this._button.setAriaLabel(accessibleLabel); - } - - private _onDidClick(): void { - if (this._activities.length === 1) { - void this._openActivity(this._activities[0]); - return; - } - if (this._activities.length > 1) { - this._showPicker(); - } - } - - private _showPicker(): void { - if (this._actionWidgetService.isVisible) { - return; - } - - const browsers = this._activities.filter(activity => activity.kind === 'browser'); - const subagents = this._activities.filter(activity => activity.kind === 'subagent'); - const items: IActionListItem[] = []; - const addCategory = (title: string, icon: typeof Codicon.globe, activities: readonly IBackgroundActivity[]) => { - if (activities.length === 0) { - return; - } - if (items.length > 0) { - items.push({ kind: ActionListItemKind.Separator, label: '' }); - } - items.push({ kind: ActionListItemKind.Header, label: title, group: { title } }); - for (const activity of activities) { - items.push({ - kind: ActionListItemKind.Action, - label: activity.label, - group: { title: '', icon }, - item: activity, - }); - } + private _summary(activities: readonly IBackgroundActivity[]): ISessionActivitySummary { + return { + icon: Codicon.agent, + label: localize('backgroundActivities.activeSubagents', "{0} Active Subagents", activities.length), + ariaLabel: localize('backgroundActivities.show', "Show {0} background activities", activities.length), }; - - addCategory(localize('backgroundActivities.browsers', "Browsers"), Codicon.globe, browsers); - addCategory(localize('backgroundActivities.subagents', "Subagents"), Codicon.agent, subagents); - - const triggerElement = this._button.element; - const delegate: IActionListDelegate = { - onSelect: activity => { - this._actionWidgetService.hide(); - void this._openActivity(activity); - }, - onHide: () => triggerElement.focus(), - }; - this._actionWidgetService.show( - 'sessionBackgroundActivities', - false, - items, - delegate, - triggerElement, - undefined, - [], - { - getAriaLabel: item => item.label ?? '', - getWidgetAriaLabel: () => localize('backgroundActivities.ariaLabel', "Background Activities"), - }, - { minWidth: 220, maxWidth: 420 }, - ); } - private async _openActivity(activity: IBackgroundActivity): Promise { - if (activity.source === 'debug') { - return; - } - if (activity.source === 'browser') { - const input = this._getBrowserInputToOpen(activity.input); - const existing = this._editorService.findEditors(input.resource) - .find(identifier => identifier.editor instanceof BrowserEditorInput && identifier.editor.id === input.id); - const targetGroup = existing?.groupId ?? await this._browserViewService.getPreferredGroup(); - await this._editorService.openEditor(input, undefined, targetGroup); - return; - } - if (this._currentSession) { + private _openActivity(activity: IBackgroundActivity): void { + if (activity.chat && this._currentSession) { this._sessionsService.openChat(this._currentSession, activity.chat.resource); } } - - setDebugData(data: ISessionChatPillsDebugData | undefined): void { - this._debugData = data; - this._refresh(); - } - - private _getBrowserInputToOpen(input: BrowserEditorInput): BrowserEditorInput { - const url = input.url; - if (input.model?.sharingState === BrowserViewSharingState.Shared || !url) { - return input; - } - - const activeSessionId = this._chat.get()?.resource.toString(); - const shared = [...this._browserViewService.getContextualBrowserViews({ activeSessionId }).values()] - .filter(candidate => candidate.model?.sharingState === BrowserViewSharingState.Shared && browserViewUrlMatches(candidate.url, url)); - return shared.find(candidate => candidate.url === url) ?? shared.at(0) ?? input; - } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts b/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts new file mode 100644 index 00000000000..2f46abdd931 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable, IReader } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { BrowserEditorInput } from '../../../../workbench/contrib/browserView/common/browserEditorInput.js'; +import { browserViewUrlMatches, BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { ChatOriginKind, IChat } from '../../../services/sessions/common/session.js'; +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionActivity, ISessionActivitySummary, SessionActivityPill } from './sessionActivityPill.js'; +import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; + +interface IBrowserActivity extends ISessionActivity { + /** The browser to open, or `undefined` for a fake activity from debug data. */ + readonly input: BrowserEditorInput | undefined; +} + +/** Lists the live browsers of the viewed chat (and its subagents) as one compact pill. */ +export class SessionBrowsersControl extends Disposable { + + readonly element: HTMLElement; + readonly isVisible: IObservable; + + private readonly _pill: SessionActivityPill; + private readonly _browserListeners = this._register(new MutableDisposable()); + /** Chats whose browsers belong to this pill: the viewed chat and its subagents. */ + private _ownerIds: ReadonlySet = new Set(); + private _currentChat: IChat | undefined; + private _enabledValue = false; + private _debugData: ISessionChatPillsDebugData | undefined; + + constructor( + private readonly _session: IObservable, + private readonly _chat: IObservable, + private readonly _enabled: IObservable, + @IBrowserViewWorkbenchService private readonly _browserViewService: IBrowserViewWorkbenchService, + @IActionWidgetService actionWidgetService: IActionWidgetService, + @IEditorService private readonly _editorService: IEditorService, + ) { + super(); + + this._pill = this._register(new SessionActivityPill({ + className: 'session-browsers', + widgetId: 'sessionBrowsers', + getWidgetAriaLabel: () => localize('browsers.ariaLabel', "Browsers"), + getSummary: activities => this._summary(activities), + openActivity: activity => this._openActivity(activity), + }, actionWidgetService)); + this.element = this._pill.element; + this.isVisible = this._pill.isVisible; + + this._register(autorun(reader => { + const session = this._session.read(reader); + const chat = this._chat.read(reader); + this._currentChat = chat; + this._enabledValue = this._enabled.read(reader); + // Read the chat list through the reader so browsers registered by a + // subagent show up as soon as that subagent joins the session. + this._ownerIds = session && chat ? this._collectOwnerIds(session, chat, reader) : new Set(); + this._refresh(); + })); + this._register(this._browserViewService.onDidChangeBrowserViews(() => this._refreshBrowserListeners())); + this._refreshBrowserListeners(); + } + + setDebugData(data: ISessionChatPillsDebugData | undefined): void { + this._debugData = data; + this._refresh(); + } + + private _refreshBrowserListeners(): void { + const store = new DisposableStore(); + this._browserListeners.value = store; + for (const input of this._browserViewService.getKnownBrowserViews().values()) { + store.add(input.onDidChangeLabel(() => this._refresh())); + } + this._refresh(); + } + + private _refresh(): void { + const activities = this._debugData + ? this._debugData.browsers.map(label => ({ label, icon: Codicon.globe, input: undefined })) + : this._enabledValue ? this._collectBrowserActivities() : []; + this._pill.setCategories([{ title: localize('browsers.browsers', "Browsers"), activities }]); + } + + private _summary(activities: readonly IBrowserActivity[]): ISessionActivitySummary { + return { + icon: Codicon.globe, + label: localize('browsers.activeBrowsers', "{0} Active Browsers", activities.length), + ariaLabel: localize('browsers.show', "Show {0} browsers", activities.length), + }; + } + + private _collectOwnerIds(session: IActiveSession, chat: IChat, reader: IReader): ReadonlySet { + const ownerIds = new Set([chat.resource.toString()]); + for (const candidate of session.chats.read(reader)) { + if (candidate.origin?.kind === ChatOriginKind.Tool && candidate.origin.parentChat && isEqual(candidate.origin.parentChat, chat.resource)) { + ownerIds.add(candidate.resource.toString()); + } + } + return ownerIds; + } + + private _collectBrowserActivities(): IBrowserActivity[] { + const activities: IBrowserActivity[] = []; + for (const input of this._browserViewService.getKnownBrowserViews().values()) { + const ownerId = input.model?.owner.sessionId; + if (ownerId && this._ownerIds.has(ownerId)) { + activities.push({ + input, + icon: Codicon.globe, + label: input.title?.trim() || localize('browsers.browser', "Browser"), + }); + } + } + return activities; + } + + private async _openActivity(activity: IBrowserActivity): Promise { + if (!activity.input) { + return; + } + const input = this._getBrowserInputToOpen(activity.input); + const existing = this._editorService.findEditors(input.resource) + .find(identifier => identifier.editor instanceof BrowserEditorInput && identifier.editor.id === input.id); + const targetGroup = existing?.groupId ?? await this._browserViewService.getPreferredGroup(); + await this._editorService.openEditor(input, undefined, targetGroup); + } + + private _getBrowserInputToOpen(input: BrowserEditorInput): BrowserEditorInput { + const url = input.url; + if (input.model?.sharingState === BrowserViewSharingState.Shared || !url) { + return input; + } + + const activeSessionId = this._currentChat?.resource.toString(); + const shared = [...this._browserViewService.getContextualBrowserViews({ activeSessionId }).values()] + .filter(candidate => candidate.model?.sharingState === BrowserViewSharingState.Shared && browserViewUrlMatches(candidate.url, url)); + return shared.find(candidate => candidate.url === url) ?? shared.at(0) ?? input; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts index 11e24670d33..58b44d5b739 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -8,21 +8,20 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun, derived, derivedOpts, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { localize } from '../../../../nls.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ChatTurnPillsWidget, diffStatsEqual, EMPTY_DIFF_STATS, IChatTurnPillsModel, IDiffStats, IPreviewFile, observeTurnStatusPillsEnabled, openChatPreviewFile, previewFilesEqual, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { ChatTurnPillsWidget, diffStatsEqual, EMPTY_DIFF_STATS, IChatTurnPillsModel, IDiffStats, IPreviewFile, observeTurnStatusPillsEnabled, openChatTurnFile, previewFilesEqual, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IChat, isActiveSessionStatus } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { LastTurnChangesMultiDiffSourceResolver } from './lastTurnChangesMultiDiffSourceResolver.js'; import { SessionBackgroundActivitiesControl } from './sessionBackgroundActivitiesControl.js'; +import { SessionBrowsersControl } from './sessionBrowsersControl.js'; import type { ISessionChatPillsDebugData } from './sessionChatInputToolbarDebug.js'; import './media/sessionChatInputToolbar.css'; @@ -87,6 +86,7 @@ export class SessionChatInputToolbar extends Disposable { /** The chat whose last-turn changes are reflected. */ private readonly _chat = observableValue('chat', undefined); private readonly _debugData = observableValue(this, undefined); + private readonly _browsers: SessionBrowsersControl; private readonly _backgroundActivities: SessionBackgroundActivitiesControl; /** The session that owns the reflected chat, from an explicit override or resolved from the chat. */ @@ -133,10 +133,8 @@ export class SessionChatInputToolbar extends Disposable { }); constructor( - @ICommandService private readonly _commandService: ICommandService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IOpenerService private readonly _openerService: IOpenerService, - @ILogService private readonly _logService: ILogService, @ISessionsService private readonly _sessionsService: ISessionsService, @IEditorService private readonly _editorService: IEditorService, @IInstantiationService instantiationService: IInstantiationService, @@ -152,17 +150,21 @@ export class SessionChatInputToolbar extends Disposable { changesEnabled: derived(reader => this._debugData.read(reader) !== undefined || this._active.read(reader) && turnStatusPillsEnabled.read(reader)), previewEnabled: derived(reader => this._debugData.read(reader) !== undefined || this._active.read(reader) && turnStatusPillsEnabled.read(reader)), openChanges: () => this._debugData.get() ? undefined : this._openChanges(), - openPreviewFile: file => this._debugData.get() ? undefined : openChatPreviewFile(file, this._commandService, this._openerService, this._logService), + openFile: file => this._debugData.get() ? undefined : openChatTurnFile(file, this._openerService, this._configurationService), }; const pills = this._register(instantiationService.createInstance(ChatTurnPillsWidget, model)); this.element.appendChild(pills.element); + this._browsers = this._register(instantiationService.createInstance(SessionBrowsersControl, this._session, this._chat, turnStatusPillsEnabled)); + this.element.appendChild(this._browsers.element); + this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, turnStatusPillsEnabled)); this.element.appendChild(this._backgroundActivities.element); this._register(autorun(reader => { - this.element.classList.toggle('hidden', !pills.isVisible.read(reader) && !this._backgroundActivities.isVisible.read(reader)); + const anyVisible = pills.isVisible.read(reader) || this._browsers.isVisible.read(reader) || this._backgroundActivities.isVisible.read(reader); + this.element.classList.toggle('hidden', !anyVisible); })); } @@ -190,6 +192,7 @@ export class SessionChatInputToolbar extends Disposable { setDebugData(data: ISessionChatPillsDebugData | undefined): void { this._debugData.set(data, undefined); + this._browsers.setDebugData(data); this._backgroundActivities.setDebugData(data); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 5198a600a65..e6773dda8ff 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -73,7 +73,7 @@ const DEFAULT_TELEMETRY_SOURCE = 'NewChatSessionTypePicker'; */ export interface ISessionTypePickerOptions { /** - * When `false` (used e.g. by the automations dialog), an explicit pick is + * When `false` (e.g. the automations dialog), an explicit pick is * never written to or cleared from the profile-wide * {@link STORAGE_KEY_LAST_SESSION_TYPE} preference, so picking a type here * cannot change the New Session default. The stored preference is still read @@ -82,6 +82,11 @@ export interface ISessionTypePickerOptions { readonly persistSelection?: boolean; /** Telemetry id/name reported on selection. Defaults to {@link DEFAULT_TELEMETRY_SOURCE}. */ readonly telemetrySource?: string; + /** + * When `false`, the dropdown chevron is not rendered on the trigger. + * The picker is still interactive. Defaults to `true`. + */ + readonly showChevron?: boolean; } /** @@ -637,8 +642,10 @@ export class SessionTypePicker extends Disposable { const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); labelSpan.textContent = modeLabel; - const chevron = dom.append(this._triggerElement, renderIcon(Codicon.chevronDownCompact)); - chevron.classList.add('sessions-chat-dropdown-chevron'); + if (this._options?.showChevron !== false) { + const chevron = dom.append(this._triggerElement, renderIcon(Codicon.chevronDownCompact)); + chevron.classList.add('sessions-chat-dropdown-chevron'); + } this._triggerElement.ariaLabel = localize('sessionTypePicker.triggerAriaLabel', "Pick Session Type, {0}", modeLabel); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 1b791f6aea9..b9ce916e0a6 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -9,6 +9,8 @@ import { AccessibleViewProviderId, AccessibleViewType, AccessibleContentProvider import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { AccessibilityVerbositySettingId } from '../../../../workbench/contrib/accessibility/browser/accessibilityConfiguration.js'; import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { CustomViewVisibleContext } from '../../../common/contextkeys.js'; import { localize } from '../../../../nls.js'; import { FOCUS_AI_CUSTOMIZATION_VIEW_ID } from '../../aiCustomizationTreeView/browser/aiCustomizationTreeView.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; @@ -17,7 +19,8 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat readonly priority = 120; readonly name = 'sessionsChat'; readonly type = AccessibleViewType.Help; - readonly when = IsSessionsWindowContext; + // A custom view replaces the chat surface this help describes, so it does not apply then. + readonly when = ContextKeyExpr.and(IsSessionsWindowContext, CustomViewVisibleContext.negate()); getProvider(accessor: ServicesAccessor) { const sessionsPartService = accessor.get(ISessionsPartService); @@ -40,7 +43,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.voiceMode', "Start or stop Voice Mode to interact with the agent using your microphone{0}.", '')); content.push(localize('sessionsChat.micContextMenu', "To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu (for example Shift+F10).")); content.push(localize('sessionsChat.contextReferences', "Type # in the chat input to attach context. Use #file to reference a file or folder, or #session to reference another agent session. Referencing a session together with the /troubleshoot command analyzes that session's logs instead of the current one. Accept a suggestion with Tab or Enter; the reference appears as a pill above the input that you can remove.")); - content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach status pills above it, then press Enter or Space to activate a pill. A pill with multiple background activities opens a picker; use the up and down arrows to navigate, Enter to open an activity, and Escape to dismiss the picker and return focus to the pill.")); + content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach status pills above it, then press Enter or Space to activate a pill. Live browsers appear in their own pill, and background activities such as running subagents in another. A pill with more than one entry opens a picker; use the up and down arrows to navigate, Enter to open an entry, and Escape to dismiss the picker and return focus to the pill.")); content.push(localize('sessionsChat.conversations', "When a session supports multiple chats, a New Chat button is always shown: as a labeled button in the session header while the session has a single visible chat tab, and as a compact button at the end of the chat tab strip once the session has more than one visible chat tab. Activate it to start a new chat. A Chats menu is also shown in the session header meta row, at the end of the pills, once the session has more than one committed chat or the active chat has subagents. Open it to reopen a closed chat or open a subagent: each chat is listed with a checkbox, where checked chats are shown as tabs and unchecked chats are closed (hidden).")); content.push(localize('sessionsChat.closeChat', "Activate a chat tab's close button to close (hide) that chat from the tab strip without deleting it; reopen it later from the Chats menu. The session's main chat cannot be closed.")); content.push(localize('sessionsChat.deleteChat', "To permanently delete a chat, open the chat tab's context menu and choose Delete Chat. This is destructive and cannot be undone.")); diff --git a/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts new file mode 100644 index 00000000000..325ea1eeb58 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/chatView.test.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NewChatView } from '../../browser/chatView.js'; +import { NewChatInSessionWidget } from '../../browser/newChatInSessionWidget.js'; +import { NewChatWidget } from '../../browser/newChatWidget.js'; + +suite('Sessions - Chat View', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('forwards new chat visibility to the aquarium host', () => { + const forwarded: boolean[] = []; + const view: NewChatView = Object.assign(Object.create(NewChatView.prototype), { + _widget: Object.assign(Object.create(NewChatWidget.prototype), { + setHostVisible: (visible: boolean) => forwarded.push(visible), + }), + }); + + view.setVisible(false); + view.setVisible(true); + + assert.deepStrictEqual(forwarded, [false, true]); + }); + + test('does not forward aquarium visibility to the peer chat composer', () => { + const view: NewChatView = Object.assign(Object.create(NewChatView.prototype), { + _widget: Object.create(NewChatInSessionWidget.prototype), + }); + + assert.doesNotThrow(() => view.setVisible(false)); + }); +}); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundActivitiesControl.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundActivitiesControl.test.ts index 7062ad44b60..1dcdbde8785 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundActivitiesControl.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionBackgroundActivitiesControl.test.ts @@ -5,16 +5,12 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { Event } from '../../../../../base/common/event.js'; import { constObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; -import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; -import { BrowserViewSharingState, IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../../workbench/contrib/browserView/common/browserView.js'; -import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ChatOriginKind, IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; @@ -22,12 +18,6 @@ import { SessionBackgroundActivitiesControl } from '../../browser/sessionBackgro import { isNonNegativeIntegerInput, weightedRandomDebugIncrement } from '../../browser/sessionChatInputToolbarDebug.js'; interface IControlSpec { - readonly browsers?: readonly { - readonly title?: string; - readonly url?: string; - readonly owner?: 'main' | 'subagent' | 'other' | 'unowned'; - readonly sharingState?: BrowserViewSharingState; - }[]; readonly subagents?: readonly string[]; readonly subagentStatus?: SessionStatus; readonly enabled?: boolean; @@ -36,9 +26,6 @@ interface IControlSpec { interface IControlHarness { readonly control: SessionBackgroundActivitiesControl; readonly getPickerItems: () => readonly ICapturedPickerItem[]; - readonly selectPickerItem: (label: string) => void; - readonly getBrowserOpenCount: () => number; - readonly getOpenedBrowserId: () => string | undefined; readonly getOpenedChat: () => URI | undefined; } @@ -47,7 +34,6 @@ interface ICapturedPickerItem { readonly label: string; readonly category: string; readonly icon: string; - readonly select?: () => void; } function createControl(spec: IControlSpec, store: ReturnType): IControlHarness { @@ -67,66 +53,20 @@ function createControl(spec: IControlSpec, store: ReturnType { - const ownerId = browser.owner === 'subagent' - ? subagents[0]?.resource.toString() - : browser.owner === 'other' ? 'chat:other' : browser.owner === 'unowned' ? undefined : mainChat.resource.toString(); - const model = new class extends mock() { - override readonly owner = ownerId ? { mainWindowId: 1, sessionId: ownerId } : { mainWindowId: 1 }; - override readonly sharingState = browser.sharingState ?? BrowserViewSharingState.NotShared; - }(); - return new class extends mock() { - override get id(): string { return `browser-${index}`; } - override get model(): IBrowserViewModel { return model; } - override get title(): string | undefined { return browser.title; } - override get url(): string | undefined { return browser.url; } - override readonly onDidChangeLabel = Event.None; - }(); - }); - const knownBrowsers = new Map(inputs.map(input => [input.id, input])); - const browserViewService = new class extends mock() { - override readonly onDidChangeBrowserViews = Event.None; - override getKnownBrowserViews() { return knownBrowsers; } - override getContextualBrowserViews() { return knownBrowsers; } - override async getPreferredGroup() { return undefined; } - }(); - let pickerItems: ICapturedPickerItem[] = []; const actionWidgetService = new class extends mock() { override get isVisible() { return false; } override hide(): void { } - override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate): void { - pickerItems = items.map(item => { - const value = item.item; - return { - kind: item.kind, - label: item.label ?? '', - category: item.group?.title ?? '', - icon: item.group?.icon?.id ?? '', - select: value === undefined ? undefined : () => delegate.onSelect(value), - }; - }); + override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], _delegate: IActionListDelegate): void { + pickerItems = items.map(item => ({ + kind: item.kind, + label: item.label ?? '', + category: item.group?.title ?? '', + icon: item.group?.icon?.id ?? '', + })); } }(); - const selectPickerItem = (label: string) => { - const item = pickerItems.find(item => item.label === label && item.select); - if (!item?.select) { - throw new Error(`Picker item '${label}' not found`); - } - item.select(); - }; - let browserOpenCount = 0; - let openedBrowserId: string | undefined; - const browserIds = new Map(inputs.map(input => [input, input.id])); - const editorService = new class extends mock() { - override findEditors() { return []; } - override async openEditor(editor: object) { - browserOpenCount++; - openedBrowserId = browserIds.get(editor); - return undefined; - } - }(); let openedChat: URI | undefined; const sessionsService = new class extends mock() { override async openChat(_session: ISession, chatUri: URI): Promise { @@ -138,24 +78,19 @@ function createControl(spec: IControlSpec, store: ReturnType pickerItems, - selectPickerItem, - getBrowserOpenCount: () => browserOpenCount, - getOpenedBrowserId: () => openedBrowserId, getOpenedChat: () => openedChat, }; } function summarize(control: SessionBackgroundActivitiesControl): { readonly text: string; readonly ariaLabel: string | null; readonly icons: readonly string[] } { - const button = control.element.querySelector('.session-background-activities-button')!; + const button = control.element.querySelector('.session-activity-pill-button')!; const knownIcons = [Codicon.globe, Codicon.agent, Codicon.sessionInProgress, Codicon.chevronDown]; return { text: button.textContent ?? '', @@ -165,6 +100,10 @@ function summarize(control: SessionBackgroundActivitiesControl): { readonly text }; } +function click(control: SessionBackgroundActivitiesControl): void { + control.element.querySelector('.session-activity-pill-button')!.click(); +} + suite('SessionBackgroundActivitiesControl', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -200,28 +139,22 @@ suite('SessionBackgroundActivitiesControl', () => { }); }); - test('renders single and aggregate labels, icons, fallback, and subagent truncation', () => { + test('renders single and aggregate labels, icons, and subagent truncation', () => { const cases: IControlSpec[] = [ - { browsers: [{ title: 'Visual Studio Code' }] }, - { browsers: [{}] }, + { subagents: ['Research'] }, { subagents: ['Investigate the authentication failure in production'] }, - { browsers: [{ title: 'Docs' }, { title: 'Preview' }] }, { subagents: ['Research', 'Review'] }, - { browsers: [{ title: 'Preview' }], subagents: ['Research'] }, ]; - const disabled = createControl({ browsers: [{ title: 'Hidden browser' }], subagents: ['Research'], enabled: false }, store); + const disabled = createControl({ subagents: ['Research'], enabled: false }, store); assert.deepStrictEqual({ enabled: cases.map(spec => summarize(createControl(spec, store).control)), disabledVisible: disabled.control.isVisible.get(), }, { enabled: [ - { text: 'Visual Studio Code', ariaLabel: 'Open Visual Studio Code', icons: ['globe'] }, - { text: 'Browser', ariaLabel: 'Open Browser', icons: ['globe'] }, + { text: 'Research', ariaLabel: 'Open Research', icons: ['agent'] }, { text: 'Investigate the authentication...', ariaLabel: 'Open Investigate the authentication...', icons: ['agent'] }, - { text: '2 Active Browsers', ariaLabel: 'Show 2 background activities', icons: ['globe', 'chevron-down'] }, { text: '2 Active Subagents', ariaLabel: 'Show 2 background activities', icons: ['agent', 'chevron-down'] }, - { text: '2 Background Activities', ariaLabel: 'Show 2 background activities', icons: ['session-in-progress', 'chevron-down'] }, ], disabledVisible: false, }); @@ -239,7 +172,7 @@ suite('SessionBackgroundActivitiesControl', () => { }); }); - test('debug data forces activities while disabled and clears cleanly', () => { + test('ignores browsers from debug data and shows only fake subagents', () => { const harness = createControl({ enabled: false }, store); harness.control.setDebugData({ stats: { files: 2, insertions: 10, deletions: 3 }, @@ -256,104 +189,28 @@ suite('SessionBackgroundActivitiesControl', () => { harness.control.setDebugData(undefined); assert.deepStrictEqual({ forced, visibleAfterClear: harness.control.isVisible.get() }, { - forced: { - text: '2 Background Activities', - ariaLabel: 'Show 2 background activities', - icons: ['session-in-progress', 'chevron-down'], - }, + forced: { text: 'Debug Subagent', ariaLabel: 'Open Debug Subagent', icons: ['agent'] }, visibleAfterClear: false, }); }); - test('groups browsers before subagents with category headers, icons, and labels', async () => { - const harness = createControl({ - browsers: [ - { title: 'Docs' }, - { title: 'Subagent Preview', owner: 'subagent' }, - { title: 'Other Session', owner: 'other' }, - ], - subagents: ['Research', 'Review'], - }, store); + test('lists subagents in a picker under a category header', () => { + const harness = createControl({ subagents: ['Research', 'Review'] }, store); - harness.control.element.querySelector('.session-background-activities-button')!.click(); - harness.selectPickerItem('Subagent Preview'); - await Promise.resolve(); + click(harness.control); - assert.deepStrictEqual({ - items: harness.getPickerItems().map(({ select: _select, ...item }) => item), - openedBrowser: harness.getOpenedBrowserId(), - }, { - items: [ - { kind: ActionListItemKind.Header, label: 'Browsers', category: 'Browsers', icon: '' }, - { kind: ActionListItemKind.Action, label: 'Docs', category: '', icon: Codicon.globe.id }, - { kind: ActionListItemKind.Action, label: 'Subagent Preview', category: '', icon: Codicon.globe.id }, - { kind: ActionListItemKind.Separator, label: '', category: '', icon: '' }, - { kind: ActionListItemKind.Header, label: 'Subagents', category: 'Subagents', icon: '' }, - { kind: ActionListItemKind.Action, label: 'Research', category: '', icon: Codicon.agent.id }, - { kind: ActionListItemKind.Action, label: 'Review', category: '', icon: Codicon.agent.id }, - ], - openedBrowser: 'browser-1', - }); + assert.deepStrictEqual(harness.getPickerItems(), [ + { kind: ActionListItemKind.Header, label: 'Subagents', category: 'Subagents', icon: '' }, + { kind: ActionListItemKind.Action, label: 'Research', category: '', icon: Codicon.agent.id }, + { kind: ActionListItemKind.Action, label: 'Review', category: '', icon: Codicon.agent.id }, + ]); }); - test('opens a single browser or subagent directly', async () => { - const browser = createControl({ browsers: [{ title: 'Preview' }] }, store); - browser.control.element.querySelector('.session-background-activities-button')!.click(); - await Promise.resolve(); + test('opens a single subagent directly', () => { + const harness = createControl({ subagents: ['Research'] }, store); - const subagent = createControl({ subagents: ['Research'] }, store); - subagent.control.element.querySelector('.session-background-activities-button')!.click(); + click(harness.control); - assert.deepStrictEqual({ - browserOpenCount: browser.getBrowserOpenCount(), - browserOpenedChat: browser.getOpenedChat()?.toString(), - subagentBrowserOpenCount: subagent.getBrowserOpenCount(), - subagentOpenedChat: subagent.getOpenedChat()?.toString(), - }, { - browserOpenCount: 1, - browserOpenedChat: undefined, - subagentBrowserOpenCount: 0, - subagentOpenedChat: 'chat:subagent-0', - }); - }); - - test('prefers a shared browser for the same destination and otherwise opens the normal browser', async () => { - const sharedHost = createControl({ - browsers: [ - { title: 'Normal', url: 'https://example.com/start' }, - { title: 'Shared Host', url: 'https://example.com/live', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, - ], - }, store); - sharedHost.control.element.querySelector('.session-background-activities-button')!.click(); - await Promise.resolve(); - - const sharedExact = createControl({ - browsers: [ - { title: 'Normal', url: 'https://example.com/start' }, - { title: 'Shared Host', url: 'https://example.com/live', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, - { title: 'Shared Exact', url: 'https://example.com/start', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, - ], - }, store); - sharedExact.control.element.querySelector('.session-background-activities-button')!.click(); - await Promise.resolve(); - - const fallback = createControl({ - browsers: [ - { title: 'Normal', url: 'https://example.com/start' }, - { title: 'Unrelated Shared', url: 'https://other.test/live', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, - ], - }, store); - fallback.control.element.querySelector('.session-background-activities-button')!.click(); - await Promise.resolve(); - - assert.deepStrictEqual({ - sharedHost: sharedHost.getOpenedBrowserId(), - sharedExact: sharedExact.getOpenedBrowserId(), - fallback: fallback.getOpenedBrowserId(), - }, { - sharedHost: 'browser-1', - sharedExact: 'browser-2', - fallback: 'browser-0', - }); + assert.deepStrictEqual(harness.getOpenedChat()?.toString(), 'chat:subagent-0'); }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts new file mode 100644 index 00000000000..dbc12585cfb --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/sessionBrowsersControl.test.ts @@ -0,0 +1,301 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; +import { BrowserViewSharingState, IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../../workbench/contrib/browserView/common/browserView.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { ChatOriginKind, IChat, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { SessionBrowsersControl } from '../../browser/sessionBrowsersControl.js'; + +interface IControlSpec { + readonly browsers?: readonly { + readonly title?: string; + readonly url?: string; + readonly owner?: 'main' | 'subagent' | 'other' | 'unowned'; + readonly sharingState?: BrowserViewSharingState; + }[]; + readonly enabled?: boolean; + /** Start with only the main chat, so the subagent can be added later. */ + readonly withoutSubagent?: boolean; +} + +interface IControlHarness { + readonly control: SessionBrowsersControl; + readonly getPickerItems: () => readonly ICapturedPickerItem[]; + readonly selectPickerItem: (label: string) => void; + readonly getBrowserOpenCount: () => number; + readonly getOpenedBrowserId: () => string | undefined; + readonly addSubagent: () => void; +} + +interface ICapturedPickerItem { + readonly kind: ActionListItemKind; + readonly label: string; + readonly category: string; + readonly icon: string; + readonly select?: () => void; +} + +function createControl(spec: IControlSpec, store: ReturnType): IControlHarness { + const mainChat = new class extends mock() { + override readonly resource = URI.parse('chat:main'); + override readonly title = constObservable('Main'); + override readonly status = constObservable(SessionStatus.InProgress); + }(); + const subagent = new class extends mock() { + override readonly resource = URI.parse('chat:subagent-0'); + override readonly title = constObservable('Research'); + override readonly status = constObservable(SessionStatus.InProgress); + override readonly origin = { kind: ChatOriginKind.Tool, parentChat: mainChat.resource }; + }(); + const chats = observableValue('chats', spec.withoutSubagent ? [mainChat] : [mainChat, subagent]); + const session = new class extends mock() { + override readonly resource = URI.parse('session:main'); + override readonly chats = chats; + }(); + + const inputs = (spec.browsers ?? []).map((browser, index) => { + const ownerId = browser.owner === 'subagent' + ? subagent.resource.toString() + : browser.owner === 'other' ? 'chat:other' : browser.owner === 'unowned' ? undefined : mainChat.resource.toString(); + const model = new class extends mock() { + override readonly owner = ownerId ? { mainWindowId: 1, sessionId: ownerId } : { mainWindowId: 1 }; + override readonly sharingState = browser.sharingState ?? BrowserViewSharingState.NotShared; + }(); + return new class extends mock() { + override get id(): string { return `browser-${index}`; } + override get model(): IBrowserViewModel { return model; } + override get title(): string | undefined { return browser.title; } + override get url(): string | undefined { return browser.url; } + override readonly onDidChangeLabel = Event.None; + }(); + }); + const knownBrowsers = new Map(inputs.map(input => [input.id, input])); + const browserViewService = new class extends mock() { + override readonly onDidChangeBrowserViews = Event.None; + override getKnownBrowserViews() { return knownBrowsers; } + override getContextualBrowserViews() { return knownBrowsers; } + override async getPreferredGroup() { return undefined; } + }(); + + let pickerItems: ICapturedPickerItem[] = []; + const actionWidgetService = new class extends mock() { + override get isVisible() { return false; } + override hide(): void { } + override show(_user: string, _supportsPreview: boolean, items: readonly IActionListItem[], delegate: IActionListDelegate): void { + pickerItems = items.map(item => { + const value = item.item; + return { + kind: item.kind, + label: item.label ?? '', + category: item.group?.title ?? '', + icon: item.group?.icon?.id ?? '', + select: value === undefined ? undefined : () => delegate.onSelect(value), + }; + }); + } + }(); + const selectPickerItem = (label: string) => { + const item = pickerItems.find(item => item.label === label && item.select); + if (!item?.select) { + throw new Error(`Picker item '${label}' not found`); + } + item.select(); + }; + + let browserOpenCount = 0; + let openedBrowserId: string | undefined; + const browserIds = new Map(inputs.map(input => [input, input.id])); + const editorService = new class extends mock() { + override findEditors() { return []; } + override async openEditor(editor: object) { + browserOpenCount++; + openedBrowserId = browserIds.get(editor); + return undefined; + } + }(); + + const control = store.add(new SessionBrowsersControl( + constObservable(session), + constObservable(mainChat), + constObservable(spec.enabled ?? true), + browserViewService, + actionWidgetService, + editorService, + )); + + return { + control, + getPickerItems: () => pickerItems, + selectPickerItem, + getBrowserOpenCount: () => browserOpenCount, + getOpenedBrowserId: () => openedBrowserId, + addSubagent: () => chats.set([mainChat, subagent], undefined), + }; +} + +function summarize(control: SessionBrowsersControl): { readonly text: string; readonly ariaLabel: string | null; readonly icons: readonly string[] } { + const button = control.element.querySelector('.session-activity-pill-button')!; + const knownIcons = [Codicon.globe, Codicon.agent, Codicon.sessionInProgress, Codicon.chevronDown]; + return { + text: button.textContent ?? '', + ariaLabel: button.getAttribute('aria-label'), + icons: [...button.querySelectorAll('.codicon')] + .map(element => knownIcons.find(icon => element.classList.contains(`codicon-${icon.id}`))?.id ?? 'unknown'), + }; +} + +function click(control: SessionBrowsersControl): void { + control.element.querySelector('.session-activity-pill-button')!.click(); +} + +suite('SessionBrowsersControl', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('renders single and aggregate labels, icons, and fallback', () => { + const cases: IControlSpec[] = [ + { browsers: [{ title: 'Visual Studio Code' }] }, + { browsers: [{}] }, + { browsers: [{ title: 'Docs' }, { title: 'Preview' }] }, + ]; + const disabled = createControl({ browsers: [{ title: 'Hidden browser' }], enabled: false }, store); + + assert.deepStrictEqual({ + enabled: cases.map(spec => summarize(createControl(spec, store).control)), + disabledVisible: disabled.control.isVisible.get(), + }, { + enabled: [ + { text: 'Visual Studio Code', ariaLabel: 'Open Visual Studio Code', icons: ['globe'] }, + { text: 'Browser', ariaLabel: 'Open Browser', icons: ['globe'] }, + { text: '2 Active Browsers', ariaLabel: 'Show 2 browsers', icons: ['globe', 'chevron-down'] }, + ], + disabledVisible: false, + }); + }); + + test('debug data forces browsers while disabled and clears cleanly', () => { + const harness = createControl({ enabled: false }, store); + harness.control.setDebugData({ + stats: { files: 2, insertions: 10, deletions: 3 }, + markdownFiles: ['README.md'], + browsers: ['Debug Browser'], + subagents: ['Debug Subagent'], + ciFailed: 2, + ciPending: 1, + prFeedback: 3, + agentFeedback: 4, + autoIncrementChanges: false, + }); + const forced = summarize(harness.control); + harness.control.setDebugData(undefined); + + assert.deepStrictEqual({ forced, visibleAfterClear: harness.control.isVisible.get() }, { + forced: { text: 'Debug Browser', ariaLabel: 'Open Debug Browser', icons: ['globe'] }, + visibleAfterClear: false, + }); + }); + + test('lists browsers of the chat and its subagents, but not of other chats', async () => { + const harness = createControl({ + browsers: [ + { title: 'Docs' }, + { title: 'Subagent Preview', owner: 'subagent' }, + { title: 'Other Session', owner: 'other' }, + ], + }, store); + + click(harness.control); + harness.selectPickerItem('Subagent Preview'); + await Promise.resolve(); + + assert.deepStrictEqual({ + items: harness.getPickerItems().map(({ select: _select, ...item }) => item), + openedBrowser: harness.getOpenedBrowserId(), + }, { + items: [ + { kind: ActionListItemKind.Header, label: 'Browsers', category: 'Browsers', icon: '' }, + { kind: ActionListItemKind.Action, label: 'Docs', category: '', icon: Codicon.globe.id }, + { kind: ActionListItemKind.Action, label: 'Subagent Preview', category: '', icon: Codicon.globe.id }, + ], + openedBrowser: 'browser-1', + }); + }); + + test('shows a subagent browser registered before the subagent joins the session', () => { + const harness = createControl({ browsers: [{ title: 'Subagent Preview', owner: 'subagent' }], withoutSubagent: true }, store); + const beforeJoin = harness.control.isVisible.get(); + harness.addSubagent(); + + assert.deepStrictEqual({ beforeJoin, afterJoin: summarize(harness.control) }, { + beforeJoin: false, + afterJoin: { text: 'Subagent Preview', ariaLabel: 'Open Subagent Preview', icons: ['globe'] }, + }); + }); + + test('opens a single browser directly', async () => { + const harness = createControl({ browsers: [{ title: 'Preview' }] }, store); + click(harness.control); + await Promise.resolve(); + + assert.deepStrictEqual({ + openCount: harness.getBrowserOpenCount(), + openedBrowser: harness.getOpenedBrowserId(), + }, { + openCount: 1, + openedBrowser: 'browser-0', + }); + }); + + test('prefers a shared browser for the same destination and otherwise opens the normal browser', async () => { + const sharedHost = createControl({ + browsers: [ + { title: 'Normal', url: 'https://example.com/start' }, + { title: 'Shared Host', url: 'https://example.com/live', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, + ], + }, store); + click(sharedHost.control); + await Promise.resolve(); + + const sharedExact = createControl({ + browsers: [ + { title: 'Normal', url: 'https://example.com/start' }, + { title: 'Shared Host', url: 'https://example.com/live', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, + { title: 'Shared Exact', url: 'https://example.com/start', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, + ], + }, store); + click(sharedExact.control); + await Promise.resolve(); + + const fallback = createControl({ + browsers: [ + { title: 'Normal', url: 'https://example.com/start' }, + { title: 'Unrelated Shared', url: 'https://other.test/live', owner: 'unowned', sharingState: BrowserViewSharingState.Shared }, + ], + }, store); + click(fallback.control); + await Promise.resolve(); + + assert.deepStrictEqual({ + sharedHost: sharedHost.getOpenedBrowserId(), + sharedExact: sharedExact.getOpenedBrowserId(), + fallback: fallback.getOpenedBrowserId(), + }, { + sharedHost: 'browser-1', + sharedExact: 'browser-2', + fallback: 'browser-0', + }); + }); +}); diff --git a/src/vs/sessions/contrib/customViewTest/browser/customViewTest.contribution.ts b/src/vs/sessions/contrib/customViewTest/browser/customViewTest.contribution.ts new file mode 100644 index 00000000000..aac67a78445 --- /dev/null +++ b/src/vs/sessions/contrib/customViewTest/browser/customViewTest.contribution.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/customViewTest.css'; +import { $ } from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { constObservable, IObservable } from '../../../../base/common/observable.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IsDevelopmentContext } from '../../../../platform/contextkey/common/contextkeys.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Menus } from '../../../browser/menus.js'; +import { AbstractCustomView } from '../../../services/customView/browser/customView.js'; +import { ICustomViewService } from '../../../services/customView/browser/customViewService.js'; + +const TEST_CUSTOM_VIEW_ID = 'sessions.customView.test'; + +/** Placeholder content used to exercise the custom view grid until real views exist. */ +class TestCustomView extends AbstractCustomView { + + private static readonly ITEM_COUNT = 40; + + readonly title: IObservable = constObservable(localize('testCustomView.title', "Test Custom View")); + override readonly description: IObservable = constObservable( + localize('testCustomView.description', "A placeholder view used to verify the custom view grid layout, header and scrolling.")); + + render(container: HTMLElement): void { + for (let i = 0; i < TestCustomView.ITEM_COUNT; i++) { + container.appendChild($('.custom-view-test-item', undefined, localize('testCustomView.item', "Item {0}", i + 1))); + } + } + + layout(_width: number, _height: number): void { } +} + +class TestCustomViewContribution extends Disposable { + + static readonly ID = 'sessions.contrib.customViewTest'; + + constructor( + @ICustomViewService customViewService: ICustomViewService, + ) { + super(); + + this._register(customViewService.registerCustomView({ + id: TEST_CUSTOM_VIEW_ID, + ctor: new SyncDescriptor(TestCustomView), + actions: { style: 'toolbar', menuId: Menus.CustomViewTest }, + })); + } +} + +registerWorkbenchContribution2(TestCustomViewContribution.ID, TestCustomViewContribution, WorkbenchPhase.BlockRestore); + +class ShowTestCustomViewAction extends Action2 { + + constructor() { + super({ + id: 'sessions.customView.showTestView', + title: localize2('showTestCustomView', "Show Test Custom View"), + category: Categories.Developer, + f1: true, + precondition: IsDevelopmentContext, + }); + } + + run(accessor: ServicesAccessor): void { + accessor.get(ICustomViewService).showCustomView(TEST_CUSTOM_VIEW_ID); + } +} + +class HideTestCustomViewAction extends Action2 { + + constructor() { + super({ + id: 'sessions.customView.hideTestView', + title: localize2('hideTestCustomView', "Hide Test Custom View"), + category: Categories.Developer, + f1: true, + precondition: IsDevelopmentContext, + }); + } + + run(accessor: ServicesAccessor): void { + accessor.get(ICustomViewService).hideCustomView(); + } +} + +/** Sample header action so the custom view header toolbar has something to render. */ +class TestCustomViewPingAction extends Action2 { + + constructor() { + super({ + id: 'sessions.customView.testView.ping', + title: localize2('testCustomViewPing', "Ping Test Custom View"), + icon: Codicon.debugAlt, + menu: [{ id: Menus.CustomViewTest, group: 'navigation', order: 1 }], + }); + } + + run(accessor: ServicesAccessor): void { + accessor.get(INotificationService).info(localize('testCustomViewPinged', "Test custom view action ran.")); + } +} + +registerAction2(ShowTestCustomViewAction); +registerAction2(HideTestCustomViewAction); +registerAction2(TestCustomViewPingAction); diff --git a/src/vs/sessions/contrib/customViewTest/browser/media/customViewTest.css b/src/vs/sessions/contrib/customViewTest/browser/media/customViewTest.css new file mode 100644 index 00000000000..68953f6405b --- /dev/null +++ b/src/vs/sessions/contrib/customViewTest/browser/media/customViewTest.css @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.custom-view-test-item { + padding: var(--vscode-spacing-size60, 6px) 0; + border-bottom: 1px solid color-mix(in srgb, var(--session-view-foreground) 8%, transparent); +} diff --git a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts index 61f71e68a62..ac2b453ebd8 100644 --- a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts +++ b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts @@ -21,7 +21,7 @@ import { ActiveEditorContext, AuxiliaryBarVisibleContext, EditorPartModalContext import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { Menus } from '../../../browser/menus.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; -import { EditorMaximizedContext, HasDockedDetailsContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js'; +import { CustomViewVisibleContext, EditorMaximizedContext, HasDockedDetailsContext, SinglePaneLayoutEnabledContext } from '../../../common/contextkeys.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; @@ -337,6 +337,8 @@ class OpenModalEditorInEditorAction extends Action2 { title: localize2('openModalEditorInEditor', "Open in Editor Area"), icon: Codicon.openInWindow, f1: false, + // The editor area is not rendered while a custom view replaces the sessions grid. + precondition: CustomViewVisibleContext.negate(), menu: { id: MenuId.ModalEditorTitle, group: 'navigation', diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubIssueFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubIssueFetcher.ts new file mode 100644 index 00000000000..c852d833c38 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubIssueFetcher.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +import { GitHubIssueState, GitHubIssueStateReason, IGitHubIssue } from '../../common/types.js'; +import { GitHubApiClient, IGitHubApiResponse } from '../githubApiClient.js'; + +interface IGitHubIssueResponse { + readonly number: number; + readonly title: string; + readonly body: string | null; + readonly state: 'open' | 'closed'; + readonly state_reason: string | null; + readonly user: { readonly login: string; readonly avatar_url: string }; + readonly created_at: string; + readonly updated_at: string; + readonly closed_at: string | null; + /** Only set when the "issue" is actually a pull request. */ + readonly pull_request?: unknown; +} + +export class GitHubIssueFetcher { + + constructor( + private readonly _apiClient: GitHubApiClient, + ) { } + + async getIssue(owner: string, repo: string, issueNumber: number, etag?: string): Promise> { + const response = await this._apiClient.request( + 'GET', + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`, + 'githubApi.getIssue', + { etag } + ); + + return { + ...response, + data: response.data ? mapIssue(response.data) : undefined + }; + } +} + +function mapIssue(data: IGitHubIssueResponse): IGitHubIssue { + return { + number: data.number, + title: data.title, + body: data.body ?? '', + state: data.state === 'closed' ? GitHubIssueState.Closed : GitHubIssueState.Open, + stateReason: mapStateReason(data.state_reason), + author: { login: data.user.login, avatarUrl: data.user.avatar_url }, + createdAt: data.created_at, + updatedAt: data.updated_at, + closedAt: data.closed_at ?? undefined, + }; +} + +function mapStateReason(value: string | null): GitHubIssueStateReason | undefined { + switch (value) { + case 'completed': return GitHubIssueStateReason.Completed; + case 'not_planned': return GitHubIssueStateReason.NotPlanned; + case 'duplicate': return GitHubIssueStateReason.Duplicate; + case 'reopened': return GitHubIssueStateReason.Reopened; + default: return undefined; + } +} diff --git a/src/vs/sessions/contrib/github/browser/github.contribution.ts b/src/vs/sessions/contrib/github/browser/github.contribution.ts index a5a7609d27f..8c5db02634e 100644 --- a/src/vs/sessions/contrib/github/browser/github.contribution.ts +++ b/src/vs/sessions/contrib/github/browser/github.contribution.ts @@ -19,6 +19,7 @@ import { GitHubService, IGitHubService } from './githubService.js'; import { IPullRequestIconCache, PullRequestIconCache } from './pullRequestIconCache.js'; import './pullRequestActions.js'; +import './issueActions.js'; const TRACE_PREFIX = '[PR-ICON-TRACE]'; diff --git a/src/vs/sessions/contrib/github/browser/githubService.ts b/src/vs/sessions/contrib/github/browser/githubService.ts index c3694684d17..9c772f87631 100644 --- a/src/vs/sessions/contrib/github/browser/githubService.ts +++ b/src/vs/sessions/contrib/github/browser/githubService.ts @@ -12,6 +12,7 @@ import { GitHubRepositoryModel, GitHubRepositoryModelReferenceCollection } from import { GitHubPullRequestModel, GitHubPullRequestModelReferenceCollection } from './models/githubPullRequestModel.js'; import { GitHubPullRequestReviewThreadsModel, GitHubPullRequestReviewThreadsModelReferenceCollection } from './models/githubPullRequestReviewThreadsModel.js'; import { GitHubPullRequestCIModel, GitHubPullRequestCIModelReferenceCollection } from './models/githubPullRequestCIModel.js'; +import { GitHubIssueModel, GitHubIssueModelReferenceCollection } from './models/githubIssueModel.js'; import { GitHubChangesFetcher } from './fetchers/githubChangesFetcher.js'; import { getPullRequestKey } from '../common/utils.js'; import { derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; @@ -52,6 +53,11 @@ export interface IGitHubService { */ createPullRequestCIModelReference(owner: string, repo: string, prNumber: number, headSha: string): IReference; + /** + * Get a reference to a reactive model for a GitHub issue. + */ + createIssueModelReference(owner: string, repo: string, issueNumber: number): IReference; + /** * List files changed between two refs using the GitHub compare API. */ @@ -84,6 +90,7 @@ export class GitHubService extends Disposable implements IGitHubService { private readonly _pullRequestReferences: GitHubPullRequestModelReferenceCollection; private readonly _pullRequestReviewThreadsReferences: GitHubPullRequestReviewThreadsModelReferenceCollection; private readonly _pullRequestCIReferences: GitHubPullRequestCIModelReferenceCollection; + private readonly _issueReferences: GitHubIssueModelReferenceCollection; private readonly _apiClient: GitHubApiClient; /** @@ -111,6 +118,7 @@ export class GitHubService extends Disposable implements IGitHubService { this._pullRequestReferences = instantiationService.createInstance(GitHubPullRequestModelReferenceCollection, apiClient); this._pullRequestReviewThreadsReferences = instantiationService.createInstance(GitHubPullRequestReviewThreadsModelReferenceCollection, apiClient); this._pullRequestCIReferences = instantiationService.createInstance(GitHubPullRequestCIModelReferenceCollection, apiClient); + this._issueReferences = instantiationService.createInstance(GitHubIssueModelReferenceCollection, apiClient); const gitHubInfoObs = derivedOpts<{ owner: string; repo: string; pullRequestNumber: number } | undefined>({ equalsFn: structuralEquals }, reader => { @@ -197,6 +205,10 @@ export class GitHubService extends Disposable implements IGitHubService { return this._pullRequestCIReferences.acquire(`${getPullRequestKey(owner, repo, prNumber)}/${headSha}`, owner, repo, prNumber, headSha); } + createIssueModelReference(owner: string, repo: string, issueNumber: number): IReference { + return this._issueReferences.acquire(`${owner}/${repo}/issues/${issueNumber}`, owner, repo, issueNumber); + } + getChangedFiles(owner: string, repo: string, base: string, head: string): Promise { return this._changesFetcher.getChangedFiles(owner, repo, base, head); } diff --git a/src/vs/sessions/contrib/github/browser/issueActions.ts b/src/vs/sessions/contrib/github/browser/issueActions.ts new file mode 100644 index 00000000000..05e73bf64a9 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/issueActions.ts @@ -0,0 +1,292 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IManagedHoverContent } from '../../../../base/browser/ui/hover/hover.js'; +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { $ } from '../../../../base/browser/dom.js'; +import { arrayEquals } from '../../../../base/common/equals.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { Action2, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { Menus } from '../../../browser/menus.js'; +import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; +import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { SessionHasIssuesContext } from '../../../common/contextkeys.js'; +import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { IGitHubIssueRef, ISession } from '../../../services/sessions/common/session.js'; +import { computeAggregateIssueIcon, computeIssueIcon, GitHubIssueState, IGitHubIssue } from '../common/types.js'; +import { IGitHubService } from './githubService.js'; +import { createIssueHoverElement, createIssueListElement } from './issueHover.js'; + +/** A session issue paired with its live details, once they have been fetched. */ +interface IResolvedSessionIssue { + readonly ref: IGitHubIssueRef; + readonly issue: IGitHubIssue | undefined; +} + +// --- Open Issue action + +class OpenIssueAction extends Action2 { + static readonly ID = 'workbench.agentSessions.action.openIssue'; + + constructor() { + super({ + id: OpenIssueAction.ID, + title: localize2('agentSessions.openIssue', 'Open Issue'), + icon: Codicon.issues, + f1: false, + // Issue pill shown in the session header meta row + // (vs/sessions/browser/parts/sessionHeader.ts), right after the pull + // request pill. Rendered with a custom action view item that shows the + // aggregate issue icon plus either `#` or ` issues`. + menu: [{ + id: Menus.SessionHeaderMeta, + group: 'navigation', + order: 2, + when: SessionHasIssuesContext + }], + }); + } + + override async run(accessor: ServicesAccessor, session?: IActiveSession | ISession | ISession[]): Promise { + const openerService = accessor.get(IOpenerService); + const sessionsService = accessor.get(ISessionsService); + + const targetSession = (Array.isArray(session) ? session[0] : session) ?? sessionsService.activeSession.get(); + const issue = getSessionIssues(targetSession)[0]; + if (!issue) { + return; + } + + await openerService.open(issue.uri, { openExternal: true }); + } +} +registerAction2(OpenIssueAction); + +function getSessionIssues(session: ISession | undefined): readonly IGitHubIssueRef[] { + return session?.workspace.get()?.folders[0]?.gitRepository?.gitHubInfo.get()?.issues ?? []; +} + +// --- Open Issue action view item (session header issue pill) + +/** + * Renders the GitHub issues a session references as a single pill, the {@link OpenIssueAction} + * menu item contributed into {@link Menus.SessionHeaderMeta} (the session header meta row). + * + * A session that references one issue shows `#` and hovers to the issue's details. + * A session that references several shows ` issues` and opens a picker on click, since the + * pill then stands for a set rather than a single target. Either way the icon reflects the + * aggregate live state: open wins over closed, and closed-as-completed wins over + * closed as not planned. + * + * The issues are read from the {@link ISessionContext} so the correct per-session issues are + * shown even when several session views are visible at once. + */ +export class OpenIssueActionViewItem extends SessionHeaderMetaActionViewItem { + + private readonly _issueRefsObs: IObservable; + private readonly _issuesObs: IObservable; + + constructor( + action: MenuItemAction, + options: IActionViewItemOptions, + @ISessionContext sessionContext: ISessionContext, + @IGitHubService private readonly _gitHubService: IGitHubService, + @IOpenerService private readonly _openerService: IOpenerService, + @IHoverService private readonly _hoverService: IHoverService, + ) { + super(undefined, action, options); + + this._issueRefsObs = derivedOpts({ + owner: this, + equalsFn: (a, b) => arrayEquals(a, b, (x, y) => x.owner === y.owner && x.repo === y.repo && x.number === y.number) + }, reader => { + const session = sessionContext.session.read(reader); + const workspace = session?.workspace.read(reader); + return workspace?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.issues ?? []; + }); + + this._issuesObs = derived(reader => this._issueRefsObs.read(reader).map(ref => { + const reference = reader.store.add(this._gitHubService.createIssueModelReference(ref.owner, ref.repo, ref.number)); + return { ref, issue: reference.object.issue.read(reader) }; + })); + + // Keep the issue models warm for as long as the pill is rendered so the icon + // reflects the live state. This autorun depends only on the issue *identities*, + // so a state change does not release and re-acquire every model. + this._register(autorun(reader => { + for (const ref of this._issueRefsObs.read(reader)) { + const reference = reader.store.add(this._gitHubService.createIssueModelReference(ref.owner, ref.repo, ref.number)); + const model = reference.object; + model.refresh(); + + // A closed issue is effectively final, so it is only fetched once. Gate the + // repeating loop on a stable boolean so poll results don't toggle it. + const shouldPoll = derived(this, pollReader => model.issue.read(pollReader)?.state !== GitHubIssueState.Closed); + reader.store.add(autorun(pollReader => { + if (shouldPoll.read(pollReader)) { + pollReader.store.add(model.startPolling()); + } + })); + } + })); + + this._register(autorun(reader => { + this._issuesObs.read(reader); + this.updateLabel(); + this.updateTooltip(); + })); + } + + protected override onDidClickButton(): void { + const issues = this._issuesObs.get(); + if (issues.length > 1) { + this._showIssuePicker(issues); + return; + } + + super.onDidClickButton(); + } + + protected override getIconElement(): HTMLElement | undefined { + const icon = this._computeIcon(); + const iconElement = $(`span.chat-composite-bar-meta-item-icon${ThemeIcon.asCSSSelector(icon)}`); + if (icon.color) { + // Inline `!important` wins over `button.css`'s `.monaco-text-button .codicon + // { color: inherit !important }`, so the glyph reflects the live issue state color. + iconElement.style.setProperty('color', asCssVariable(icon.color.id), 'important'); + } + return iconElement; + } + + protected override getLabelText(): string { + const issues = this._issuesObs.get(); + if (issues.length === 0) { + return ''; + } + return issues.length === 1 + ? `#${issues[0].ref.number}` + : localize('agentSessions.openIssue.count', "{0} issues", issues.length); + } + + protected override getHoverContents(): IManagedHoverContent | undefined { + const issues = this._issuesObs.get(); + if (issues.length !== 1) { + return this.getTooltip(); + } + + const { ref, issue } = issues[0]; + return { + element: () => createIssueHoverElement({ + owner: ref.owner, + repo: ref.repo, + number: ref.number, + repositoryHref: this._getRepositoryUri(ref).toString(true), + issue, + onDidClickRepository: () => this._openerService.open(this._getRepositoryUri(ref), { openExternal: true }), + }), + }; + } + + protected override getTooltip(): string { + const issues = this._issuesObs.get(); + if (issues.length > 1) { + return localize('agentSessions.openIssue.tooltipMany', "Show the {0} Issues Referenced by This Session", issues.length); + } + const number = issues[0]?.ref.number; + return number !== undefined + ? localize('agentSessions.openIssue.tooltipWithNumber', "Open Issue #{0}", number) + : localize('agentSessions.openIssue.tooltip', "Open Issue"); + } + + private _computeIcon(): ThemeIcon { + const issues = this._issuesObs.get(); + if (issues.length === 1) { + const issue = issues[0].issue; + return issue ? computeIssueIcon(issue.state, issue.stateReason) : computeIssueIcon(GitHubIssueState.Open, undefined); + } + return computeAggregateIssueIcon(issues.map(({ issue }) => issue)); + } + + /** + * Shows the referenced issues below the pill. A sticky hover is used rather than a + * context menu because menu items render their icon on the label element, which would + * lose the per-issue state color. + */ + private _showIssuePicker(issues: readonly IResolvedSessionIssue[]): void { + const target = this.button?.element; + if (!target) { + return; + } + + const entries = issues.map(({ ref, issue }) => ({ + number: ref.number, + title: issue?.title, + icon: issue ? computeIssueIcon(issue.state, issue.stateReason) : computeIssueIcon(GitHubIssueState.Open, undefined), + uri: ref.uri, + })); + + this._hoverService.showInstantHover({ + content: createIssueListElement(entries, entry => { + this._hoverService.hideHover(); + this._openerService.open(entry.uri, { openExternal: true }); + }), + target, + position: { hoverPosition: HoverPosition.BELOW }, + persistence: { sticky: true, hideOnKeyDown: true }, + appearance: { showPointer: false, skipFadeInAnimation: true }, + trapFocus: true, + }, true); + } + + private _getRepositoryUri(ref: IGitHubIssueRef): URI { + return URI.parse(`https://github.com/${ref.owner}/${ref.repo}`); + } +} + +/** + * Registers the {@link OpenIssueActionViewItem} for the open-issue action in the session + * header meta toolbar. Registering it here (rather than in the core session header) keeps + * the rendering of the GitHub-owned action co-located with the action itself. + */ +class OpenIssueActionViewItemContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.openIssueActionViewItem'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + ) { + super(); + + // The action view item service only notifies toolbars of a factory via the event + // passed to register(), not on registration itself. A session header restored with + // existing issues may create its meta toolbar before this contribution runs, so + // announce the factory once right after registering to make those toolbars + // re-render and pick it up. + const onDidRegister = this._register(new Emitter()); + this._register(actionViewItemService.register(Menus.SessionHeaderMeta, OpenIssueAction.ID, (action, options, instantiationService) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + return instantiationService.createInstance(OpenIssueActionViewItem, action, options); + }, onDidRegister.event)); + onDidRegister.fire(); + } +} + +registerWorkbenchContribution2(OpenIssueActionViewItemContribution.ID, OpenIssueActionViewItemContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/github/browser/issueHover.ts b/src/vs/sessions/contrib/github/browser/issueHover.ts new file mode 100644 index 00000000000..73dfc0c0fd2 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/issueHover.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/issueHover.css'; + +import { $, append } from '../../../../base/browser/dom.js'; +import { safeIntl } from '../../../../base/common/date.js'; +import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { IGitHubIssue } from '../common/types.js'; + +const issueDateFormatter = safeIntl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }); + +export interface IIssueHoverData { + readonly owner: string; + readonly repo: string; + readonly number: number; + readonly repositoryHref: string; + readonly issue: IGitHubIssue | undefined; + readonly onDidClickRepository?: () => void; +} + +export function createIssueHoverElement(data: IIssueHoverData): HTMLElement { + const hoverElement = $('.sessions-issue-hover'); + + const header = append(hoverElement, $('.sessions-issue-hover-header')); + const repositoryLink = document.createElement('a'); + repositoryLink.className = 'sessions-issue-hover-repository'; + append(header, repositoryLink); + repositoryLink.href = data.repositoryHref; + repositoryLink.textContent = `${data.owner}/${data.repo}#${data.number}`; + repositoryLink.title = repositoryLink.textContent; + if (data.onDidClickRepository) { + repositoryLink.onclick = event => { + event.preventDefault(); + event.stopPropagation(); + data.onDidClickRepository?.(); + }; + } + + const date = formatIssueDate(data.issue?.createdAt); + if (date) { + append(header, $('span.sessions-issue-hover-date', undefined, localize('agentSessions.issueHover.onDate', "on {0}", date))); + } + + append(hoverElement, $('.sessions-issue-hover-title', undefined, data.issue?.title || localize('agentSessions.issueHover.titleFallback', "Issue #{0}", data.number))); + + const body = data.issue?.body.trim() || localize('agentSessions.issueHover.bodyFallback', "No description provided."); + append(hoverElement, $('.sessions-issue-hover-description', undefined, body)); + + return hoverElement; +} + +function formatIssueDate(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return undefined; + } + + return issueDateFormatter.value.format(date); +} + +/** One row of the multi-issue list shown when a session references several issues. */ +export interface IIssueListEntry { + readonly number: number; + readonly title: string | undefined; + readonly icon: ThemeIcon; +} + +/** + * Renders the session's issues as a list of ` # ` rows. Each row + * is a button so it is reachable by keyboard when the containing popup traps focus. + */ +export function createIssueListElement<T extends IIssueListEntry>(entries: readonly T[], onDidSelect: (entry: T) => void): HTMLElement { + const listElement = $('.sessions-issue-list', { role: 'list' }); + + for (const entry of entries) { + const row = append(listElement, $('button.sessions-issue-list-entry', { role: 'listitem', type: 'button' })); + row.onclick = event => { + event.preventDefault(); + event.stopPropagation(); + onDidSelect(entry); + }; + + const icon = append(row, $(`span.sessions-issue-list-entry-icon${ThemeIcon.asCSSSelector(entry.icon)}`)); + if (entry.icon.color) { + icon.style.color = asCssVariable(entry.icon.color.id); + } + + append(row, $('span.sessions-issue-list-entry-number', undefined, `#${entry.number}`)); + + const title = entry.title; + if (title) { + const titleElement = append(row, $('span.sessions-issue-list-entry-title', undefined, title)); + titleElement.title = title; + } + } + + return listElement; +} diff --git a/src/vs/sessions/contrib/github/browser/media/issueHover.css b/src/vs/sessions/contrib/github/browser/media/issueHover.css new file mode 100644 index 00000000000..0443d0fec91 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/media/issueHover.css @@ -0,0 +1,113 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sessions-issue-hover { + box-sizing: border-box; + display: flex; + flex-direction: column; + width: 520px; + max-width: 100%; + color: var(--vscode-editorHoverWidget-foreground); +} + +.sessions-issue-hover-header { + display: flex; + align-items: baseline; + gap: var(--vscode-spacing-size40); + min-width: 0; + padding: var(--vscode-spacing-size120) var(--vscode-spacing-size160) 0; + font-size: var(--vscode-agents-fontSize-body1); + line-height: 1.4; +} + +.sessions-issue-hover-repository { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.sessions-issue-hover-date { + flex-shrink: 0; + color: var(--vscode-descriptionForeground); +} + +.sessions-issue-hover-title { + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size160) var(--vscode-spacing-size120); + font-size: var(--vscode-agents-fontSize-heading2, 18px); + font-weight: var(--vscode-agents-fontWeight-semiBold, 600); + line-height: 1.25; + overflow-wrap: anywhere; +} + +.sessions-issue-hover-description { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + padding: var(--vscode-spacing-size120) var(--vscode-spacing-size160); + border-top: var(--vscode-strokeThickness) solid var(--vscode-editorHoverWidget-border); + font-size: var(--vscode-agents-fontSize-body1); + line-height: 1.4; + color: var(--vscode-descriptionForeground); + overflow-wrap: anywhere; +} + +/* --- Issue list (shown when a session references more than one issue) --- */ + +.sessions-issue-list { + box-sizing: border-box; + display: flex; + flex-direction: column; + min-width: 260px; + max-width: 420px; + padding: var(--vscode-spacing-size40) 0; +} + +.sessions-issue-list-entry { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); + min-width: 0; + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size120); + border: none; + border-radius: var(--vscode-cornerRadius-small); + background: none; + color: var(--vscode-editorHoverWidget-foreground); + font-family: inherit; + font-size: var(--vscode-agents-fontSize-body1); + line-height: 1.4; + text-align: left; + cursor: pointer; +} + +.sessions-issue-list-entry:hover, +.sessions-issue-list-entry:focus { + background-color: var(--vscode-list-hoverBackground); + outline: none; +} + +.sessions-issue-list-entry:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.sessions-issue-list-entry-icon { + flex-shrink: 0; +} + +.sessions-issue-list-entry-number { + flex-shrink: 0; + color: var(--vscode-descriptionForeground); + font-variant-numeric: tabular-nums; +} + +.sessions-issue-list-entry-title { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/src/vs/sessions/contrib/github/browser/models/githubIssueModel.ts b/src/vs/sessions/contrib/github/browser/models/githubIssueModel.ts new file mode 100644 index 00000000000..72839d2f596 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/models/githubIssueModel.ts @@ -0,0 +1,194 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { Disposable, DisposableSet, IDisposable, ReferenceCollection, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { LRUCache } from '../../../../../base/common/map.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IGitHubIssue } from '../../common/types.js'; +import { GitHubIssueFetcher } from '../fetchers/githubIssueFetcher.js'; +import { GitHubApiClient } from '../githubApiClient.js'; + +const LOG_PREFIX = '[GitHubIssueModel]'; + +/** + * How long a model waits before it revalidates on demand. Issues move far more slowly + * than pull requests, so repeated {@link GitHubIssueModel.refresh} calls — several + * session views showing the same issue, a header re-created on a session switch — + * collapse into a single request instead of producing one each. + */ +export const MIN_REFRESH_INTERVAL_MS = 60_000; + +/** How often an issue is revalidated while something keeps its model warm. */ +const DEFAULT_POLL_INTERVAL_MS = 900_000; + +/** How many disposed issues keep their revalidation state. */ +const MAX_CACHED_SNAPSHOTS = 100; + +/** + * The revalidation state of a disposed issue model: the last payload and the ETag that + * produced it. Restoring it into a freshly created model lets that model render the + * last-known state right away and revalidate with `If-None-Match`, which GitHub answers + * with a `304 Not Modified` that does not count against the API rate limit. + */ +interface IGitHubIssueSnapshot { + readonly etag: string | undefined; + readonly issue: IGitHubIssue | undefined; + readonly refreshedAt: number; +} + +export class GitHubIssueModelReferenceCollection extends ReferenceCollection<GitHubIssueModel> { + private readonly _fetcher: GitHubIssueFetcher; + + /** + * Revalidation state of issues whose model has been disposed, keyed like the + * collection itself. Session switches and list re-renders release the last reference + * to an issue model routinely; without this the next model would start cold and spend + * a full, rate-limited request re-fetching a payload that almost never changed. + */ + private readonly _snapshots = new LRUCache<string, IGitHubIssueSnapshot>(MAX_CACHED_SNAPSHOTS); + + constructor( + apiClient: GitHubApiClient, + @ILogService private readonly _logService: ILogService + ) { + super(); + this._fetcher = new GitHubIssueFetcher(apiClient); + } + + protected override createReferencedObject(key: string, owner: string, repo: string, issueNumber: number): GitHubIssueModel { + const model = new GitHubIssueModel(owner, repo, issueNumber, this._fetcher, this._logService); + const snapshot = this._snapshots.get(key); + if (snapshot) { + model.restore(snapshot); + } + return model; + } + + protected override destroyReferencedObject(key: string, object: GitHubIssueModel): void { + const snapshot = object.snapshot(); + if (snapshot) { + this._snapshots.set(key, snapshot); + } + object.dispose(); + } +} + +/** + * Reactive model for a GitHub issue. Wraps fetcher data in an observable, supports + * on-demand refresh, and can poll periodically. + * + * Every request after the first is conditional on the last ETag, so an unchanged issue + * costs a `304` that GitHub does not charge against the rate limit. On-demand refreshes + * are additionally debounced by {@link MIN_REFRESH_INTERVAL_MS} so redundant callers do + * not each produce a request. + */ +export class GitHubIssueModel extends Disposable { + + private _etag: string | undefined = undefined; + private readonly _issue = observableValue<IGitHubIssue | undefined>(this, undefined); + readonly issue: IObservable<IGitHubIssue | undefined> = this._issue; + + private _refreshPromise: Promise<void> | undefined = undefined; + /** When the last request completed (whether it returned `200` or `304`). */ + private _refreshedAt: number | undefined = undefined; + + private readonly _pollScheduler: RunOnceScheduler; + private readonly _pollingDisposables = this._register(new DisposableSet()); + + constructor( + readonly owner: string, + readonly repo: string, + readonly issueNumber: number, + private readonly _fetcher: GitHubIssueFetcher, + private readonly _logService: ILogService, + ) { + super(); + + this._pollScheduler = this._register(new RunOnceScheduler(() => this._poll(), DEFAULT_POLL_INTERVAL_MS)); + } + + /** Adopts the revalidation state of an earlier model for the same issue. */ + restore(snapshot: IGitHubIssueSnapshot): void { + this._etag = snapshot.etag; + this._refreshedAt = snapshot.refreshedAt; + if (snapshot.issue) { + this._issue.set(snapshot.issue, undefined); + } + } + + /** The revalidation state to hand to the next model for this issue, if any. */ + snapshot(): IGitHubIssueSnapshot | undefined { + return this._refreshedAt !== undefined + ? { etag: this._etag, issue: this._issue.get(), refreshedAt: this._refreshedAt } + : undefined; + } + + /** + * Revalidates the issue, unless the last request completed less than + * {@link MIN_REFRESH_INTERVAL_MS} ago. + */ + refresh(): Promise<void> { + if (this._refreshedAt !== undefined && Date.now() - this._refreshedAt < MIN_REFRESH_INTERVAL_MS) { + return Promise.resolve(); + } + + return this._refreshNow(); + } + + startPolling(intervalMs: number = DEFAULT_POLL_INTERVAL_MS): IDisposable { + const disposable = toDisposable(() => { + this._pollingDisposables.deleteAndDispose(disposable); + + if (this._pollingDisposables.size === 0) { + this._pollScheduler.cancel(); + } + }); + this._pollingDisposables.add(disposable); + + if (this._pollingDisposables.size === 1) { + this._pollScheduler.schedule(intervalMs); + } + + return disposable; + } + + private _refreshNow(): Promise<void> { + if (!this._refreshPromise) { + this._refreshPromise = this._refresh() + .finally(() => { + this._refreshPromise = undefined; + }); + } + + return this._refreshPromise; + } + + private async _poll(): Promise<void> { + // Poll ticks always revalidate; the on-demand debounce would otherwise + // swallow a tick that lands inside the debounce window. + await this._refreshNow(); + // Re-schedule for the next poll cycle (RunOnceScheduler is one-shot). + if (!this._store.isDisposed && this._pollingDisposables.size > 0) { + this._pollScheduler.schedule(); + } + } + + private async _refresh(): Promise<void> { + try { + const response = await this._fetcher.getIssue(this.owner, this.repo, this.issueNumber, this._etag); + this._refreshedAt = Date.now(); + if (response.statusCode === 200 && response.data) { + this._etag = response.etag; + this._issue.set(response.data, undefined); + } + } catch (err) { + // Leave `_refreshedAt` untouched so the next caller retries instead of being + // debounced against a request that never produced data. + this._logService.error(`${LOG_PREFIX} Failed to refresh issue ${this.owner}/${this.repo}#${this.issueNumber}:`, err); + } + } +} diff --git a/src/vs/sessions/contrib/github/common/types.ts b/src/vs/sessions/contrib/github/common/types.ts index ee5b3ffa00f..1251b21b461 100644 --- a/src/vs/sessions/contrib/github/common/types.ts +++ b/src/vs/sessions/contrib/github/common/types.ts @@ -137,6 +137,66 @@ export function computePullRequestIcon(state: GitHubPullRequestState | 'draft', //#endregion +//#region Issues + +export const enum GitHubIssueState { + Open = 'open', + Closed = 'closed', +} + +/** Why an issue was closed (GitHub's `state_reason` on the REST issue payload). */ +export const enum GitHubIssueStateReason { + Completed = 'completed', + NotPlanned = 'not_planned', + Duplicate = 'duplicate', + Reopened = 'reopened', +} + +export interface IGitHubIssue { + readonly number: number; + readonly title: string; + readonly body: string; + readonly state: GitHubIssueState; + readonly stateReason: GitHubIssueStateReason | undefined; + readonly author: IGitHubUser; + readonly createdAt: string; + readonly updatedAt: string; + readonly closedAt: string | undefined; +} + +/** + * Compute the issue status icon, mirroring how github.com colors issues: open is + * green, closed-as-completed is purple, and closed as not planned or duplicate is + * muted (the work was never done). + */ +export function computeIssueIcon(state: GitHubIssueState, stateReason: GitHubIssueStateReason | undefined): ThemeIcon { + if (state === GitHubIssueState.Open) { + return { ...Codicon.issueOpened, color: themeColorFromId('charts.green') }; + } + if (stateReason === GitHubIssueStateReason.NotPlanned || stateReason === GitHubIssueStateReason.Duplicate) { + return { ...Codicon.issueClosed, color: themeColorFromId('descriptionForeground') }; + } + return { ...Codicon.issueClosed, color: themeColorFromId('charts.purple') }; +} + +/** + * Compute a single icon summarizing a set of issues: open wins over closed, and + * closed-as-completed wins over closed as not planned or duplicate. Issues whose + * live state is not loaded yet count as open, so the icon starts optimistic and + * only settles once every issue is known to be closed. + */ +export function computeAggregateIssueIcon(issues: readonly (IGitHubIssue | undefined)[]): ThemeIcon { + if (issues.length === 0 || issues.some(issue => !issue || issue.state === GitHubIssueState.Open)) { + return computeIssueIcon(GitHubIssueState.Open, undefined); + } + + const allDiscarded = issues.every(issue => + issue!.stateReason === GitHubIssueStateReason.NotPlanned || issue!.stateReason === GitHubIssueStateReason.Duplicate); + return computeIssueIcon(GitHubIssueState.Closed, allDiscarded ? GitHubIssueStateReason.NotPlanned : GitHubIssueStateReason.Completed); +} + +//#endregion + //#region Review Comments & Threads export interface IGitHubPRComment { diff --git a/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts index bfbbcdcfc59..c11acf58a18 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts @@ -14,11 +14,13 @@ import { TestStorageService } from '../../../../../workbench/test/common/workben import { GitHubPullRequestModel } from '../../browser/models/githubPullRequestModel.js'; import { GitHubPullRequestReviewThreadsModel } from '../../browser/models/githubPullRequestReviewThreadsModel.js'; import { GitHubPullRequestCIModel, GitHubPullRequestCIModelReferenceCollection, parseWorkflowRunId } from '../../browser/models/githubPullRequestCIModel.js'; +import { GitHubIssueModelReferenceCollection, MIN_REFRESH_INTERVAL_MS } from '../../browser/models/githubIssueModel.js'; import { GitHubRepositoryModel } from '../../browser/models/githubRepositoryModel.js'; +import { GitHubApiClient } from '../../browser/githubApiClient.js'; import { GitHubPRFetcher } from '../../browser/fetchers/githubPRFetcher.js'; import { GitHubPRCIFetcher } from '../../browser/fetchers/githubPRCIFetcher.js'; import { GitHubRepositoryFetcher } from '../../browser/fetchers/githubRepositoryFetcher.js'; -import { GitHubCIOverallStatus, GitHubCheckConclusion, GitHubCheckStatus, GitHubPullRequestState, IGitHubCICheck, IGitHubPRComment, IGitHubPullRequestReview, IGitHubPullRequest, IGitHubRepository, IGitHubPullRequestReviewThread } from '../../common/types.js'; +import { GitHubCIOverallStatus, GitHubCheckConclusion, GitHubCheckStatus, GitHubIssueState, GitHubPullRequestState, IGitHubCICheck, IGitHubPRComment, IGitHubPullRequestReview, IGitHubPullRequest, IGitHubRepository, IGitHubPullRequestReviewThread } from '../../common/types.js'; //#region Mock Fetchers @@ -653,6 +655,107 @@ suite('GitHubPullRequestCIModel', () => { })); }); +suite('GitHubIssueModel', () => { + + const store = new DisposableStore(); + const logService = new NullLogService(); + + /** + * Stands in for the low-level API client so the tests can observe the exact + * `If-None-Match` value each request carries and replay `304` responses. + */ + class MockGitHubApiClient { + readonly sentETags: (string | undefined)[] = []; + readonly responses: { data?: unknown; statusCode: number; etag?: string }[] = []; + + async request(_method: string, _path: string, _callSite: string, options?: { etag?: string }) { + this.sentETags.push(options?.etag); + return this.responses.shift() ?? { data: undefined, statusCode: 304 }; + } + } + + function issueResponse(state: 'open' | 'closed', title: string) { + return { + number: 7, + title, + body: 'body', + state, + state_reason: state === 'closed' ? 'completed' : null, + user: { login: 'octocat', avatar_url: '' }, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + closed_at: null, + }; + } + + function createCollection(client: MockGitHubApiClient) { + return new GitHubIssueModelReferenceCollection(client as unknown as GitHubApiClient, logService); + } + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('revalidates with the stored ETag and keeps the last payload on 304', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + const client = new MockGitHubApiClient(); + client.responses.push({ data: issueResponse('open', 'Original'), statusCode: 200, etag: 'W/"v1"' }); + client.responses.push({ data: undefined, statusCode: 304, etag: 'W/"v1"' }); + const collection = createCollection(client); + const reference = store.add(collection.acquire('owner/repo/issues/7', 'owner', 'repo', 7)); + + await reference.object.refresh(); + await timeout(MIN_REFRESH_INTERVAL_MS); + await reference.object.refresh(); + + assert.deepStrictEqual({ + sentETags: client.sentETags, + title: reference.object.issue.get()?.title, + }, { + sentETags: [undefined, 'W/"v1"'], + title: 'Original', + }); + })); + + test('on-demand refreshes inside the debounce window collapse into one request', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + const client = new MockGitHubApiClient(); + client.responses.push({ data: issueResponse('open', 'Original'), statusCode: 200, etag: 'W/"v1"' }); + const collection = createCollection(client); + const reference = store.add(collection.acquire('owner/repo/issues/7', 'owner', 'repo', 7)); + + await reference.object.refresh(); + await timeout(MIN_REFRESH_INTERVAL_MS - 1); + await reference.object.refresh(); + + assert.strictEqual(client.sentETags.length, 1); + })); + + test('a re-created model starts from the previous one\'s payload and ETag', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + const client = new MockGitHubApiClient(); + client.responses.push({ data: issueResponse('open', 'Original'), statusCode: 200, etag: 'W/"v1"' }); + client.responses.push({ data: issueResponse('closed', 'Original'), statusCode: 200, etag: 'W/"v2"' }); + const collection = createCollection(client); + + const first = collection.acquire('owner/repo/issues/7', 'owner', 'repo', 7); + await first.object.refresh(); + first.dispose(); + + const second = store.add(collection.acquire('owner/repo/issues/7', 'owner', 'repo', 7)); + const restoredState = second.object.issue.get()?.state; + await timeout(MIN_REFRESH_INTERVAL_MS); + await second.object.refresh(); + + assert.deepStrictEqual({ + restoredState, + sentETags: client.sentETags, + state: second.object.issue.get()?.state, + }, { + restoredState: GitHubIssueState.Open, + sentETags: [undefined, 'W/"v1"'], + state: GitHubIssueState.Closed, + }); + })); +}); + suite('parseWorkflowRunId', () => { ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts index 3f215fb09e4..7ffcd1ced17 100644 --- a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts @@ -36,7 +36,7 @@ import { IPaneCompositePartService } from '../../../../workbench/services/paneco import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; -import { SessionsWelcomeVisibleContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; +import { SessionsWelcomeVisibleContext, IsQuickChatSessionContext, CustomViewVisibleContext } from '../../../common/contextkeys.js'; import { logSidePanelToggle } from '../../../common/sessionsTelemetry.js'; import { ISessionChangesService } from '../../changes/browser/sessionChangesService.js'; import { IChangesViewService } from '../../changes/common/changesViewService.js'; @@ -226,7 +226,7 @@ export abstract class BaseLayoutController extends Disposable { if (e.partId !== Parts.PANEL_PART) { return; } - if (this.multipleSessionsVisibleObs.get()) { + if (this.multipleSessionsVisibleObs.get() || this._isCustomViewVisible()) { return; } const activeSession = this._sessionsService.activeSession.get(); @@ -248,7 +248,7 @@ export abstract class BaseLayoutController extends Disposable { if (e.partId !== Parts.EDITOR_PART || this._isRestoringSessionLayout) { return; } - if (this.multipleSessionsVisibleObs.get()) { + if (this.multipleSessionsVisibleObs.get() || this._isCustomViewVisible()) { return; } const activeSession = this._sessionsService.activeSession.get(); @@ -350,6 +350,15 @@ export abstract class BaseLayoutController extends Disposable { */ protected _registerAuxiliaryControllers(): void { } + /** + * Whether a custom view currently replaces the sessions grid. The parts it + * covers are force-hidden, so those transitions must not be captured as the + * active session's layout preference. + */ + protected _isCustomViewVisible(): boolean { + return this._layoutService.isVisible(Parts.CUSTOM_VIEW_GRID_PART); + } + /** * Registers the `Toggle Side Panel` action (menu item, keybinding, * command-palette entry). The action delegates straight to `toggleSidePane()`, @@ -374,8 +383,9 @@ export abstract class BaseLayoutController extends Disposable { category: Categories.View, f1: true, // A quick chat has no side pane (Round 20 hides the empty aux bar - // and the chat is full-width), so toggling it is meaningless. - precondition: IsQuickChatSessionContext.negate(), + // and the chat is full-width), so toggling it is meaningless. A custom + // view replaces the side pane entirely. + precondition: ContextKeyExpr.and(IsQuickChatSessionContext.negate(), CustomViewVisibleContext.negate()), keybinding: { weight: KeybindingWeight.SessionsContrib, primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyB diff --git a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index c897959d39e..de1dd5daf5a 100644 --- a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts +++ b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts @@ -274,6 +274,7 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption [Parts.AUXILIARYBAR_PART, true], [Parts.PANEL_PART, false], [Parts.EDITOR_PART, true], + [Parts.CUSTOM_VIEW_GRID_PART, false], ...(options.initialPartVisibility ?? []), ]), openedViewContainers: [], diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 7be4b3f3cc7..8f19e424974 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -19,6 +19,7 @@ import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; import { AgentSession, AuthenticateParams, AuthenticateResult, IAgentConnection, IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; import { buildAnnotationsUri } from '../../../../../platform/agentHost/common/annotationsUri.js'; +import { parseGitHubIssueUrl } from '../../../../../platform/agentHost/common/githubIssueReferences.js'; import { getEffectiveAgents } from '../../../../../platform/agentHost/common/customAgents.js'; import { KNOWN_MODE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { migrateLegacyAutopilotConfig } from '../../../../../platform/agentHost/common/agentHostSchema.js'; @@ -46,7 +47,7 @@ import { getRegisteredLanguageModels, resolveConfiguredModel, resolveModelIdenti import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; -import { ChatInteractivity, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, IChat, IChatCapabilities, IGitHubInfo, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionFile, ISessionFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, effectiveChatInteractivity, IChat, IChatCapabilities, IGitHubInfo, IGitHubIssueRef, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionFile, ISessionFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionModelsSnapshot } from '../../../../services/sessions/common/sessionsProvider.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; @@ -195,7 +196,20 @@ function isGitHubInfoEqual(a: IGitHubInfo | undefined, b: IGitHubInfo | undefine a.pullRequest?.number === b.pullRequest?.number && a.pullRequest?.icon?.id === b.pullRequest?.icon?.id && a.pullRequest?.baseRefOid === b.pullRequest?.baseRefOid && - a.pullRequest?.headRefOid === b.pullRequest?.headRefOid; + a.pullRequest?.headRefOid === b.pullRequest?.headRefOid && + arrayEquals(a.issues ?? [], b.issues ?? [], (x, y) => x.owner === y.owner && x.repo === y.repo && x.number === y.number); +} + +/** Maps the GitHub issue URLs recorded on the session's metadata to issue references. */ +function toGitHubIssueRefs(issueUrls: readonly string[] | undefined): readonly IGitHubIssueRef[] | undefined { + const refs: IGitHubIssueRef[] = []; + for (const url of issueUrls ?? []) { + const reference = parseGitHubIssueUrl(url); + if (reference) { + refs.push({ ...reference, uri: URI.parse(url) }); + } + } + return refs.length > 0 ? refs : undefined; } // ============================================================================ @@ -664,6 +678,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { number: pullRequestNumber, uri: URI.parse(state.pullRequestUrl!), } : undefined, + issues: toGitHubIssueRefs(state.issueUrls), }; }); @@ -4407,6 +4422,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._cacheInitialized = true; this._sessionRefreshRetryDelay = BaseAgentHostSessionsProvider.SESSION_REFRESH_RETRY_MIN_MS; const currentKeys = new Set<string>(); + const listedAgentProviders = new Set<string>(); const added: ISession[] = []; const changed: ISession[] = []; @@ -4414,6 +4430,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const meta = this._adoptSessionMeta(rawMeta); const rawId = AgentSession.id(meta.session); currentKeys.add(rawId); + const agentProvider = AgentSession.provider(meta.session); + if (agentProvider) { + listedAgentProviders.add(agentProvider); + } const existing = this._sessionCache.get(rawId); if (existing) { @@ -4434,11 +4454,25 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Some hosts briefly omit the just-sent eager session from listSessions. // Keep the pending session visible until sendRequest graduates it. const pendingRawId = this._pendingSession?.resource.path.replace(/^\//, ''); + // The host aggregates one listing across all of its agents, and an + // agent that cannot enumerate yet (its SDK is not downloaded) can + // contribute an empty list rather than failing. When other agents + // did answer, a namespace with no row at all is therefore *unknown* + // rather than empty, and evicting it would be a silent data loss — + // `removed` discards the user's pins and group membership. A wholly + // empty listing keeps the authoritative-empty contract, since an + // agent that cannot answer at all rejects (and we never get here). + // Real deletions still arrive through `deleteSessions` and the + // `sessionRemoved` notification. + const evictUnlistedAgents = listedAgentProviders.size === 0; for (const [key, cached] of this._sessionCache) { if (!currentKeys.has(key)) { if (key === pendingRawId) { continue; } + if (!evictUnlistedAgents && !listedAgentProviders.has(cached.agentProvider)) { + continue; + } this._sessionCache.delete(key); this._runningSessionConfigs.delete(cached.sessionId); this._runningSessionConfigResolveSeq.delete(cached.sessionId); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 973a4497d46..4c9e3d8f5d8 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -204,6 +204,18 @@ class MockAgentHostService extends mock<IAgentHostService>() { this._sessions.set(AgentSession.id(meta.session), meta); } + /** + * Drop a session from what `listSessions()` reports, without going through + * `disposeSession`. Simulates an agent that cannot enumerate its sessions + * yet (auth token or SDK still loading) and so contributes nothing to the + * host's aggregated listing. + */ + stopListingSessions(...ids: string[]): void { + for (const id of ids) { + this._sessions.delete(id); + } + } + // ---- Session-state subscriptions --------------------------------------- private readonly _sessionStateEmitters = new Map<string, Emitter<SubscriptionState>>(); @@ -1093,6 +1105,75 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + test('a session whose agent reports nothing survives the refresh', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + // The host aggregates one listing across all of its agents, and an + // agent that cannot enumerate yet (SDK not downloaded) contributes an + // empty list instead of failing. Codex going quiet must not evict its + // sessions: `removed` is treated as a definitive deletion downstream + // and would discard the user's pins and groups. + agentHost.setAgents([ + { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo, + { provider: 'codex', displayName: 'Codex', description: '', models: [] } as AgentInfo, + ]); + const configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration(AgentHostCodexAgentEnabledSettingId, true); + agentHost.addSession(createSession('codex-1', { provider: 'codex', summary: 'Codex One' })); + agentHost.addSession(createSession('cli-1', { provider: 'copilotcli', summary: 'CLI One' })); + + const provider = createProvider(disposables, agentHost, undefined, { configurationService }); + await timeout(0); + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + agentHost.stopListingSessions('codex-1'); + agentHost.fireAction({ + channel: buildDefaultChatUri(AgentSession.uri('copilotcli', 'cli-1').toString()), + action: { type: ActionType.ChatTurnComplete }, + serverSeq: 1, + origin: undefined, + } as ActionEnvelope); + await timeout(0); + + assert.deepStrictEqual({ + removed: changes.flatMap(c => c.removed.map(s => s.title.get())), + cachedTitles: provider.getSessions().map(s => s.title.get()).sort(), + }, { + removed: [], + cachedTitles: ['CLI One', 'Codex One'], + }); + })); + + test('a session missing while its agent still reports others is evicted', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + // The agent answered and listed a sibling session, so its namespace is + // known: the missing session really is gone and must be evicted. + agentHost.addSession(createSession('cli-gone', { provider: 'copilotcli', summary: 'Gone' })); + agentHost.addSession(createSession('cli-kept', { provider: 'copilotcli', summary: 'Kept' })); + + const provider = createProvider(disposables, agentHost); + await timeout(0); + + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + agentHost.stopListingSessions('cli-gone'); + agentHost.fireAction({ + channel: buildDefaultChatUri(AgentSession.uri('copilotcli', 'cli-kept').toString()), + action: { type: ActionType.ChatTurnComplete }, + serverSeq: 1, + origin: undefined, + } as ActionEnvelope); + await timeout(0); + + assert.deepStrictEqual({ + removed: changes.flatMap(c => c.removed.map(s => s.title.get())), + cachedTitles: provider.getSessions().map(s => s.title.get()).sort(), + }, { + removed: ['Gone'], + cachedTitles: ['Kept'], + }); + })); + test('a successful empty listSessions arms no retry', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { // No sessions on the host: listSessions() succeeds with []. This is a // valid result, not a failure — the cache should be marked initialized diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSessionEventsFile.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSessionEventsFile.test.ts index 8bb8f2447a5..9224f7e7f48 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSessionEventsFile.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/openSessionEventsFile.test.ts @@ -73,6 +73,19 @@ suite('openSessionEventsFile resolveEventsUri', () => { ); }); + test('local AH copilotcli session resolves from COPILOT_HOME', () => { + const result = resolveEventsUri( + URI.parse('agent-host-copilotcli:/abc'), + userHome, + () => undefined, + { COPILOT_HOME: '/custom/copilot' }, + ); + assert.deepStrictEqual( + { kind: result.kind, resource: result.kind === 'ok' ? result.resource.toString() : undefined }, + { kind: 'ok', resource: 'file:///custom/copilot/session-state/abc/events.jsonl' }, + ); + }); + test('copilot log roots resolve beside session-state', () => { const conn = makeRemoteConn('localhost:4321', '/home/remote'); const remoteLogs = buildRemoteCopilotLogsUri(conn); @@ -97,6 +110,13 @@ suite('openSessionEventsFile resolveEventsUri', () => { }); }); + test('local copilot log root resolves from COPILOT_HOME', () => { + assert.strictEqual( + buildLocalCopilotLogsUri(userHome, { COPILOT_HOME: '/custom/copilot' }).toString(), + 'file:///custom/copilot/logs', + ); + }); + test('EH CLI copilotcli session resolves to ~/.copilot/session-state/<id>/events.jsonl', () => { const result = resolveEventsUri(URI.parse('copilotcli:/abc'), userHome, () => undefined); assert.deepStrictEqual( diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 7218a9c30f1..8ec22317835 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -16,7 +16,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IAgentSession } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { getAgentSessionPullRequestUri, IAgentSession } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { getRepositoryName } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.js'; import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { AgentSessionProviders, AgentSessionTarget } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; @@ -1312,31 +1312,7 @@ class AgentSessionAdapter implements ICopilotChatSession { } private _extractPullRequestUri(session: IAgentSession): URI | undefined { - const metadata = session.metadata; - if (!metadata) { - return undefined; - } - - const url = metadata.pullRequestUrl as string | undefined; - if (url) { - try { - return URI.parse(url); - } catch { - // fall through - } - } - - // Construct from pullRequestNumber + owner/repo - const prNumber = metadata.pullRequestNumber as number | undefined; - if (typeof prNumber === 'number') { - const owner = metadata.owner as string | undefined; - const name = metadata.name as string | undefined; - if (owner && name) { - return URI.parse(`https://github.com/${owner}/${name}/pull/${prNumber}`); - } - } - - return undefined; + return getAgentSessionPullRequestUri(session); } private _extractChanges(session: IAgentSession): readonly ISessionFileChange[] { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHost.contribution.ts index 832313eb375..da019e61187 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHost.contribution.ts @@ -9,7 +9,9 @@ import { ICloudSandboxAgentHostService, ICloudSandboxCredentialsService } from ' import { CloudSandboxAgentHostService } from './cloudSandboxAgentHostService.js'; import { CloudSandboxAgentHostContribution } from './cloudSandboxAgentHostContribution.js'; import { CloudSandboxCredentialsService } from './cloudSandboxCredentialsService.js'; +import { CloudSandboxTelemetryService, ICloudSandboxTelemetryService } from './cloudSandboxTelemetry.js'; +registerSingleton(ICloudSandboxTelemetryService, CloudSandboxTelemetryService, InstantiationType.Delayed); registerSingleton(ICloudSandboxCredentialsService, CloudSandboxCredentialsService, InstantiationType.Delayed); registerSingleton(ICloudSandboxAgentHostService, CloudSandboxAgentHostService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts index ae988150cd7..1e4147b8539 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostService.ts @@ -5,8 +5,8 @@ import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { Disposable, DisposableMap, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; -import { disposableTimeout, timeout } from '../../../../../base/common/async.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { timeout } from '../../../../../base/common/async.js'; import { IProtocolTransport } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; import { RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; import { editorWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js'; @@ -21,6 +21,7 @@ import { CloudSandboxEnvironmentOfflineError, ICloudSandboxCredentialsService, isCloudSandboxSealedToken, + isRetryableCloudSandboxError, type ICloudSandboxClientToken, type ICloudSandboxEnvironment, } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; @@ -29,25 +30,13 @@ import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { CloudSandboxCredentialRefresher, MAX_WAKING_DELAY_MS, type ICloudSandboxCreds } from './cloudSandboxCredentialRefresh.js'; const LOG_PREFIX = '[CloudSandboxAgentHost]'; /** Maximum number of `/connect` "waking" retries before giving up. */ const MAX_WAKING_RETRIES = 20; -/** Upper bound on a single waking Retry-After wait (ms), guarding against a hostile header. */ -const MAX_WAKING_DELAY_MS = 30_000; - -/** Refresh the Web PubSub credentials this long before the access token's `expires_at`. */ -const CREDENTIAL_REFRESH_LEAD_MS = 60_000; - -/** Floor / ceiling for the scheduled credential-refresh delay. */ -const MIN_CREDENTIAL_REFRESH_DELAY_MS = 5_000; -const MAX_CREDENTIAL_REFRESH_DELAY_MS = 55 * 60_000; - -/** Backoff delay after a failed credential refresh before retrying. */ -const CREDENTIAL_REFRESH_RETRY_MS = 30_000; - /** Maximum time to wait for a sandbox environment to report `online` before giving up. */ const ENVIRONMENT_READY_TIMEOUT_MS = 120_000; @@ -63,24 +52,6 @@ const MAX_ESTABLISH_ATTEMPTS = 3; /** Delay between establish attempts. */ const ESTABLISH_RETRY_DELAY_MS = 2_000; -/** Mutable holder for the current Web PubSub credentials, read by the transport factory. */ -interface ICloudSandboxCreds { - token: ICloudSandboxClientToken; -} - -/** - * Delay (ms) until credentials should be refreshed, computed as `expires_at` minus a lead time and - * clamped to a sane range. Falls back to the minimum when `expires_at` is missing/unparseable. - */ -function credentialRefreshDelayMs(expiresAt: string | undefined): number { - const expiryMs = expiresAt ? Date.parse(expiresAt) : NaN; - if (Number.isNaN(expiryMs)) { - return MIN_CREDENTIAL_REFRESH_DELAY_MS; - } - const delay = expiryMs - Date.now() - CREDENTIAL_REFRESH_LEAD_MS; - return Math.min(MAX_CREDENTIAL_REFRESH_DELAY_MS, Math.max(MIN_CREDENTIAL_REFRESH_DELAY_MS, delay)); -} - /** * Renderer-side coordinator for Copilot cloud sandbox connections. * @@ -158,6 +129,10 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa if (err instanceof CloudSandboxEnvironmentOfflineError) { throw err; } + // Nor can it help when Mission Control rejected the request outright. + if (!isRetryableCloudSandboxError(err)) { + throw err; + } lastError = err; if (attempt >= MAX_ESTABLISH_ATTEMPTS) { break; @@ -251,7 +226,13 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa // Keep credentials fresh for the life of the connection so reconnects have a valid token. const store = new DisposableStore(); - this._scheduleCredentialRefresh(store, address, options, clientToken.client_id, creds); + store.add(this._instantiationService.createInstance( + CloudSandboxCredentialRefresher, + address, + { environmentId: options.environmentId, sessionId: options.sessionId }, + clientToken.client_id, + creds, + )); this._managed.set(address, store); // Expose the sealed GitHub token so the AHP `authenticate` pass can present it to the host. this._creds.set(address, creds); @@ -312,38 +293,6 @@ export class CloudSandboxAgentHostService extends Disposable implements ICloudSa } } - /** - * Re-mint Web PubSub credentials shortly before they expire and write them into {@link creds}. - * The open socket is untouched; the new token is used the next time the transport is rebuilt. - */ - private _scheduleCredentialRefresh(store: DisposableStore, address: string, options: ICloudSandboxConnectOptions, clientId: string, creds: ICloudSandboxCreds): void { - const timer = store.add(new MutableDisposable()); - const arm = (delayMs: number) => { - timer.value = disposableTimeout(() => void refresh(), Math.max(MIN_CREDENTIAL_REFRESH_DELAY_MS, delayMs)); - }; - const refresh = async () => { - try { - const result = await this._credentialsService.reconnect( - { environmentId: options.environmentId, sessionId: options.sessionId }, clientId, CancellationToken.None, - ); - if (result.kind === 'waking') { - arm(Math.min(result.waking.retryAfterSeconds * 1000, MAX_WAKING_DELAY_MS)); - return; - } - // Keep the previous sealed token when a refresh omits it. - creds.token = result.token.encrypted_github_token - ? result.token - : { ...result.token, encrypted_github_token: creds.token.encrypted_github_token, host_encryption_key: creds.token.host_encryption_key }; - this._logService.trace(`${LOG_PREFIX} Refreshed Web PubSub credentials for ${address}`); - arm(credentialRefreshDelayMs(result.token.expires_at)); - } catch (err) { - this._logService.warn(`${LOG_PREFIX} Credential refresh failed for ${address}; retrying`, err); - arm(CREDENTIAL_REFRESH_RETRY_MS); - } - }; - arm(credentialRefreshDelayMs(creds.token.expires_at)); - } - /** Mint client creds, retrying (bounded) while the environment is waking. */ private async _mintWithWaking(options: ICloudSandboxConnectOptions, token: CancellationToken): Promise<ICloudSandboxClientToken> { for (let attempt = 0; attempt < MAX_WAKING_RETRIES; attempt++) { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxCredentialRefresh.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxCredentialRefresh.ts new file mode 100644 index 00000000000..a21860e09d5 --- /dev/null +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxCredentialRefresh.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from '../../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; +import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js'; +import { Disposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { + ICloudSandboxCredentialsService, + isRetryableCloudSandboxError, + type CloudSandboxConnectResult, + type ICloudSandboxClientToken, + type ICloudSandboxConnectionRequest, +} from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { ICloudSandboxTelemetryService, type CloudSandboxRefreshStopReason } from './cloudSandboxTelemetry.js'; + +const LOG_PREFIX = '[CloudSandboxAgentHost]'; + +/** Refresh the Web PubSub credentials this long before the access token's `expires_at`. */ +const CREDENTIAL_REFRESH_LEAD_MS = 60_000; + +/** + * Floor / ceiling for the scheduled credential-refresh delay. + * + * The floor doubles as the rate limit on `/reconnect`: a token that is already at or past its + * refresh point re-mints on every tick, so this bounds how fast that can happen. Tokens live for + * the best part of an hour, so a floor this high only ever applies to a degenerate one. + */ +export const MIN_CREDENTIAL_REFRESH_DELAY_MS = 30_000; +const MAX_CREDENTIAL_REFRESH_DELAY_MS = 55 * 60_000; + +/** Backoff delay after a failed credential refresh before retrying. */ +const CREDENTIAL_REFRESH_RETRY_MS = 30_000; + +/** + * Consecutive refresh cycles that may fail to produce a healthy token before the scheduler gives up. + * + * The refresh timer outlives every user interaction — it runs for as long as the window is open — so + * without a ceiling one unrecoverable environment turns into an unbounded stream of `/reconnect` + * calls, each of which asks Mission Control to resume a sandbox that cannot be resumed. + */ +export const MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES = 10; + +/** + * Refresh interval used when a token carries no usable `expires_at`. + * + * `expires_at` is required by the API, so this only covers a malformed response. Refreshing on a + * conservative fixed interval keeps such a connection working rather than dropping it outright, + * while being far enough apart that it cannot amount to a meaningful load on Mission Control. + */ +const CREDENTIAL_REFRESH_FALLBACK_MS = 15 * 60_000; + +/** Upper bound on a single waking Retry-After wait (ms), guarding against a hostile header. */ +export const MAX_WAKING_DELAY_MS = 30_000; + +/** Mutable holder for the current Web PubSub credentials, read by the transport factory. */ +export interface ICloudSandboxCreds { + token: ICloudSandboxClientToken; +} + +/** + * Delay (ms) until credentials should be refreshed, computed as `expires_at` minus a lead time and + * clamped to a sane range. Returns `undefined` when `expires_at` is missing or unparseable, leaving + * the caller to decide — there is no basis for scheduling, so silently substituting the floor would + * make a token that never reports an expiry re-mint on every tick. + */ +export function credentialRefreshDelayMs(expiresAt: string | undefined, now = Date.now()): number | undefined { + const expiryMs = expiresAt ? Date.parse(expiresAt) : NaN; + if (Number.isNaN(expiryMs)) { + return undefined; + } + const delay = expiryMs - now - CREDENTIAL_REFRESH_LEAD_MS; + return Math.min(MAX_CREDENTIAL_REFRESH_DELAY_MS, Math.max(MIN_CREDENTIAL_REFRESH_DELAY_MS, delay)); +} + +/** + * Re-mints Web PubSub credentials shortly before they expire and writes them into the credentials + * holder it was given. The open socket is untouched; the new token is used the next time the + * transport is rebuilt. + * + * The loop is bounded in three ways, because it runs unattended for the life of the window and every + * cycle costs Mission Control a sandbox resume: a permanent rejection stops it outright, + * {@link MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES} caps a run of transient ones, and + * {@link MIN_CREDENTIAL_REFRESH_DELAY_MS} rate-limits a token that always looks due for refresh. + * + * Disposing stops the loop and cancels any request in flight. + */ +export class CloudSandboxCredentialRefresher extends Disposable { + + private readonly _timer = this._register(new MutableDisposable()); + + /** + * A `MutableDisposable` silently drops a value assigned after it is disposed, so a timeout armed + * while a refresh was in flight — the connection can go away mid-request — would never be + * cancelled and would keep calling `/reconnect` for the life of the window. Cancelling on + * teardown both aborts the in-flight request and stops anything being armed afterwards. + */ + private readonly _cts = new CancellationTokenSource(); + + /** Consecutive cycles that did not yield a healthy, long-lived token. */ + private _unhealthyCycles = 0; + + constructor( + private readonly _address: string, + private readonly _request: ICloudSandboxConnectionRequest, + private readonly _clientId: string, + private readonly _creds: ICloudSandboxCreds, + @ICloudSandboxCredentialsService private readonly _credentialsService: ICloudSandboxCredentialsService, + @ICloudSandboxTelemetryService private readonly _telemetry: ICloudSandboxTelemetryService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._register(toDisposable(() => this._cts.dispose(true))); + + const initialDelayMs = credentialRefreshDelayMs(this._creds.token.expires_at); + if (initialDelayMs === undefined) { + this._armUnhealthy(CREDENTIAL_REFRESH_FALLBACK_MS, 'unusableToken', `tokens kept arriving without a usable 'expires_at'`); + return; + } + this._arm(initialDelayMs); + } + + private _stop(reason: CloudSandboxRefreshStopReason, detail: string, error?: unknown): void { + this._timer.clear(); + this._telemetry.reportCredentialRefreshStopped(reason, this._unhealthyCycles, error); + this._logService.error(`${LOG_PREFIX} Stopped refreshing credentials for ${this._address}: ${detail}. The connection will drop when the current token expires.`); + } + + private _arm(delayMs: number): void { + if (this._cts.token.isCancellationRequested) { + return; + } + this._timer.value = disposableTimeout(() => void this._refresh(), Math.max(MIN_CREDENTIAL_REFRESH_DELAY_MS, delayMs)); + } + + /** Re-arm after a cycle that produced no usable token, giving up once too many pile up. */ + private _armUnhealthy(delayMs: number, reason: CloudSandboxRefreshStopReason, detail: string): void { + if (++this._unhealthyCycles >= MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES) { + this._stop(reason, `${detail} across ${this._unhealthyCycles} consecutive attempts`); + return; + } + this._arm(delayMs); + } + + private async _refresh(): Promise<void> { + let result: CloudSandboxConnectResult; + try { + result = await this._credentialsService.reconnect(this._request, this._clientId, this._cts.token); + } catch (err) { + // Teardown cancels the in-flight request, which is a disposal rather than a refresh + // failure: counting it would log a warning for an ordinary disconnect and could report + // the loop as having given up when it was simply torn down. + if (this._cts.token.isCancellationRequested || isCancellationError(err) || err instanceof CancellationError) { + return; + } + // A rejected request (deleted environment, revoked token) fails identically however + // often it is repeated, so retrying only adds load without any prospect of recovery. + if (!isRetryableCloudSandboxError(err)) { + this._stop('permanentError', toErrorMessage(err), err); + return; + } + this._logService.warn(`${LOG_PREFIX} Credential refresh failed for ${this._address}; retrying`, err); + this._armUnhealthy(CREDENTIAL_REFRESH_RETRY_MS, 'consecutiveFailures', 'credential refresh kept failing'); + return; + } + + // The connection went away while the request was in flight; its credentials are moot. + if (this._cts.token.isCancellationRequested) { + return; + } + + if (result.kind === 'waking') { + // `/reconnect` refreshes an already-connected client, so a waking environment here is the + // sandbox disappearing underneath us rather than a wake worth waiting out. + this._armUnhealthy(Math.min(result.waking.retryAfterSeconds * 1000, MAX_WAKING_DELAY_MS), 'environmentWaking', 'environment kept reporting waking'); + return; + } + + // Keep the previous sealed token when a refresh omits it. + this._creds.token = result.token.encrypted_github_token + ? result.token + : { ...result.token, encrypted_github_token: this._creds.token.encrypted_github_token, host_encryption_key: this._creds.token.host_encryption_key }; + + this._logService.trace(`${LOG_PREFIX} Refreshed Web PubSub credentials for ${this._address}`); + const delayMs = credentialRefreshDelayMs(result.token.expires_at); + if (delayMs === undefined) { + // No basis for scheduling. Keep the connection alive on a conservative interval, but + // count the cycles so an endless stream of unschedulable tokens still terminates. + this._armUnhealthy(CREDENTIAL_REFRESH_FALLBACK_MS, 'unusableToken', `tokens kept arriving without a usable 'expires_at'`); + return; + } + if (delayMs <= MIN_CREDENTIAL_REFRESH_DELAY_MS) { + // Already at (or past) its refresh point, so the next cycle would re-mint immediately. + this._armUnhealthy(delayMs, 'unusableToken', 'refreshed tokens kept expiring immediately'); + return; + } + this._unhealthyCycles = 0; + this._arm(delayMs); + } +} diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxCredentialsService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxCredentialsService.ts index 755b798298e..965d04efff2 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxCredentialsService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxCredentialsService.ts @@ -5,11 +5,13 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; +import { isCancellationError } from '../../../../../base/common/errors.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { CLOUD_SANDBOX_AGENT_SLUG, CloudSandboxAuthenticationRequiredError, CloudSandboxConnectResult, + CloudSandboxRequestError, ICloudSandboxClientToken, ICloudSandboxConnectionRequest, ICloudSandboxCredentialsService, @@ -24,6 +26,7 @@ import { IProductService } from '../../../../../platform/product/common/productS import { IRequestContext } from '../../../../../base/parts/request/common/request.js'; import { asText, IRequestService } from '../../../../../platform/request/common/request.js'; import { AuthenticationSession, IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; +import { ICloudSandboxTelemetryService, requestOutcomeForStatus, type CloudSandboxRequestAction } from './cloudSandboxTelemetry.js'; /** The agent-environment endpoints Mission Control exposes. */ type CloudSandboxEnvironmentAction = 'get' | 'connect' | 'reconnect'; @@ -76,6 +79,7 @@ export class CloudSandboxCredentialsService extends Disposable implements ICloud @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @IProductService private readonly _productService: IProductService, @ILogService private readonly _logService: ILogService, + @ICloudSandboxTelemetryService private readonly _telemetry: ICloudSandboxTelemetryService, ) { super(); } @@ -198,14 +202,14 @@ export class CloudSandboxCredentialsService extends Disposable implements ICloud ): Promise<IRequestContext> { const path = action === 'get' ? '' : `/${action}`; const url = `${GITHUB_DOT_COM_COPILOT_API_BASE_URI}/agents/environments/${encodeURIComponent(environmentId)}${path}${toQuery(searchParams)}`; - return this._request(url, `mc.environmentClient.${action}`, { + return this._request(url, `mc.environmentClient.${action}`, action === 'get' ? 'getEnvironment' : action, { 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID, }, token); } /** Issue a task API request, throwing on a non-success status. */ - private async _sendTask(url: string, action: string, token: CancellationToken): Promise<IRequestContext> { - const context = await this._request(url, `mc.taskClient.${action}`, { + private async _sendTask(url: string, action: 'list' | 'get', token: CancellationToken): Promise<IRequestContext> { + const context = await this._request(url, `mc.taskClient.${action}`, action === 'list' ? 'listTasks' : 'getTask', { 'Accept': 'application/json', 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID, }, token, DISCOVERY_TIMEOUT_MS); @@ -215,20 +219,27 @@ export class CloudSandboxCredentialsService extends Disposable implements ICloud return context; } - private async _request(url: string, callSite: string, headers: Record<string, string>, token: CancellationToken, timeout: number = REQUEST_TIMEOUT_MS): Promise<IRequestContext> { + private async _request(url: string, callSite: string, action: CloudSandboxRequestAction, headers: Record<string, string>, token: CancellationToken, timeout: number = REQUEST_TIMEOUT_MS): Promise<IRequestContext> { const accessToken = await this._resolveGitHubToken(); if (!accessToken) { + // No request is issued, so there is no request outcome to count. throw new CloudSandboxAuthenticationRequiredError(); } try { - return await this._requestService.request({ + const context = await this._requestService.request({ type: 'GET', url, headers: { ...headers, ['Authorization']: `Bearer ${accessToken}` }, timeout, callSite, }, token); + this._telemetry.reportRequest(action, requestOutcomeForStatus(context.res.statusCode)); + return context; } catch (error) { + // A cancelled request was never answered, so it is not a failure worth counting. + if (!isCancellationError(error) && !token.isCancellationRequested) { + this._telemetry.reportRequest(action, 'networkError'); + } this._logService.error(`${LOG_PREFIX} GET ${url} failed: ${toErrorMessage(error)}`); throw error; } @@ -257,7 +268,11 @@ export class CloudSandboxCredentialsService extends Disposable implements ICloud /** Throw a diagnosable error for a non-success response, including the body when readable. */ private async _throwForStatus(action: string, context: IRequestContext): Promise<never> { const body = await asText(context).catch(() => ''); - throw new Error(`Mission Control ${action} failed: HTTP ${context.res.statusCode ?? 'unknown'} - ${(body ?? '').slice(0, 200)}`); + const status = context.res.statusCode; + throw new CloudSandboxRequestError( + status, + `Mission Control ${action} failed: HTTP ${status ?? 'unknown'} - ${(body ?? '').slice(0, 200)}`, + ); } /** A GitHub session carrying at least the configured chat provider scopes. */ diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxTelemetry.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxTelemetry.ts new file mode 100644 index 00000000000..428387f787d --- /dev/null +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxTelemetry.ts @@ -0,0 +1,209 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IntervalTimer } from '../../../../../base/common/async.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { CloudSandboxRequestError } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; + +/** The Mission Control call being reported. A closed set, so it is safe to send verbatim. */ +export type CloudSandboxRequestAction = 'connect' | 'reconnect' | 'getEnvironment' | 'listTasks' | 'getTask'; + +/** + * How a Mission Control request ended, bucketed so a count is meaningful without carrying the + * response itself. `waking` is the 202 an environment returns while it boots, which is neither a + * success nor a failure but is the response most likely to be retried in a loop. `unexpectedStatus` + * covers 1xx/3xx, which the client does not treat as success either — see + * {@link requestOutcomeForStatus}. + */ +export type CloudSandboxRequestOutcome = 'succeeded' | 'waking' | 'clientError' | 'serverError' | 'networkError' | 'unexpectedStatus'; + +/** Why the credential-refresh scheduler stopped. A closed set of client-side decisions. */ +export type CloudSandboxRefreshStopReason = + /** Mission Control rejected the request in a way that repeating cannot fix (e.g. 404). */ + | 'permanentError' + /** Too many consecutive failed refreshes. */ + | 'consecutiveFailures' + /** `/reconnect` kept answering "waking" for a client that is supposed to be connected. */ + | 'environmentWaking' + /** Refreshed tokens kept arriving already expired, or without a usable `expires_at`. */ + | 'unusableToken'; + +export const ICloudSandboxTelemetryService = createDecorator<ICloudSandboxTelemetryService>('cloudSandboxTelemetryService'); + +/** + * Telemetry for the cloud sandbox integration. + * + * Owns every event the sandbox path emits so the reporting rules — what is aggregated, which values + * are closed sets, what must never carry a URL or token — live in one place instead of being + * restated at each call site. New sandbox events belong here as additional methods. + */ +export interface ICloudSandboxTelemetryService { + readonly _serviceBrand: undefined; + + /** + * Record how a Mission Control request ended. + * + * Cheap to call on every request: outcomes are accumulated and reported periodically rather than + * sent individually, because a single connect can fan out to tens of calls through waking retries + * and readiness polls. + */ + reportRequest(action: CloudSandboxRequestAction, outcome: CloudSandboxRequestOutcome): void; + + /** + * Report that credential refresh for a connection stopped, and why. + * + * Refresh is what keeps a sandbox connection usable, so each of these marks a connection that + * will drop once its current token expires — and, equally, a retry loop that was stopped from + * running indefinitely. + */ + reportCredentialRefreshStopped(reason: CloudSandboxRefreshStopReason, consecutiveFailures: number, error?: unknown): void; +} + +/** How often accumulated request counts are reported. */ +const REQUEST_REPORT_INTERVAL_MS = 30 * 60_000; + +/** + * The outcome bucket for a response with {@link statusCode}. + * + * Only 2xx counts as a success, matching the client's own `isSuccess` check — a 1xx or 3xx is + * thrown as a request failure, so counting it as a success would understate the failure rate. + */ +export function requestOutcomeForStatus(statusCode: number | undefined): CloudSandboxRequestOutcome { + if (statusCode === undefined) { + return 'networkError'; + } + if (statusCode === 202) { + return 'waking'; + } + if (statusCode >= 200 && statusCode < 300) { + return 'succeeded'; + } + if (statusCode >= 500) { + return 'serverError'; + } + if (statusCode >= 400) { + return 'clientError'; + } + return 'unexpectedStatus'; +} + +/** Per-action counts accumulated between reports. */ +type RequestCounts = Record<CloudSandboxRequestOutcome, number>; + +function emptyCounts(): RequestCounts { + return { succeeded: 0, waking: 0, clientError: 0, serverError: 0, networkError: 0, unexpectedStatus: 0 }; +} + +export class CloudSandboxTelemetryService extends Disposable implements ICloudSandboxTelemetryService { + declare readonly _serviceBrand: undefined; + + private readonly _counts = new Map<CloudSandboxRequestAction, RequestCounts>(); + private readonly _reportTimer = this._register(new IntervalTimer()); + /** When the current window began, i.e. when its first request was recorded. */ + private _windowStart = Date.now(); + + constructor( + @ITelemetryService private readonly _telemetryService: ITelemetryService, + ) { + super(); + // Report whatever has accumulated rather than losing the last window on shutdown. + this._register({ dispose: () => this.flushRequestCounts() }); + } + + reportRequest(action: CloudSandboxRequestAction, outcome: CloudSandboxRequestOutcome): void { + let counts = this._counts.get(action); + if (!counts) { + counts = emptyCounts(); + this._counts.set(action, counts); + // Only tick while there is something to report, so an idle window stays idle. The window + // starts here rather than at the last flush, so an idle stretch is not folded into + // `windowMs` — that would make the reported request rate look far lower than it was. + if (this._counts.size === 1) { + this._windowStart = Date.now(); + this._reportTimer.cancelAndSet(() => this.flushRequestCounts(), REQUEST_REPORT_INTERVAL_MS); + } + } + counts[outcome]++; + } + + reportCredentialRefreshStopped(reason: CloudSandboxRefreshStopReason, consecutiveFailures: number, error?: unknown): void { + this._telemetryService.publicLog2<CloudSandboxRefreshStoppedEvent, CloudSandboxRefreshStoppedClassification>( + 'cloudSandboxCredentialRefreshStopped', + { + reason, + consecutiveFailures, + statusCode: error instanceof CloudSandboxRequestError ? error.statusCode : undefined, + }, + ); + } + + /** Report and reset the accumulated request counts. Safe to call when nothing has been recorded. */ + flushRequestCounts(): void { + if (this._counts.size === 0) { + return; + } + const windowMs = Date.now() - this._windowStart; + for (const [action, counts] of this._counts) { + this._telemetryService.publicLog2<CloudSandboxRequestsEvent, CloudSandboxRequestsClassification>( + 'cloudSandboxRequests', + { + action, + windowMs, + total: counts.succeeded + counts.waking + counts.clientError + counts.serverError + counts.networkError + counts.unexpectedStatus, + succeeded: counts.succeeded, + waking: counts.waking, + clientError: counts.clientError, + serverError: counts.serverError, + networkError: counts.networkError, + unexpectedStatus: counts.unexpectedStatus, + }, + ); + } + this._counts.clear(); + this._reportTimer.cancel(); + } +} + +type CloudSandboxRequestsEvent = { + action: string; + windowMs: number; + total: number; + succeeded: number; + waking: number; + clientError: number; + serverError: number; + networkError: number; + unexpectedStatus: number; +}; + +type CloudSandboxRequestsClassification = { + action: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Which Mission Control call was counted (connect, reconnect, getEnvironment, listTasks or getTask).' }; + windowMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Milliseconds covered by these counts.' }; + total: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Requests issued for this action during the window.' }; + succeeded: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Requests that returned a success status.' }; + waking: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Requests answered with HTTP 202, meaning the sandbox environment was still waking.' }; + clientError: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Requests rejected with a 4xx status.' }; + serverError: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Requests that failed with a 5xx status.' }; + networkError: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Requests that never produced a response, such as a timeout.' }; + unexpectedStatus: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Requests answered with a status the client does not expect, such as 1xx or 3xx.' }; + owner: 'osortega'; + comment: 'Volume and outcome of the requests the cloud sandbox integration sends to GitHub Mission Control, used to size its load and detect runaway retry loops.'; +}; + +type CloudSandboxRefreshStoppedEvent = { + reason: string; + consecutiveFailures: number; + statusCode: number | undefined; +}; + +type CloudSandboxRefreshStoppedClassification = { + reason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Why the scheduler gave up: permanentError, consecutiveFailures, environmentWaking or unusableToken.' }; + consecutiveFailures: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Consecutive unhealthy refresh cycles preceding the stop.' }; + statusCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'HTTP status that caused a permanent stop, when the stop was caused by a rejected request.' }; + owner: 'osortega'; + comment: 'Reports that credential refresh for a cloud sandbox connection stopped, so unrecoverable sandbox sessions can be distinguished from transient failures.'; +}; diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxCredentialRefresh.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxCredentialRefresh.test.ts new file mode 100644 index 00000000000..1551abd6512 --- /dev/null +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxCredentialRefresh.test.ts @@ -0,0 +1,284 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationError } from '../../../../../../base/common/errors.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { + CloudSandboxRequestError, + type CloudSandboxConnectResult, + type ICloudSandboxClientToken, + type ICloudSandboxCredentialsService, +} from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import { + CloudSandboxCredentialRefresher, + credentialRefreshDelayMs, + MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES, + type ICloudSandboxCreds, +} from '../../browser/cloudSandboxCredentialRefresh.js'; +import type { + CloudSandboxRefreshStopReason, + CloudSandboxRequestAction, + CloudSandboxRequestOutcome, + ICloudSandboxTelemetryService, +} from '../../browser/cloudSandboxTelemetry.js'; + +const START_TIME = Date.parse('2026-01-01T00:00:00Z'); + +/** A token expiring `minutes` from `from`. 40 minutes sits comfortably clear of the refresh floor. */ +function tokenExpiringIn(minutes: number, from: number, overrides: Partial<ICloudSandboxClientToken> = {}): ICloudSandboxClientToken { + return { + access_token: 'tok', + expires_at: new Date(from + minutes * 60_000).toISOString(), + wps_endpoint: 'wss://wps.example/client/hubs/h', + hub: 'h', + subprotocol: 'json.reliable.webpubsub.azure.v1', + client_id: 'client-1', + groups: { broadcast: 'b', to_client: 'tc', to_host: 'th' }, + ...overrides, + }; +} + +/** Records the stop reports the refresher emits; request counting is covered by its own suite. */ +class RecordingTelemetry implements ICloudSandboxTelemetryService { + declare readonly _serviceBrand: undefined; + + readonly stops: { reason: CloudSandboxRefreshStopReason; consecutiveFailures: number; statusCode: number | undefined }[] = []; + + reportRequest(_action: CloudSandboxRequestAction, _outcome: CloudSandboxRequestOutcome): void { } + + reportCredentialRefreshStopped(reason: CloudSandboxRefreshStopReason, consecutiveFailures: number, error?: unknown): void { + this.stops.push({ + reason, + consecutiveFailures, + statusCode: error instanceof CloudSandboxRequestError ? error.statusCode : undefined, + }); + } +} + +/** Answers every `reconnect` from a single scripted step, so a loop can run as long as it likes. */ +class ScriptedCredentialsService { + callCount = 0; + + constructor(private readonly _step: () => CloudSandboxConnectResult | Promise<never>) { } + + async reconnect(): Promise<CloudSandboxConnectResult> { + this.callCount++; + return this._step() as CloudSandboxConnectResult; + } +} + +suite('CloudSandboxCredentialRefresher', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + /** + * Run a refresher over `durationMs` of virtual time and report what it did. The refresher is + * disposed before returning so nothing survives into the next test. + */ + async function runRefresher( + step: () => CloudSandboxConnectResult | Promise<never>, + durationMs: number, + initialToken = tokenExpiringIn(40, START_TIME), + ): Promise<{ calls: number; stops: RecordingTelemetry['stops']; creds: ICloudSandboxCreds }> { + const telemetry = new RecordingTelemetry(); + const credentials = new ScriptedCredentialsService(step); + const creds: ICloudSandboxCreds = { token: initialToken }; + const disposables = new DisposableStore(); + + disposables.add(new CloudSandboxCredentialRefresher( + 'cloudsandbox:env_1', + { environmentId: 'env_1', sessionId: 'session-1' }, + 'client-1', + creds, + credentials as unknown as ICloudSandboxCredentialsService, + telemetry, + new NullLogService(), + )); + + await new Promise<void>(resolve => setTimeout(resolve, durationMs)); + disposables.dispose(); + return { calls: credentials.callCount, stops: telemetry.stops, creds }; + } + + test('a healthy token keeps refreshing and never reports a stop', () => runWithFakedTimers<void>({ useFakeTimers: true, startTime: START_TIME }, async () => { + // Every refresh yields another healthy token, so the loop should simply keep going. Twelve + // hours is far more than the failure cap would allow if the counter were mis-managed. + const result = await runRefresher(() => ({ kind: 'token', token: tokenExpiringIn(40, Date.now()) }), 12 * 60 * 60_000); + + assert.deepStrictEqual( + { keptRefreshing: result.calls > 10, stops: result.stops }, + { keptRefreshing: true, stops: [] }, + ); + })); + + test('a permanently rejected refresh stops at once, reporting the status', () => runWithFakedTimers<void>({ useFakeTimers: true, startTime: START_TIME }, async () => { + const result = await runRefresher(() => Promise.reject(new CloudSandboxRequestError(404, 'environment gone')), 12 * 60 * 60_000); + + assert.deepStrictEqual( + result, + { + calls: 1, + stops: [{ reason: 'permanentError', consecutiveFailures: 0, statusCode: 404 }], + creds: result.creds, + }, + ); + })); + + test('transient failures stop once the consecutive-failure cap is reached', () => runWithFakedTimers<void>({ useFakeTimers: true, startTime: START_TIME }, async () => { + const result = await runRefresher(() => Promise.reject(new CloudSandboxRequestError(500, 'server error')), 12 * 60 * 60_000); + + assert.deepStrictEqual( + { calls: result.calls, stops: result.stops }, + { + calls: MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES, + stops: [{ reason: 'consecutiveFailures', consecutiveFailures: MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES, statusCode: undefined }], + }, + ); + })); + + test('a success between failures resets the cap, so a flaky connection survives', () => runWithFakedTimers<void>({ useFakeTimers: true, startTime: START_TIME }, async () => { + // Fail nine times, recover on the tenth, then fail forever. The recovery must reset the + // counter, so the stop lands a further ten failures later rather than on the tenth overall. + let call = 0; + const result = await runRefresher( + () => { + call++; + if (call === MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES) { + return { kind: 'token', token: tokenExpiringIn(40, Date.now()) }; + } + return Promise.reject(new CloudSandboxRequestError(503, 'unavailable')); + }, + 24 * 60 * 60_000, + ); + + assert.deepStrictEqual( + { calls: result.calls, stops: result.stops }, + { + calls: 2 * MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES, + stops: [{ reason: 'consecutiveFailures', consecutiveFailures: MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES, statusCode: undefined }], + }, + ); + })); + + test('a waking answer to /reconnect is bounded, since that client is already connected', () => runWithFakedTimers<void>({ useFakeTimers: true, startTime: START_TIME }, async () => { + const result = await runRefresher(() => ({ kind: 'waking', waking: { retryAfterSeconds: 5 } }), 12 * 60 * 60_000); + + assert.deepStrictEqual( + { calls: result.calls, stops: result.stops }, + { + calls: MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES, + stops: [{ reason: 'environmentWaking', consecutiveFailures: MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES, statusCode: undefined }], + }, + ); + })); + + test('tokens that arrive already due are bounded, not re-minted on every tick', () => runWithFakedTimers<void>({ useFakeTimers: true, startTime: START_TIME }, async () => { + // Expiring inside the lead time, so each refreshed token is immediately due again. + const result = await runRefresher(() => ({ kind: 'token', token: tokenExpiringIn(-5, Date.now()) }), 12 * 60 * 60_000); + + assert.deepStrictEqual( + { calls: result.calls, stops: result.stops }, + { + calls: MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES, + stops: [{ reason: 'unusableToken', consecutiveFailures: MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES, statusCode: undefined }], + }, + ); + })); + + test('a token with no usable expiry falls back to a fixed interval, still bounded', () => runWithFakedTimers<void>({ useFakeTimers: true, startTime: START_TIME }, async () => { + const result = await runRefresher(() => ({ kind: 'token', token: tokenExpiringIn(40, Date.now(), { expires_at: 'not-a-date' }) }), 24 * 60 * 60_000); + + assert.deepStrictEqual( + { calls: result.calls, stops: result.stops }, + { + calls: MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES, + stops: [{ reason: 'unusableToken', consecutiveFailures: MAX_CONSECUTIVE_CREDENTIAL_REFRESH_FAILURES, statusCode: undefined }], + }, + ); + })); + + test('disposal stops the loop without reporting it as a failure', () => runWithFakedTimers<void>({ useFakeTimers: true, startTime: START_TIME }, async () => { + // Disposing cancels the in-flight request, so the refresh rejects with a cancellation. That + // is an ordinary teardown, and must not be counted or reported as the loop giving up. + const telemetry = new RecordingTelemetry(); + const credentials = new ScriptedCredentialsService(() => Promise.reject(new CancellationError())); + const creds: ICloudSandboxCreds = { token: tokenExpiringIn(40, START_TIME) }; + const disposables = new DisposableStore(); + + disposables.add(new CloudSandboxCredentialRefresher( + 'cloudsandbox:env_1', + { environmentId: 'env_1', sessionId: 'session-1' }, + 'client-1', + creds, + credentials as unknown as ICloudSandboxCredentialsService, + telemetry, + new NullLogService(), + )); + + await new Promise<void>(resolve => setTimeout(resolve, 40 * 60_000)); + const callsBeforeDispose = credentials.callCount; + disposables.dispose(); + await new Promise<void>(resolve => setTimeout(resolve, 12 * 60 * 60_000)); + + assert.deepStrictEqual( + { callsBeforeDispose, callsAfterDispose: credentials.callCount, stops: telemetry.stops }, + { callsBeforeDispose: 1, callsAfterDispose: 1, stops: [] }, + ); + })); + + test('a refreshed token without a sealed GitHub token keeps the previous one', () => runWithFakedTimers<void>({ useFakeTimers: true, startTime: START_TIME }, async () => { + const result = await runRefresher( + () => ({ kind: 'token', token: tokenExpiringIn(40, Date.now(), { access_token: 'fresh' }) }), + 40 * 60_000, + tokenExpiringIn(40, START_TIME, { encrypted_github_token: 'copilot-sealed.v1.k.abc' }), + ); + + assert.deepStrictEqual( + { accessToken: result.creds.token.access_token, sealed: result.creds.token.encrypted_github_token }, + { accessToken: 'fresh', sealed: 'copilot-sealed.v1.k.abc' }, + ); + })); +}); + +suite('credentialRefreshDelayMs', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const inMinutes = (minutes: number) => new Date(START_TIME + minutes * 60_000).toISOString(); + + test('schedules a refresh one minute before expiry, clamped to the supported range', () => { + assert.deepStrictEqual( + { + typicalToken: credentialRefreshDelayMs(inMinutes(40), START_TIME), + beyondCeiling: credentialRefreshDelayMs(inMinutes(24 * 60), START_TIME), + dueImminently: credentialRefreshDelayMs(inMinutes(1), START_TIME), + alreadyExpired: credentialRefreshDelayMs(inMinutes(-30), START_TIME), + }, + { + typicalToken: 39 * 60_000, + beyondCeiling: 55 * 60_000, + // Never faster than the floor: a token that always looks due would otherwise re-mint + // on every tick, and each mint asks Mission Control to resume a sandbox. + dueImminently: 30_000, + alreadyExpired: 30_000, + }, + ); + }); + + test('reports no schedule when expiry is missing or unparseable', () => { + assert.deepStrictEqual( + [ + credentialRefreshDelayMs(undefined, START_TIME), + credentialRefreshDelayMs('', START_TIME), + credentialRefreshDelayMs('not-a-date', START_TIME), + ], + [undefined, undefined, undefined], + ); + }); +}); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxTelemetry.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxTelemetry.test.ts new file mode 100644 index 00000000000..311fb3cf8cd --- /dev/null +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxTelemetry.test.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { CloudSandboxRequestError } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { ITelemetryData, ITelemetryService, TelemetryLevel } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { + CloudSandboxTelemetryService, + requestOutcomeForStatus, +} from '../../browser/cloudSandboxTelemetry.js'; + +interface ICapturedEvent { + readonly eventName: string; + readonly data: ITelemetryData | undefined; +} + +class TestTelemetryService implements ITelemetryService { + declare readonly _serviceBrand: undefined; + + readonly telemetryLevel = TelemetryLevel.USAGE; + readonly sendErrorTelemetry = true; + readonly sessionId = 'sessionId'; + readonly machineId = 'machineId'; + readonly sqmId = 'sqmId'; + readonly devDeviceId = 'devDeviceId'; + readonly firstSessionDate = 'firstSessionDate'; + readonly events: ICapturedEvent[] = []; + + publicLog(): void { } + publicLogError(): void { } + publicLog2(eventName: string, data?: ITelemetryData): void { + this.events.push({ eventName, data }); + } + publicLogError2(): void { } + setExperimentProperty(): void { } + setCommonProperty(): void { } +} + +suite('cloudSandbox telemetry', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('requestOutcomeForStatus buckets every response kind', () => { + assert.deepStrictEqual( + [200, 202, 204, 400, 404, 429, 500, 503, 100, 302, undefined].map(requestOutcomeForStatus), + [ + 'succeeded', + 'waking', + 'succeeded', + 'clientError', + 'clientError', + 'clientError', + 'serverError', + 'serverError', + // Only 2xx is a success, matching the client's own check: a 1xx/3xx is thrown as a + // request failure, so counting it as a success would understate the failure rate. + 'unexpectedStatus', + 'unexpectedStatus', + 'networkError', + ], + ); + }); + + test('requests are reported per action, with outcomes broken out', () => { + const telemetryService = new TestTelemetryService(); + const sandboxTelemetry = store.add(new CloudSandboxTelemetryService(telemetryService)); + + sandboxTelemetry.reportRequest('reconnect', 'serverError'); + sandboxTelemetry.reportRequest('reconnect', 'serverError'); + sandboxTelemetry.reportRequest('reconnect', 'succeeded'); + sandboxTelemetry.reportRequest('connect', 'waking'); + sandboxTelemetry.flushRequestCounts(); + + assert.deepStrictEqual( + telemetryService.events.map(e => ({ + eventName: e.eventName, + action: e.data?.action, + total: e.data?.total, + succeeded: e.data?.succeeded, + waking: e.data?.waking, + serverError: e.data?.serverError, + })), + [ + { eventName: 'cloudSandboxRequests', action: 'reconnect', total: 3, succeeded: 1, waking: 0, serverError: 2 }, + { eventName: 'cloudSandboxRequests', action: 'connect', total: 1, succeeded: 0, waking: 1, serverError: 0 }, + ], + ); + }); + + test('flushing resets the counts, and a flush with nothing recorded reports nothing', () => { + const telemetryService = new TestTelemetryService(); + const sandboxTelemetry = store.add(new CloudSandboxTelemetryService(telemetryService)); + + sandboxTelemetry.flushRequestCounts(); + assert.strictEqual(telemetryService.events.length, 0, 'nothing recorded yet'); + + sandboxTelemetry.reportRequest('getEnvironment', 'succeeded'); + sandboxTelemetry.flushRequestCounts(); + sandboxTelemetry.flushRequestCounts(); + + assert.deepStrictEqual( + telemetryService.events.map(e => ({ action: e.data?.action, total: e.data?.total })), + [{ action: 'getEnvironment', total: 1 }], + ); + }); + + test('disposing reports whatever has been counted so far', () => { + const telemetryService = new TestTelemetryService(); + const sandboxTelemetry = new CloudSandboxTelemetryService(telemetryService); + + sandboxTelemetry.reportRequest('listTasks', 'clientError'); + sandboxTelemetry.dispose(); + + assert.deepStrictEqual( + telemetryService.events.map(e => ({ action: e.data?.action, total: e.data?.total, clientError: e.data?.clientError })), + [{ action: 'listTasks', total: 1, clientError: 1 }], + ); + }); + + test('a refresh stop reports its reason, cycle count and causing status', () => { + const telemetryService = new TestTelemetryService(); + const sandboxTelemetry = store.add(new CloudSandboxTelemetryService(telemetryService)); + + sandboxTelemetry.reportCredentialRefreshStopped('permanentError', 0, new CloudSandboxRequestError(404, 'gone')); + sandboxTelemetry.reportCredentialRefreshStopped('consecutiveFailures', 10); + + assert.deepStrictEqual( + telemetryService.events.map(e => ({ eventName: e.eventName, ...e.data })), + [ + { eventName: 'cloudSandboxCredentialRefreshStopped', reason: 'permanentError', consecutiveFailures: 0, statusCode: 404 }, + { eventName: 'cloudSandboxCredentialRefreshStopped', reason: 'consecutiveFailures', consecutiveFailures: 10, statusCode: undefined }, + ], + ); + }); + + test('the window covers only the time from its first request, not preceding idle time', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + const telemetryService = new TestTelemetryService(); + const disposables = new DisposableStore(); + const sandboxTelemetry = disposables.add(new CloudSandboxTelemetryService(telemetryService)); + + // Hours of silence before the first request. Folding that into `windowMs` would make the + // reported request rate look far lower than it actually was. + await new Promise<void>(resolve => setTimeout(resolve, 6 * 60 * 60_000)); + sandboxTelemetry.reportRequest('connect', 'succeeded'); + await new Promise<void>(resolve => setTimeout(resolve, 60_000)); + sandboxTelemetry.flushRequestCounts(); + disposables.dispose(); + + assert.deepStrictEqual( + telemetryService.events.map(e => ({ action: e.data?.action, total: e.data?.total, windowMs: e.data?.windowMs })), + [{ action: 'connect', total: 1, windowMs: 60_000 }], + ); + })); +}); diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index a5eedbb0626..5b167ae49d1 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -20,7 +20,7 @@ import { TerminalCapability } from '../../../../platform/terminal/common/capabil import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { Menus } from '../../../browser/menus.js'; import { isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js'; -import { SessionsWelcomeVisibleContext, IsPhoneLayoutContext } from '../../../common/contextkeys.js'; +import { SessionsWelcomeVisibleContext, IsPhoneLayoutContext, CustomViewVisibleContext } from '../../../common/contextkeys.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISession } from '../../../services/sessions/common/session.js'; @@ -765,6 +765,8 @@ class OpenSessionInTerminalAction extends Action2 { id: 'agentSession.openInTerminal', title: localize2('openInTerminal', "Open Terminal"), icon: Codicon.terminal, + // The panel is hidden while a custom view replaces the sessions grid. + precondition: CustomViewVisibleContext.negate(), toggled: { condition: SessionsTerminalViewVisibleContext, title: localize('hideTerminal', "Hide Terminal"), diff --git a/src/vs/sessions/services/customView/browser/customView.ts b/src/vs/sessions/services/customView/browser/customView.ts new file mode 100644 index 00000000000..69cabf4c492 --- /dev/null +++ b/src/vs/sessions/services/customView/browser/customView.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { constObservable, IObservable } from '../../../../base/common/observable.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; + +/** + * How a custom view renders the actions in its header: as an icon toolbar or as + * a row of labelled buttons. Both render the same menu, so contributed actions + * and their `when` clauses are identical either way. + */ +export type CustomViewActionsStyle = 'toolbar' | 'buttonBar'; + +export interface ICustomViewActions { + readonly style: CustomViewActionsStyle; + readonly menuId: MenuId; +} + +export interface ICustomViewDescriptor { + + /** Stable id, used by `ICustomViewService.showCustomView`. */ + readonly id: string; + + readonly ctor: SyncDescriptor<AbstractCustomView>; + + readonly actions?: ICustomViewActions; +} + +/** + * A full-surface view hosted in the custom view grid, in place of the sessions + * grid. The host renders the surrounding chrome (header with title, description + * and actions, plus the scroll container); a view only fills its content area + * and is disposed when it is hidden. + */ +export abstract class AbstractCustomView extends Disposable { + + /** Shown in the header. Observable so it can settle after an async load. */ + abstract readonly title: IObservable<string>; + + /** Optional secondary line below the title. */ + readonly description: IObservable<string | undefined> = constObservable(undefined); + + /** + * Width the content is capped to. Defaults to the same measure the session + * views use. + */ + readonly maxWidth: number | undefined = undefined; + + /** Renders the content into the host-provided container. Called once. */ + abstract render(container: HTMLElement): void; + + /** Called whenever the available content area changes. */ + abstract layout(width: number, height: number): void; + + focus(): void { } +} diff --git a/src/vs/sessions/services/customView/browser/customViewGridPartService.ts b/src/vs/sessions/services/customView/browser/customViewGridPartService.ts new file mode 100644 index 00000000000..a9e3793f16a --- /dev/null +++ b/src/vs/sessions/services/customView/browser/customViewGridPartService.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ICustomViewDescriptor } from './customView.js'; + +export const ICustomViewGridPartService = createDecorator<ICustomViewGridPartService>('customViewGridPartService'); + +/** + * Renders the custom view grid part. The part is a passive renderer: the + * Agents workbench drives it from `ICustomViewService.activeCustomView` so the + * view is rendered, made visible and focused in one ordered step. + */ +export interface ICustomViewGridPartService { + + readonly _serviceBrand: undefined; + + /** Renders the given custom view, replacing (and disposing) the previous one. */ + setView(descriptor: ICustomViewDescriptor | undefined): void; + + /** Moves keyboard focus into the rendered custom view. */ + focusActiveView(): void; +} diff --git a/src/vs/sessions/services/customView/browser/customViewService.ts b/src/vs/sessions/services/customView/browser/customViewService.ts new file mode 100644 index 00000000000..d838bfe4434 --- /dev/null +++ b/src/vs/sessions/services/customView/browser/customViewService.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ICustomViewDescriptor } from './customView.js'; + +export const ICustomViewService = createDecorator<ICustomViewService>('customViewService'); + +/** + * Owns which custom view (if any) should be rendered in place of the sessions + * grid. Only one view can be shown at a time. The Agents workbench observes + * {@link activeCustomView} and, while it is set, renders the custom view grid + * and hides the sessions grid, the side panel and the bottom panel. + */ +export interface ICustomViewService { + + readonly _serviceBrand: undefined; + + /** The view that should currently be rendered, or `undefined` for none. */ + readonly activeCustomView: IObservable<ICustomViewDescriptor | undefined>; + + registerCustomView(descriptor: ICustomViewDescriptor): IDisposable; + + /** Shows the registered view with the given id, replacing any shown view. */ + showCustomView(id: string): void; + + hideCustomView(): void; +} + +export class CustomViewService extends Disposable implements ICustomViewService { + + declare readonly _serviceBrand: undefined; + + private readonly _descriptors = new Map<string, ICustomViewDescriptor>(); + + private readonly _activeCustomView = observableValue<ICustomViewDescriptor | undefined>(this, undefined); + readonly activeCustomView: IObservable<ICustomViewDescriptor | undefined> = this._activeCustomView; + + constructor( + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + registerCustomView(descriptor: ICustomViewDescriptor): IDisposable { + if (this._descriptors.has(descriptor.id)) { + throw new Error(`A custom view with id '${descriptor.id}' is already registered`); + } + + this._descriptors.set(descriptor.id, descriptor); + + return toDisposable(() => { + this._descriptors.delete(descriptor.id); + if (this._activeCustomView.get() === descriptor) { + this._activeCustomView.set(undefined, undefined); + } + }); + } + + showCustomView(id: string): void { + const descriptor = this._descriptors.get(id); + if (!descriptor) { + this._logService.warn(`[CustomViewService] showCustomView: no custom view registered with id '${id}'`); + return; + } + + this._activeCustomView.set(descriptor, undefined); + } + + hideCustomView(): void { + this._activeCustomView.set(undefined, undefined); + } +} + +registerSingleton(ICustomViewService, CustomViewService, InstantiationType.Delayed); diff --git a/src/vs/sessions/services/customView/test/browser/customViewService.test.ts b/src/vs/sessions/services/customView/test/browser/customViewService.test.ts new file mode 100644 index 00000000000..0425c176524 --- /dev/null +++ b/src/vs/sessions/services/customView/test/browser/customViewService.test.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { constObservable, IObservable } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { AbstractCustomView, ICustomViewDescriptor } from '../../browser/customView.js'; +import { CustomViewService } from '../../browser/customViewService.js'; + +class TestCustomView extends AbstractCustomView { + readonly title: IObservable<string> = constObservable('test'); + render(): void { } + layout(): void { } +} + +suite('Sessions - CustomViewService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createService(): CustomViewService { + return disposables.add(new CustomViewService(new NullLogService())); + } + + function descriptor(id: string): ICustomViewDescriptor { + return { id, ctor: new SyncDescriptor(TestCustomView) }; + } + + test('shows, replaces and hides registered views', () => { + const service = createService(); + const first = descriptor('first'); + const second = descriptor('second'); + disposables.add(service.registerCustomView(first)); + disposables.add(service.registerCustomView(second)); + + const initial = service.activeCustomView.get(); + service.showCustomView('first'); + const shown = service.activeCustomView.get(); + service.showCustomView('second'); + const replaced = service.activeCustomView.get(); + service.hideCustomView(); + + assert.deepStrictEqual({ + initial, + shown, + replaced, + hidden: service.activeCustomView.get(), + }, { + initial: undefined, + shown: first, + replaced: second, + hidden: undefined, + }); + }); + + test('ignores an unknown id and drops the active view when it is unregistered', () => { + const service = createService(); + const registration = service.registerCustomView(descriptor('first')); + + service.showCustomView('unknown'); + const afterUnknown = service.activeCustomView.get(); + service.showCustomView('first'); + registration.dispose(); + + assert.deepStrictEqual({ + afterUnknown, + afterUnregister: service.activeCustomView.get(), + }, { + afterUnknown: undefined, + afterUnregister: undefined, + }); + }); + + test('rejects a duplicate registration', () => { + const service = createService(); + disposables.add(service.registerCustomView(descriptor('first'))); + + assert.throws(() => service.registerCustomView(descriptor('first'))); + }); +}); diff --git a/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts b/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts index 390d002ac96..da12752602e 100644 --- a/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts @@ -147,14 +147,16 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe this.save(); } + // A session dropping out of the provider's list is an eviction, not a + // deletion — an agent that cannot answer `listSessions` yet reports no + // sessions, so its sessions disappear until the next refresh. Clearing + // membership here would turn that transient gap into a permanent, + // unrecoverable loss of the user's grouping. this._register(this.sessionsManagementService.onDidChangeSessions(e => { - const changed = new Set<string>(); for (const session of e.removed) { this._inFlightSessionGroups.delete(session.sessionId); - if (this._membership.delete(session.sessionId)) { - changed.add(session.sessionId); - } } + const changed = new Set<string>(); this.removeArchivedMembership(e.added, changed); this.removeArchivedMembership(e.changed, changed); if (changed.size > 0) { @@ -163,6 +165,10 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe } })); + this._register(this.sessionsManagementService.onDidDeleteSession(session => { + this.removeFromGroup(session.sessionId); + })); + this._register(this.sessionsManagementService.onDidArchiveSession(session => { this.removeFromGroup(session.sessionId); })); diff --git a/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts b/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts index 315152efb48..0c9f07baa86 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts @@ -128,10 +128,13 @@ export class SessionsListModelService extends Disposable implements ISessionsLis this._legacyReadSessionIds = legacyRead.size > 0 ? legacyRead : undefined; this._migratedReadSessionIds = this.loadSet(SessionsListModelService.READ_MIGRATION_DONE_KEY); - this._register(this.sessionsManagementService.onDidChangeSessions(e => { - for (const session of e.removed) { - this.deleteSession(session); - } + // Only a definitive deletion discards pin and sort state. A session + // merely dropping out of the provider's list is an eviction (e.g. an + // agent that cannot answer `listSessions` yet reports no sessions), and + // discarding state there would permanently unpin sessions that come + // back on the next refresh. + this._register(this.sessionsManagementService.onDidDeleteSession(session => { + this.deleteSession(session); })); } diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index d1ed7a48515..6e5c73d1b38 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -25,6 +25,7 @@ import { SessionsRecencyHistory } from './sessionsRecencyHistory.js'; import { VisibleSessions } from './visibleSessions.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ISessionsPartService } from './sessionsPartService.js'; +import { ICustomViewService } from '../../customView/browser/customViewService.js'; import { IsNewChatSessionContext } from '../../../common/contextkeys.js'; import { setActiveSessionContextKeys } from '../common/sessionContextKeys.js'; @@ -308,6 +309,7 @@ export class SessionsService extends Disposable implements ISessionsService { @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @ISessionsPartService private readonly sessionsPartService: ISessionsPartService, + @ICustomViewService private readonly customViewService: ICustomViewService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, ) { @@ -586,6 +588,10 @@ export class SessionsService extends Disposable implements ISessionsService { * Cancel any in-flight open-session/restore and return a fresh cancellation token. */ private _startOpenSession(): CancellationToken { + // Opening a session is the gesture that dismisses a custom view; the + // workbench then restores the sessions grid and its side panel state. + this.customViewService.hideCustomView(); + this._openSessionCts.value?.cancel(); const cts = new CancellationTokenSource(); this._openSessionCts.value = cts; diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 698e06ad787..e08ae5d5af9 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -205,6 +205,23 @@ export interface IGitHubInfo { /** Object ID of the head ref (PR branch) commit. */ readonly headRefOid?: string; }; + /** + * GitHub issues referenced by this session, in the order they were first + * mentioned. Issues may live in a different repository than {@link owner}/{@link repo}. + */ + readonly issues?: readonly IGitHubIssueRef[]; +} + +/** A GitHub issue referenced by a session. */ +export interface IGitHubIssueRef { + /** GitHub repository owner of the issue. */ + readonly owner: string; + /** GitHub repository name of the issue. */ + readonly repo: string; + /** Issue number. */ + readonly number: number; + /** URI of the issue. */ + readonly uri: URI; } export interface ISessionChangesSummary { diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index 914673326d9..862a862d0c6 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -9,6 +9,7 @@ import { IContextKey, IContextKeyService } from '../../../../platform/contextkey import { SessionHasChangesContext, SessionHasPullRequestContext, + SessionHasIssuesContext, SessionHasWorkspaceContext, IsQuickChatSessionContext, SessionIsArchivedContext, @@ -53,6 +54,7 @@ interface ISessionContextKeys { readonly hasGitRepository: IContextKey<boolean>; readonly hasChanges: IContextKey<boolean>; readonly hasPullRequest: IContextKey<boolean>; + readonly hasIssues: IContextKey<boolean>; readonly hasWorkspace: IContextKey<boolean>; readonly isQuickChat: IContextKey<boolean>; readonly isCreated: IContextKey<boolean>; @@ -93,6 +95,7 @@ function getBoundKeys(contextKeyService: IContextKeyService): ISessionContextKey hasGitRepository: SessionHasGitRepositoryContext.bindTo(contextKeyService), hasChanges: SessionHasChangesContext.bindTo(contextKeyService), hasPullRequest: SessionHasPullRequestContext.bindTo(contextKeyService), + hasIssues: SessionHasIssuesContext.bindTo(contextKeyService), hasWorkspace: SessionHasWorkspaceContext.bindTo(contextKeyService), isQuickChat: IsQuickChatSessionContext.bindTo(contextKeyService), isCreated: SessionIsCreatedContext.bindTo(contextKeyService), @@ -154,6 +157,9 @@ export function setSessionContextKeys(session: ISession | undefined, contextKeyS const pullRequest = session?.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.pullRequest; keys.hasPullRequest.set(!!pullRequest); + const issues = session?.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader)?.issues; + keys.hasIssues.set(!!issues?.length); + keys.hasWorkspace.set(!!session?.workspace.read(reader)?.label); // Sourced from the session's `isQuickChat` tag — never inferred from diff --git a/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts index ee7ae1885eb..79dc1928d21 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts @@ -53,6 +53,7 @@ suite('SessionGroupsService', () => { let sessionStartedEmitter: Emitter<ISession>; let sessionArchivedEmitter: Emitter<ISession>; let sessionUnarchivedEmitter: Emitter<ISession>; + let sessionDeletedEmitter: Emitter<ISession>; let sessionReplacedEmitter: Emitter<{ readonly from: ISession; readonly to: ISession }>; let newSessionDiscardedEmitter: Emitter<ISession>; let instantiationService: TestInstantiationService; @@ -76,6 +77,7 @@ suite('SessionGroupsService', () => { sessionStartedEmitter = disposables.add(new Emitter<ISession>()); sessionArchivedEmitter = disposables.add(new Emitter<ISession>()); sessionUnarchivedEmitter = disposables.add(new Emitter<ISession>()); + sessionDeletedEmitter = disposables.add(new Emitter<ISession>()); sessionReplacedEmitter = disposables.add(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); newSessionDiscardedEmitter = disposables.add(new Emitter<ISession>()); sessions = []; @@ -87,6 +89,7 @@ suite('SessionGroupsService', () => { onDidStartSession: sessionStartedEmitter.event, onDidArchiveSession: sessionArchivedEmitter.event, onDidUnarchiveSession: sessionUnarchivedEmitter.event, + onDidDeleteSession: sessionDeletedEmitter.event, onDidReplaceSession: sessionReplacedEmitter.event, onDidDiscardNewSession: newSessionDiscardedEmitter.event, }); @@ -159,10 +162,10 @@ suite('SessionGroupsService', () => { assert.strictEqual(service.getGroupOfSession('s2'), undefined); }); - test('membership is cleaned up when a session is removed', () => { + test('membership is cleaned up when a session is deleted', () => { const a = service.createGroup('A', ['s1', 's2']); const session = createSession('s1'); - sessionsChangedEmitter.fire({ added: [], removed: [session], changed: [] }); + sessionDeletedEmitter.fire(session); assert.deepStrictEqual({ groupName: service.getGroup(a.id)?.name, @@ -175,6 +178,24 @@ suite('SessionGroupsService', () => { }); }); + test('membership survives a session being evicted from the provider list', () => { + const a = service.createGroup('A', ['s1', 's2']); + const session = createSession('s1'); + + // An agent that cannot answer `listSessions` yet reports no sessions, + // so the list evicts them until the next refresh. That must not drop + // the user's grouping. + sessionsChangedEmitter.fire({ added: [], removed: [session], changed: [] }); + + assert.deepStrictEqual({ + membership: service.getGroupOfSession('s1'), + remainingMembers: service.getSessionIdsInGroup(a.id).sort(), + }, { + membership: a.id, + remainingMembers: ['s1', 's2'], + }); + }); + test('archiving the last member leaves an empty group', () => { const a = service.createGroup('A', ['s1']); diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts index 5c541b59d72..11d4ca4360c 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts @@ -48,14 +48,17 @@ suite('SessionsListModelService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); let service: SessionsListModelService; let sessionsChangedEmitter: Emitter<ISessionsChangeEvent>; + let sessionDeletedEmitter: Emitter<ISession>; setup(() => { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); sessionsChangedEmitter = disposables.add(new Emitter<ISessionsChangeEvent>()); + sessionDeletedEmitter = disposables.add(new Emitter<ISession>()); instantiationService.stub(ISessionsManagementService, { ...mock<ISessionsManagementService>(), onDidChangeSessions: sessionsChangedEmitter.event, + onDidDeleteSession: sessionDeletedEmitter.event, }); service = disposables.add(instantiationService.createInstance(SessionsListModelService)); }); @@ -157,14 +160,14 @@ suite('SessionsListModelService', () => { // -- Cleanup -- - test('cleans up state when session is removed', () => { + test('cleans up state when session is deleted', () => { const session = createSession('s1'); service.pinSession(session); const events: ISessionListModelChangeEvent[] = []; disposables.add(service.onDidChange(e => events.push(e))); - sessionsChangedEmitter.fire({ added: [], removed: [session], changed: [] }); + sessionDeletedEmitter.fire(session); assert.strictEqual(service.isSessionPinned(session), false); assert.deepStrictEqual(events, [ @@ -172,23 +175,38 @@ suite('SessionsListModelService', () => { ]); }); - test('removal does not fire when session has no state', () => { + test('pin survives a session being evicted from the provider list', () => { + const session = createSession('s1'); + service.pinSession(session); + + let changeCount = 0; + disposables.add(service.onDidChange(() => changeCount++)); + + // An agent that cannot answer `listSessions` yet reports no sessions, + // so the list evicts them until the next refresh. That must not unpin. + sessionsChangedEmitter.fire({ added: [], removed: [session], changed: [] }); + + assert.strictEqual(service.isSessionPinned(session), true); + assert.strictEqual(changeCount, 0); + }); + + test('deletion does not fire when session has no state', () => { const session = createSession('s1'); let changeCount = 0; disposables.add(service.onDidChange(() => changeCount++)); - sessionsChangedEmitter.fire({ added: [], removed: [session], changed: [] }); + sessionDeletedEmitter.fire(session); assert.strictEqual(changeCount, 0); }); - test('removal does not affect other sessions', () => { + test('deletion does not affect other sessions', () => { const s1 = createSession('s1'); const s2 = createSession('s2'); service.pinSession(s1); service.pinSession(s2); - sessionsChangedEmitter.fire({ added: [], removed: [s1], changed: [] }); + sessionDeletedEmitter.fire(s1); assert.strictEqual(service.isSessionPinned(s1), false); assert.strictEqual(service.isSessionPinned(s2), true); @@ -204,7 +222,7 @@ suite('SessionsListModelService', () => { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IStorageService, storageService); - instantiationService.stub(ISessionsManagementService, { ...mock<ISessionsManagementService>(), onDidChangeSessions: disposables.add(new Emitter<ISessionsChangeEvent>()).event }); + instantiationService.stub(ISessionsManagementService, { ...mock<ISessionsManagementService>(), onDidDeleteSession: disposables.add(new Emitter<ISession>()).event }); const loadedService = disposables.add(instantiationService.createInstance(SessionsListModelService)); assert.strictEqual(loadedService.isSessionPinned(createSession('s1')), true); @@ -217,7 +235,7 @@ suite('SessionsListModelService', () => { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IStorageService, storageService); - instantiationService.stub(ISessionsManagementService, { ...mock<ISessionsManagementService>(), onDidChangeSessions: disposables.add(new Emitter<ISessionsChangeEvent>()).event }); + instantiationService.stub(ISessionsManagementService, { ...mock<ISessionsManagementService>(), onDidDeleteSession: disposables.add(new Emitter<ISession>()).event }); const loadedService = disposables.add(instantiationService.createInstance(SessionsListModelService)); // Should not throw and should return empty state @@ -244,7 +262,7 @@ suite('SessionsListModelService', () => { instantiationService.stub(IStorageService, storage); instantiationService.stub(ISessionsManagementService, { ...mock<ISessionsManagementService>(), - onDidChangeSessions: disposables.add(new Emitter<ISessionsChangeEvent>()).event, + onDidDeleteSession: disposables.add(new Emitter<ISession>()).event, markRead: async (session: ISession) => { readMarks.push(session.sessionId); }, markUnread: async (session: ISession) => { unreadMarks.push(session.sessionId); }, }); @@ -299,7 +317,7 @@ suite('SessionsListModelService', () => { instantiationService.stub(IStorageService, storage); instantiationService.stub(ISessionsManagementService, { ...mock<ISessionsManagementService>(), - onDidChangeSessions: disposables.add(new Emitter<ISessionsChangeEvent>()).event, + onDidDeleteSession: disposables.add(new Emitter<ISession>()).event, markRead: async (session: ISession) => { readMarks.push(session.sessionId); }, markUnread: async (session: ISession) => { unreadMarks.push(session.sessionId); }, }); diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 53d461e6667..0c3021862c7 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -34,6 +34,7 @@ import { SessionsManagementService } from '../../browser/sessionsManagementServi import { ISessionsManagementService, ICreateNewSessionOptions, inheritableSessionTarget, WorkspaceNotTrustedError } from '../../common/sessionsManagement.js'; import { SessionsService } from '../../browser/sessionsService.js'; import { ISessionsPartService } from '../../browser/sessionsPartService.js'; +import { CustomViewService, ICustomViewService } from '../../../customView/browser/customViewService.js'; import { ISessionsProvidersService } from '../../browser/sessionsProvidersService.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; @@ -233,6 +234,7 @@ class TestSessionsPartService extends mock<ISessionsPartService>() { function createView(instantiationService: TestInstantiationService, service: ISessionsManagementService, disposables: ReturnType<typeof ensureNoDisposablesAreLeakedInTestSuite>): SessionsService { instantiationService.stub(ISessionsManagementService, service); instantiationService.stub(ISessionsPartService, new TestSessionsPartService()); + instantiationService.stub(ICustomViewService, disposables.add(new CustomViewService(new NullLogService()))); return disposables.add(instantiationService.createInstance(SessionsService)); } diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index 8971c2b89cf..ef7b1fe2e92 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -454,7 +454,9 @@ import '../workbench/contrib/opener/browser/opener.contribution.js'; import './browser/paneCompositePartService.js'; import './browser/parts/editorParts.js'; import './browser/parts/sessionsParts.js'; +import './browser/parts/customViewGridParts.js'; import './services/sessions/browser/sessionsService.js'; +import './services/customView/browser/customViewService.js'; import './browser/parts/menubar.contribution.js'; import './browser/layoutActions.js'; @@ -490,6 +492,7 @@ import './contrib/workspace/browser/workspace.contribution.js'; import './contrib/aquarium/browser/aquarium.contribution.js'; import './contrib/policyBlocked/browser/policyBlocked.contribution.js'; import './contrib/automations/browser/automations.contribution.js'; +import './contrib/customViewTest/browser/customViewTest.contribution.js'; // Onboarding: the engine + spotlight presentation (from the workbench layer) and // the Agents window scenario data. diff --git a/src/vs/sessions/test/browser/sessionView.test.ts b/src/vs/sessions/test/browser/sessionView.test.ts new file mode 100644 index 00000000000..878ba1d8e27 --- /dev/null +++ b/src/vs/sessions/test/browser/sessionView.test.ts @@ -0,0 +1,34 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { SessionView } from '../../browser/parts/sessionView.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; + +suite('Sessions - Session View', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('forwards effective visibility (part and grid leaf) to the hosted chat view', () => { + const forwarded: boolean[] = []; + // Created from the prototype so the internal visibility helpers are present. + const view: SessionView = Object.assign(Object.create(SessionView.prototype), { + _isPartVisible: true, + _isLeafVisible: true, + _lastLayout: undefined, + _currentView: { value: { setVisible: (visible: boolean) => forwarded.push(visible) } }, + }); + + // A sibling session is maximized, hiding this leaf. + view.setVisible(false); + // The whole sessions part is hidden while the leaf is still hidden. + view.setPartVisible(false); + // Leaving the maximized state must not reveal the chat while the part is hidden. + view.setVisible(true); + // Showing the part again reveals the chat. + view.setPartVisible(true); + + assert.deepStrictEqual(forwarded, [false, true]); + }); +}); diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index 85b22829381..11a9dcc15e0 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -52,11 +52,17 @@ suite('Sessions - Workbench', () => { const isSinglePaneEditorPaneVisible = SinglePaneWorkbench.prototype.isEditorPaneVisible as (this: ITestWorkbench) => boolean; const toggleSecondarySideBarSinglePane = SinglePaneWorkbench.prototype.toggleSecondarySideBar as (this: ITestWorkbench) => void; const isSecondarySideBarVisibleSinglePane = SinglePaneWorkbench.prototype.isSecondarySideBarVisible as (this: ITestWorkbench) => boolean; + const applyCustomViewGridVisibility = Reflect.get(Workbench.prototype, '_applyCustomViewGridVisibility') as (this: ITestWorkbench, descriptor: object | undefined) => void; + const setSessionsHidden = Reflect.get(Workbench.prototype, 'setSessionsHidden') as (this: ITestWorkbench, hidden: boolean) => void; + const setPanelHidden = Reflect.get(Workbench.prototype, 'setPanelHidden') as (this: ITestWorkbench, hidden: boolean) => void; + const updateMobileCustomViewNavigation = Reflect.get(Workbench.prototype, '_updateMobileCustomViewNavigation') as (this: ITestWorkbench) => void; + const isVisible = Workbench.prototype.isVisible as (this: ITestWorkbench, part: Parts) => boolean; + const toggleSecondarySideBar = Workbench.prototype.toggleSecondarySideBar as (this: ITestWorkbench) => void; // --- Harness ------------------------------------------------------------ interface ITestWorkbench { - partVisibility: { sidebar: boolean; auxiliaryBar: boolean; editor: boolean; panel: boolean; sessions: boolean }; + partVisibility: { sidebar: boolean; auxiliaryBar: boolean; editor: boolean; panel: boolean; sessions: boolean; customViewGrid: boolean }; auxiliaryBarPartView: object; _savedPartSizes: { sidebar?: number; auxiliaryBar?: number; editor?: number; sessions?: number; panel?: number }; _editorMaximized: boolean; @@ -74,6 +80,15 @@ suite('Sessions - Workbench', () => { readonly counts: { save: number; layout: number }; readonly sidePaneReveals: boolean[]; readonly focusedParts: Parts[]; + readonly renderedCustomViews: (object | undefined)[]; + readonly gridVisibility: Map<object, boolean>; + readonly mobileNavLayers: string[]; + readonly focusedSessions: number; + layoutPolicy: { viewportClass: { get(): string } }; + sessionsPartView: object; + panelPartView: object; + customViewGridPartView: object; + editorPartView: object; setEditorHidden(hidden: boolean, explicit?: boolean): void; setAuxiliaryBarHidden(hidden: boolean): void; } @@ -114,6 +129,8 @@ suite('Sessions - Workbench', () => { sideBarWidth?: number; dockedWidth?: number; hasAppliedInitialEditorSplit?: boolean; + /** Use the real `setEditorMaximized` instead of the no-op stub. */ + editorMaximize?: boolean; suppressionCount?: number; focusedPart?: Parts; editorGroupService?: { mainPart: { groups: readonly { isEmpty: boolean }[] } }; @@ -129,6 +146,8 @@ suite('Sessions - Workbench', () => { const sessionsPartView = {}; const sideBarPartView = {}; const auxiliaryBarPartView = {}; + const panelPartView = {}; + const customViewGridPartView = {}; const resizes: IViewSize[] = []; const visibilityChanges: boolean[] = []; const events: IPartVisibilityChangeEvent[] = []; @@ -136,6 +155,11 @@ suite('Sessions - Workbench', () => { const counts = { save: 0, layout: 0 }; const sidePaneReveals: boolean[] = []; const focusedParts: Parts[] = []; + const renderedCustomViews: (object | undefined)[] = []; + const gridVisibility = new Map<object, boolean>(); + const mobileNavLayers: string[] = []; + let focusedSessions = 0; + const notifyPartVisibility = (view: object, visible: boolean) => notifyPartVisibilityOn(host as unknown as ITestWorkbench, view, visible); let editorNodeVisible = (options.partVisibility?.editor ?? false) || (options.partVisibility?.auxiliaryBar ?? true); const viewSizes = new Map<object, IViewSize>([ [editorPartView, { width: options.editorWidth ?? 0, height: 800 }], @@ -144,12 +168,14 @@ suite('Sessions - Workbench', () => { [auxiliaryBarPartView, { width: 300, height: 800 }], ]); - const partVisibility = { sidebar: true, auxiliaryBar: true, editor: false, panel: false, sessions: true, ...options.partVisibility }; + const partVisibility = { sidebar: true, auxiliaryBar: true, editor: false, panel: false, sessions: true, customViewGrid: false, ...options.partVisibility }; const host = { editorPartView, sessionsPartView, sideBarPartView, auxiliaryBarPartView, + panelPartView, + customViewGridPartView, _editorPartContainer: undefined, mainContainer: { classList: { toggle: (name: string, force: boolean) => { classToggles.push({ name, force }); } } }, partVisibility, @@ -158,11 +184,15 @@ suite('Sessions - Workbench', () => { layout: () => { }, getViewSize: (view: object) => viewSizes.get(view) ?? { width: 0, height: 0 }, isViewVisible: (view: object) => view === editorPartView ? editorNodeVisible : true, + hasMaximizedView: () => false, + exitMaximizedView: () => { }, setViewVisible: (view: object, visible: boolean) => { if (view === editorPartView) { editorNodeVisible = visible; } + gridVisibility.set(view, visible); visibilityChanges.push(visible); + notifyPartVisibility(view, visible); }, resizeView: (view: object, size: IViewSize) => { resizes.push(size); viewSizes.set(view, size); }, }, @@ -191,12 +221,23 @@ suite('Sessions - Workbench', () => { _savePartVisibility: () => { counts.save++; }, _fireDidChangePartVisibility: (partId: Parts, visible: boolean, source?: 'resize') => { events.push({ partId, visible, ...(source ? { source } : {}) }); }, _onDidRevealSidePane: { fire: () => { sidePaneReveals.push(true); } }, + _onDidChangeEditorMaximized: { fire: () => { } }, _notifyContainerDidLayout: () => { }, _layoutDockedAuxBar: () => { counts.layout++; }, layoutMobileSidebar: () => { }, - setEditorMaximized: () => { }, + ...(options.editorMaximize ? {} : { setEditorMaximized: () => { } }), hasFocus: (part: Parts) => options.focusedPart === part, focusPart: (part: Parts) => { focusedParts.push(part); }, + layout: () => { }, + mobileNavStack: { + has: (layer: string) => mobileNavLayers.includes(layer), + push: (layer: string) => { mobileNavLayers.push(layer); }, + popSilently: (layer: string) => { mobileNavLayers.splice(mobileNavLayers.indexOf(layer), 1); }, + }, + customViewGridPartService: { setView: (descriptor: object | undefined) => { renderedCustomViews.push(descriptor); }, focusActiveView: () => { } }, + _customViewVisibleKey: { set: () => { } }, + sessionsPartService: { focusSession: () => { focusedSessions++; } }, + sessionsService: { activeSession: { get: () => undefined } }, // captures resizes, visibilityChanges, @@ -205,12 +246,32 @@ suite('Sessions - Workbench', () => { counts, sidePaneReveals, focusedParts, + renderedCustomViews, + gridVisibility, + mobileNavLayers, + get focusedSessions() { return focusedSessions; }, }; Object.setPrototypeOf(host, options.single ? SinglePaneWorkbench.prototype : Workbench.prototype); return host as unknown as ITestWorkbench; } + // The real SplitView calls `Part.setVisible` when a view's grid visibility + // changes, which the workbench maps back onto the desired part visibility. + // Reproduce that feedback so tests catch state being overwritten by it. + function notifyPartVisibilityOn(host: ITestWorkbench, view: object, visible: boolean): void { + if ((host as unknown as { _applyingCustomViewGridVisibility: boolean })._applyingCustomViewGridVisibility) { + return; + } + if (view === host.sessionsPartView) { + setSessionsHidden.call(host, !visible); + } else if (view === host.panelPartView) { + setPanelHidden.call(host, !visible); + } else if (view === host.auxiliaryBarPartView) { + host.setAuxiliaryBarHidden(!visible); + } + } + // --- Editor split / reveal --------------------------------------------- test('tracks editor pane visibility across editor and auxiliary bar changes', () => { @@ -1485,6 +1546,7 @@ suite('Sessions - Workbench', () => { editor: false, panel: false, sessions: true, + customViewGrid: false, }, suppression: 0, }); @@ -1745,6 +1807,165 @@ suite('Sessions - Workbench', () => { }); }); + // --- Custom view grid --------------------------------------------------- + + test('showing a custom view hides the sessions grid, editor, side panel and panel', () => { + const host = createHost({ partVisibility: { editor: true, auxiliaryBar: true, panel: true, sessions: true } }); + const descriptor = {}; + + applyCustomViewGridVisibility.call(host, descriptor); + + assert.deepStrictEqual({ + renderedCustomViews: host.renderedCustomViews, + customViewGridVisible: isVisible.call(host, Parts.CUSTOM_VIEW_GRID_PART), + sessions: isVisible.call(host, Parts.SESSIONS_PART), + editor: isVisible.call(host, Parts.EDITOR_PART), + auxiliaryBar: isVisible.call(host, Parts.AUXILIARYBAR_PART), + panel: isVisible.call(host, Parts.PANEL_PART), + sideBar: isVisible.call(host, Parts.SIDEBAR_PART), + gridNodes: { + customViewGrid: host.gridVisibility.get(host.customViewGridPartView), + sessions: host.gridVisibility.get(host.sessionsPartView), + editor: host.gridVisibility.get(host.editorPartView), + panel: host.gridVisibility.get(host.panelPartView), + }, + events: host.events, + focusedParts: host.focusedParts, + }, { + renderedCustomViews: [descriptor], + customViewGridVisible: true, + sessions: false, + editor: false, + auxiliaryBar: false, + panel: false, + sideBar: true, + gridNodes: { + customViewGrid: true, + sessions: false, + editor: false, + panel: false, + }, + events: [ + { partId: Parts.CUSTOM_VIEW_GRID_PART, visible: true }, + { partId: Parts.SESSIONS_PART, visible: false }, + { partId: Parts.EDITOR_PART, visible: false }, + { partId: Parts.AUXILIARYBAR_PART, visible: false }, + { partId: Parts.PANEL_PART, visible: false }, + ], + focusedParts: [Parts.CUSTOM_VIEW_GRID_PART], + }); + }); + + test('hiding the custom view restores the desired part visibility, including changes made while it was shown', () => { + const host = createHost({ partVisibility: { editor: true, auxiliaryBar: true, panel: false, sessions: true } }); + + applyCustomViewGridVisibility.call(host, {}); + + // The layout controller reacts to a session switch while the custom view is + // up: the desired state changes but nothing is rendered. + setEditorHidden.call(host, true); + const whileShown = { + editor: isVisible.call(host, Parts.EDITOR_PART), + editorNode: host.gridVisibility.get(host.editorPartView), + }; + + applyCustomViewGridVisibility.call(host, undefined); + + assert.deepStrictEqual({ + whileShown, + customViewGridVisible: isVisible.call(host, Parts.CUSTOM_VIEW_GRID_PART), + renderedCustomViewCount: host.renderedCustomViews.length, + lastRenderedCustomView: host.renderedCustomViews[host.renderedCustomViews.length - 1], + sessions: isVisible.call(host, Parts.SESSIONS_PART), + editor: isVisible.call(host, Parts.EDITOR_PART), + auxiliaryBar: isVisible.call(host, Parts.AUXILIARYBAR_PART), + panel: isVisible.call(host, Parts.PANEL_PART), + focusedSessions: host.focusedSessions, + }, { + whileShown: { editor: false, editorNode: false }, + customViewGridVisible: false, + renderedCustomViewCount: 2, + lastRenderedCustomView: undefined, + sessions: true, + editor: false, + auxiliaryBar: true, + panel: false, + focusedSessions: 1, + }); + }); + + test('swapping to another custom view re-renders it without touching the layout', () => { + const host = createHost({ partVisibility: { editor: true, auxiliaryBar: true, sessions: true } }); + const first = {}; + const second = {}; + + applyCustomViewGridVisibility.call(host, first); + const eventsAfterShow = host.events.length; + applyCustomViewGridVisibility.call(host, second); + + assert.deepStrictEqual({ + renderedCustomViews: host.renderedCustomViews, + customViewGridVisible: isVisible.call(host, Parts.CUSTOM_VIEW_GRID_PART), + sessions: isVisible.call(host, Parts.SESSIONS_PART), + eventsAfterSwap: host.events.length - eventsAfterShow, + }, { + renderedCustomViews: [first, second], + customViewGridVisible: true, + sessions: false, + eventsAfterSwap: 0, + }); + }); + + test('tracks the custom view in the phone navigation stack and drops it when leaving phone layout', () => { + const host = createHost(); + host.layoutPolicy.viewportClass.get = () => 'phone'; + + applyCustomViewGridVisibility.call(host, {}); + const onPhone = [...host.mobileNavLayers]; + + // Rotating back to a desktop-class viewport must not leave a stale entry behind. + host.layoutPolicy.viewportClass.get = () => 'desktop'; + updateMobileCustomViewNavigation.call(host); + + assert.deepStrictEqual({ onPhone, afterLeavingPhone: host.mobileNavLayers }, { + onPhone: ['customView'], + afterLeavingPhone: [], + }); + }); + + test('the secondary side bar toggle is inert while a custom view is shown', () => { + const host = createHost({ partVisibility: { auxiliaryBar: true } }); + + applyCustomViewGridVisibility.call(host, {}); + toggleSecondarySideBar.call(host); + + assert.strictEqual(host.partVisibility.auxiliaryBar, true); + }); + + test('showing a custom view un-maximizes the editor so the sessions grid owns the row again on hide', () => { + const host = createHost({ editorMaximize: true, partVisibility: { editor: true, auxiliaryBar: true, sessions: true } }); + setEditorMaximized.call(host as unknown as IMaximizeTestHarness, true); + + applyCustomViewGridVisibility.call(host, {}); + const whileShown = { + editorMaximized: host._editorMaximized, + sessions: isVisible.call(host, Parts.SESSIONS_PART), + customViewGrid: isVisible.call(host, Parts.CUSTOM_VIEW_GRID_PART), + }; + + applyCustomViewGridVisibility.call(host, undefined); + + assert.deepStrictEqual({ + whileShown, + sessions: isVisible.call(host, Parts.SESSIONS_PART), + customViewGrid: isVisible.call(host, Parts.CUSTOM_VIEW_GRID_PART), + }, { + whileShown: { editorMaximized: false, sessions: false, customViewGrid: true }, + sessions: true, + customViewGrid: false, + }); + }); + // --- Persistence gating ------------------------------------------------- test('does not restore saved desktop part visibility on phone layout', () => { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index bf42962ec0b..93b1b4f31a2 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -2218,6 +2218,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatResponseProgressPart2: extHostTypes.ChatResponseProgressPart2, ChatResponseThinkingProgressPart: extHostTypes.ChatResponseThinkingProgressPart, ChatResponseHookPart: extHostTypes.ChatResponseHookPart, + ChatResponseVoiceProgressPart: extHostTypes.ChatResponseVoiceProgressPart, ChatResponseAutoModeResolutionPart: extHostTypes.ChatResponseAutoModeResolutionPart, ChatResponseReferencePart: extHostTypes.ChatResponseReferencePart, ChatResponseReferencePart2: extHostTypes.ChatResponseReferencePart, diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index eba48601a1d..6b03bf70318 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -216,6 +216,13 @@ export class ChatAgentResponseStream { _report(dto); return this; }, + voiceProgress(id: vscode.ChatResponseVoiceProgressStage, value: string) { + throwIfDone(this.voiceProgress); + checkProposedApiEnabled(that._extension, 'chatParticipantPrivate'); + const part = new extHostTypes.ChatResponseVoiceProgressPart(id, value); + _report(typeConvert.ChatResponseVoiceProgressPart.from(part)); + return this; + }, warning(value) { throwIfDone(this.progress); checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index f9382c2e943..db5b0006326 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -344,7 +344,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { knownModel.info, messages.value.map(typeConvert.LanguageModelChatMessage2.to), // todo@connor4312: move `core` -> `undefined` after 1.111 Insiders is out - { ...options, modelOptions: options.modelOptions ?? {}, modelConfiguration: options.configuration, requestInitiator: from ? ExtensionIdentifier.toKey(from) : 'core', toolMode: options.toolMode ?? extHostTypes.LanguageModelChatToolMode.Auto }, + { ...options, modelOptions: options.modelOptions ?? {}, modelConfiguration: options.configuration, requestInitiator: from ? ExtensionIdentifier.toKey(from) : 'core', toolMode: options.toolMode ?? extHostTypes.LanguageModelChatToolMode.Auto, includeEncryptedThinking: options.includeEncryptedThinking }, progress, providerToken ); diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 48f49b4d52e..0ed06f435e5 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -43,7 +43,7 @@ import { DEFAULT_EDITOR_ASSOCIATION, SaveReason } from '../../common/editor.js'; import { IViewBadge } from '../../common/views.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { IChatRequestModeInstructions } from '../../contrib/chat/common/model/chatModel.js'; -import { IChatAgentMarkdownContentWithVulnerability, IChatAutoModeResolutionPart, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatExternalToolInvocationUpdate, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatInfoMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; +import { IChatAgentMarkdownContentWithVulnerability, IChatAutoModeResolutionPart, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatExternalToolInvocationUpdate, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatVoiceProgressPart, IChatWarningMessage, IChatInfoMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { coerceImageBuffer } from '../../contrib/chat/common/chatImageExtraction.js'; @@ -2855,6 +2855,16 @@ export namespace ChatResponseHookPart { } } +export namespace ChatResponseVoiceProgressPart { + export function from(part: vscode.ChatResponseVoiceProgressPart): Dto<IChatVoiceProgressPart> { + return { + kind: 'voiceProgress', + id: part.id, + value: part.value, + }; + } +} + export namespace ChatResponseAutoModeResolutionPart { const validLabels = new Set<IChatAutoModeResolutionPart['predictedLabel']>(['needs_reasoning', 'no_reasoning', 'fallback']); @@ -3386,6 +3396,8 @@ export namespace ChatResponsePart { return ChatResponseThinkingProgressPart.from(part); } else if (part instanceof types.ChatResponseHookPart) { return ChatResponseHookPart.from(part); + } else if (part instanceof types.ChatResponseVoiceProgressPart) { + return ChatResponseVoiceProgressPart.from(part); } else if (part instanceof types.ChatResponseFileTreePart) { return ChatResponseFilesPart.from(part); } else if (part instanceof types.ChatResponseMultiDiffPart) { @@ -3479,6 +3491,7 @@ export namespace ChatAgentRequest { attempt: request.attempt ?? 0, enableCommandDetection: request.enableCommandDetection ?? true, isParticipantDetected: request.isParticipantDetected ?? false, + isVoiceModeInput: request.isVoiceModeInput, sessionId, sessionResource: request.sessionResource, references: variableReferences @@ -3514,6 +3527,8 @@ export namespace ChatAgentRequest { // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).isParticipantDetected; // eslint-disable-next-line local/code-no-any-casts + delete (requestWithAllProps as any).isVoiceModeInput; + // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).location; // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).location2; diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 38cd199b5fc..1bb1c5a5f10 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3271,6 +3271,17 @@ export class ChatResponseHookPart { } } +export type ChatResponseVoiceProgressStage = 'investigating' | 'planning' | 'editing' | 'validating' | 'recovering'; + +export class ChatResponseVoiceProgressPart { + readonly id: ChatResponseVoiceProgressStage; + readonly value: string; + constructor(id: ChatResponseVoiceProgressStage, value: string) { + this.id = id; + this.value = value; + } +} + export class ChatResponseAutoModeResolutionPart { resolvedModel: string; resolvedModelName: string; diff --git a/src/vs/workbench/api/test/common/extHostTypeConverters.test.ts b/src/vs/workbench/api/test/common/extHostTypeConverters.test.ts index 4c9c4f70cbc..51ed4ede6a3 100644 --- a/src/vs/workbench/api/test/common/extHostTypeConverters.test.ts +++ b/src/vs/workbench/api/test/common/extHostTypeConverters.test.ts @@ -8,8 +8,8 @@ import { URI, UriComponents } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../platform/log/common/log.js'; import { IconPathDto } from '../../common/extHost.protocol.js'; -import { ChatPromptReference, ChatRequestModeInstructions, ChatToolInvocationPart, IconPath } from '../../common/extHostTypeConverters.js'; -import { ChatReferenceBinaryData, ChatSubagentToolInvocationData, ChatToolInvocationPart as ExtHostChatToolInvocationPart, ThemeColor, ThemeIcon } from '../../common/extHostTypes.js'; +import { ChatPromptReference, ChatRequestModeInstructions, ChatResponseVoiceProgressPart, ChatToolInvocationPart, IconPath } from '../../common/extHostTypeConverters.js'; +import { ChatReferenceBinaryData, ChatResponseVoiceProgressPart as ExtHostChatResponseVoiceProgressPart, ChatSubagentToolInvocationData, ChatToolInvocationPart as ExtHostChatToolInvocationPart, ThemeColor, ThemeIcon } from '../../common/extHostTypes.js'; import { IElementVariableEntry } from '../../../contrib/chat/common/attachments/chatVariableEntries.js'; import { IChatRequestModeInstructions } from '../../../contrib/chat/common/model/chatModel.js'; import { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; @@ -17,6 +17,13 @@ import { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; suite('extHostTypeConverters', function () { ensureNoDisposablesAreLeakedInTestSuite(); + test('converts voice progress to hidden chat progress', () => { + assert.deepStrictEqual( + ChatResponseVoiceProgressPart.from(new ExtHostChatResponseVoiceProgressPart('investigating', 'Investigating the relevant code.')), + { kind: 'voiceProgress', id: 'investigating', value: 'Investigating the relevant code.' } + ); + }); + suite('IconPath', function () { suite('from', function () { test('undefined', function () { diff --git a/src/vs/workbench/browser/parts/editor/breadcrumbs.ts b/src/vs/workbench/browser/parts/editor/breadcrumbs.ts index 7ab9a9f58b2..869126b5085 100644 --- a/src/vs/workbench/browser/parts/editor/breadcrumbs.ts +++ b/src/vs/workbench/browser/parts/editor/breadcrumbs.ts @@ -127,7 +127,7 @@ Registry.as<IConfigurationRegistry>(Extensions.Configuration).registerConfigurat description: localize('enabled', "Enable/disable navigation breadcrumbs."), type: 'boolean', default: true, - agentsWindow: { default: false }, + agentsWindow: { default: true }, }, 'breadcrumbs.filePath': { description: localize('filepath', "Controls whether and how file paths are shown in the breadcrumbs view."), @@ -172,6 +172,7 @@ Registry.as<IConfigurationRegistry>(Extensions.Configuration).registerConfigurat markdownDescription: localize('showEditorType', "Controls whether the breadcrumbs bar shows a dropdown to switch between the editors that can open the current file (for example the text editor and a custom editor). The dropdown only appears when a more specialized editor is available."), type: 'boolean', default: false, + agentsWindow: { default: true }, tags: ['experimental'] }, 'breadcrumbs.symbolPathSeparator': { diff --git a/src/vs/workbench/browser/parts/editor/editorConfiguration.ts b/src/vs/workbench/browser/parts/editor/editorConfiguration.ts index b2739bc4821..3a95c7ae65e 100644 --- a/src/vs/workbench/browser/parts/editor/editorConfiguration.ts +++ b/src/vs/workbench/browser/parts/editor/editorConfiguration.ts @@ -9,7 +9,7 @@ import { IWorkbenchContribution } from '../../../common/contributions.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions, IConfigurationNode, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; -import { diffEditorsAssociationsSettingId, editorsAssociationsAgentsWindowDefault, editorsAssociationsSettingId, IEditorResolverService, markdownDefaultEditorAgentsWindowSettingId, RegisteredEditorInfo, RegisteredEditorPriority, toRegisteredEditorPriorityInfo } from '../../../services/editor/common/editorResolverService.js'; +import { diffEditorsAssociationsAgentsWindowDefault, diffEditorsAssociationsSettingId, editorsAssociationsAgentsWindowDefault, editorsAssociationsSettingId, IEditorResolverService, markdownDefaultEditorAgentsWindowSettingId, RegisteredEditorInfo, RegisteredEditorPriority, toRegisteredEditorPriorityInfo } from '../../../services/editor/common/editorResolverService.js'; import { IJSONSchemaMap } from '../../../../base/common/jsonSchema.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { coalesce } from '../../../../base/common/arrays.js'; @@ -192,6 +192,9 @@ export class DynamicEditorConfigurations extends Disposable implements IWorkbenc type: 'string', enum: binaryEditorCandidates, } + }, + agentsWindow: { + default: diffEditorsAssociationsAgentsWindowDefault({ markdownDefaultEditor: markdownDefaultEditorEnabled }) } } } diff --git a/src/vs/workbench/browser/parts/editor/editorGroupView.ts b/src/vs/workbench/browser/parts/editor/editorGroupView.ts index 3ba2b186260..8b43f64a4a1 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupView.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupView.ts @@ -1021,6 +1021,11 @@ export class EditorGroupView extends Themable implements IEditorGroupView { return this.model.stickyCount; } + /** The container that bounds the editor pane, excluding any docked content inset. */ + get editorPaneContainer(): HTMLElement { + return this.editorContainer; + } + get activeEditorPane(): IVisibleEditorPane | undefined { return this.editorPane ? this.editorPane.activeEditorPane ?? undefined : undefined; } @@ -2228,13 +2233,11 @@ export class EditorGroupView extends Themable implements IEditorGroupView { this.lastLayout = { width, height, top, left }; this.element.classList.toggle('max-height-478px', height <= 478); - // Layout the title control first to receive the size it occupies. The - // title always spans the full group width (so the tab strip and its - // toolbar can extend across any docked right inset). + // Keep tabs full-width while breadcrumbs follow the editor content inset. const titleControlSize = this.titleControl.layout({ container: new Dimension(width, height), available: new Dimension(width, height - this.editorPane.minimumHeight) - }); + }, this._contentRightInset); // Update progress bar location this.progressBar.getContainer().style.top = `${Math.max(this.titleHeight.offset - 2, 0)}px`; @@ -2255,9 +2258,8 @@ export class EditorGroupView extends Themable implements IEditorGroupView { } /** - * Sets the right inset (px) reserved beside the editor pane while the title - * keeps the full group width, then relayouts. `0` restores the default - * full-width content. + * Sets the right inset reserved beside the breadcrumbs and editor pane while tabs remain full-width. + * `0` restores the default full-width content. */ setContentRightInset(inset: number): void { const next = Math.max(0, Math.round(inset)); diff --git a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts index 1072ac32cb9..43cc2e5a0e4 100644 --- a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts @@ -104,7 +104,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC compact: 22 as const, // Style-override (Modern UI) multi-tab mode adds 4px top + 4px bottom padding to // the tabs-and-actions-container (tabs.css), so the total title-bar height is the - // --editor-group-tab-height CSS value (24px / 14px) plus that 8px padding. + // --editor-group-tab-height CSS value (24px / 20px) plus that 8px padding. styleOverride: 32 as const, // 24px tab + 4px top + 4px bottom padding styleOverrideCompact: 28 as const, // 20px tab + 4px top + 4px bottom padding (20px = minimum to fit 16px icon + 2px padding) }; diff --git a/src/vs/workbench/browser/parts/editor/editorTitleControl.ts b/src/vs/workbench/browser/parts/editor/editorTitleControl.ts index 1fb1c7bd326..23b0abf0320 100644 --- a/src/vs/workbench/browser/parts/editor/editorTitleControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTitleControl.ts @@ -39,6 +39,7 @@ export class EditorTitleControl extends Themable { private readonly editorTabsControlDisposable = this._register(new DisposableStore()); private breadcrumbsControlFactory: BreadcrumbsControlFactory | undefined; + private breadcrumbsContainer: HTMLElement | undefined; private readonly breadcrumbsControlDisposables = this._register(new DisposableStore()); private get breadcrumbsControl() { return this.breadcrumbsControlFactory?.control; } @@ -79,11 +80,12 @@ export class EditorTitleControl extends Themable { private createBreadcrumbsControl(): BreadcrumbsControlFactory | undefined { if (this.groupsView.partOptions.showTabs === 'single') { + this.breadcrumbsContainer = undefined; return undefined; // Single tabs have breadcrumbs inlined. No tabs have no breadcrumbs. } // Breadcrumbs container - const breadcrumbsContainer = $('.breadcrumbs-below-tabs'); + const breadcrumbsContainer = this.breadcrumbsContainer = $('.breadcrumbs-below-tabs'); this.parent.appendChild(breadcrumbsContainer); const breadcrumbsControlFactory = this.breadcrumbsControlDisposables.add(this.instantiationService.createInstance(BreadcrumbsControlFactory, breadcrumbsContainer, this.groupView, { @@ -200,7 +202,7 @@ export class EditorTitleControl extends Themable { } } - layout(dimensions: IEditorTitleControlDimensions): Dimension { + layout(dimensions: IEditorTitleControlDimensions, breadcrumbsRightInset = 0): Dimension { // Layout tabs control const tabsControlDimension = this.editorTabsControl.layout(dimensions); @@ -208,7 +210,9 @@ export class EditorTitleControl extends Themable { // Layout breadcrumbs if visible let breadcrumbsControlDimension: Dimension | undefined = undefined; if (this.breadcrumbsControl?.isHidden() === false) { - breadcrumbsControlDimension = new Dimension(dimensions.container.width, BreadcrumbsControl.HEIGHT); + const breadcrumbsWidth = Math.max(0, dimensions.container.width - breadcrumbsRightInset); + this.breadcrumbsContainer!.style.width = `${breadcrumbsWidth}px`; + breadcrumbsControlDimension = new Dimension(breadcrumbsWidth, BreadcrumbsControl.HEIGHT); this.breadcrumbsControl.layout(breadcrumbsControlDimension); } diff --git a/src/vs/workbench/browser/parts/editor/editorTypePicker.ts b/src/vs/workbench/browser/parts/editor/editorTypePicker.ts index 787a84fe80d..5c7be67457e 100644 --- a/src/vs/workbench/browser/parts/editor/editorTypePicker.ts +++ b/src/vs/workbench/browser/parts/editor/editorTypePicker.ts @@ -8,7 +8,7 @@ import { extUri } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { DEFAULT_EDITOR_ASSOCIATION, EditorResourceAccessor, SideBySideEditor, isDiffEditorInput } from '../../../common/editor.js'; +import { DEFAULT_EDITOR_ASSOCIATION, EditorResourceAccessor, SideBySideEditor, isDiffEditorInput, isEditorInputWithDiffResources } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { IEditorResolverService, RegisteredEditorInfo, RegisteredEditorPriority, priorityToRank } from '../../../services/editor/common/editorResolverService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; @@ -33,7 +33,12 @@ export interface IAvailableEditorTypes { * exclusive editor (e.g. the hex editor, for which `getEditors` returns an empty list). */ export function getAvailableEditorTypes(activeEditor: EditorInput | null | undefined, editorResolverService: IEditorResolverService): IAvailableEditorTypes | undefined { - const resource = EditorResourceAccessor.getOriginalUri(activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY }); + const standardDiffResources = isDiffEditorInput(activeEditor) ? { + original: activeEditor.original.resource, + modified: activeEditor.modified.resource, + } : undefined; + const diffResources = standardDiffResources ?? (isEditorInputWithDiffResources(activeEditor) ? activeEditor.diffResources : undefined); + const resource = diffResources?.modified ?? EditorResourceAccessor.getOriginalUri(activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY }); if (!resource) { return undefined; } @@ -41,12 +46,11 @@ export function getAvailableEditorTypes(activeEditor: EditorInput | null | undef if (editors.length <= 1) { return undefined; } - const isDiffEditor = isDiffEditorInput(activeEditor); return { resource, - isDiffEditor, - originalResource: isDiffEditor ? activeEditor.original.resource : undefined, - modifiedResource: isDiffEditor ? activeEditor.modified.resource : undefined, + isDiffEditor: !!diffResources, + originalResource: diffResources?.original, + modifiedResource: diffResources?.modified, currentId: activeEditor?.editorId ?? DEFAULT_EDITOR_ASSOCIATION.id, editors }; diff --git a/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts b/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts index bd77caab75a..67913a163b6 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts @@ -28,6 +28,7 @@ import { mainWindow } from '../../../../base/browser/window.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { DEFAULT_CUSTOM_TITLEBAR_HEIGHT } from '../../../../platform/window/common/window.js'; +import { PendingNotificationToasts } from './pendingNotificationToasts.js'; interface INotificationToast { readonly item: INotificationViewItem; @@ -72,6 +73,7 @@ export class NotificationsToasts extends Themable implements INotificationsToast private readonly mapNotificationToToast = new Map<INotificationViewItem, INotificationToast>(); private readonly mapNotificationToDisposable = new Map<INotificationViewItem, IDisposable>(); + private readonly pendingToasts: PendingNotificationToasts<INotificationViewItem>; private readonly notificationsToastsVisibleContextKey: IContextKey<boolean>; @@ -93,6 +95,12 @@ export class NotificationsToasts extends Themable implements INotificationsToast super(themeService); this.notificationsToastsVisibleContextKey = NotificationsToastsVisibleContext.bindTo(contextKeyService); + this.pendingToasts = this._register(new PendingNotificationToasts( + item => this.model.notifications.includes(item), + (item, other) => item.equals(other), + callback => scheduleAtNextAnimationFrame(getWindow(this.container), callback) + )); + this._register(toDisposable(() => this.removeToasts())); this.registerListeners(); } @@ -192,6 +200,10 @@ export class NotificationsToasts extends Themable implements INotificationsToast } } + if (this.pendingToasts.tryReplace(item)) { + return; + } + // Optimization: it is possible that a lot of notifications are being // added in a very short time. To prevent this kind of spam, we protect // against showing too many notifications at once. Since they can always @@ -202,15 +214,10 @@ export class NotificationsToasts extends Themable implements INotificationsToast return; } - // Optimization: showing a notification toast can be expensive - // because of the associated animation. If the renderer is busy - // doing actual work, the animation can cause a lot of slowdown - // As such we use `scheduleAtNextAnimationFrame` to push out - // the toast until the renderer has time to process it. - // (see also https://github.com/microsoft/vscode/issues/107935) - const itemDisposables = new DisposableStore(); - this.mapNotificationToDisposable.set(item, itemDisposables); - itemDisposables.add(scheduleAtNextAnimationFrame(getWindow(this.container), () => this.doAddToast(item, itemDisposables))); + this.pendingToasts.add(item, (pendingItem, itemDisposables) => { + this.mapNotificationToDisposable.set(pendingItem, itemDisposables); + this.doAddToast(pendingItem, itemDisposables); + }); } private isElementInNotificationQuarter(element: HTMLElement): boolean { @@ -395,6 +402,8 @@ export class NotificationsToasts extends Themable implements INotificationsToast private removeToast(item: INotificationViewItem): void { let focusEditor = false; + this.pendingToasts.remove(item); + // UI const notificationToast = this.mapNotificationToToast.get(item); if (notificationToast) { @@ -432,6 +441,9 @@ export class NotificationsToasts extends Themable implements INotificationsToast private removeToasts(): void { + // Pending + this.pendingToasts.clear(); + // Toast this.mapNotificationToToast.clear(); diff --git a/src/vs/workbench/browser/parts/notifications/pendingNotificationToasts.ts b/src/vs/workbench/browser/parts/notifications/pendingNotificationToasts.ts new file mode 100644 index 00000000000..cbbbdb5e858 --- /dev/null +++ b/src/vs/workbench/browser/parts/notifications/pendingNotificationToasts.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; + +interface IPendingNotificationToast<T> { + item: T; + readonly disposables: DisposableStore; + cleanupScheduled: boolean; +} + +export class PendingNotificationToasts<T> implements IDisposable { + + private readonly pendingToasts = new Set<IPendingNotificationToast<T>>(); + + constructor( + private readonly isCurrent: (item: T) => boolean, + private readonly equals: (item: T, other: T) => boolean, + private readonly scheduler: (callback: () => void) => IDisposable + ) { } + + tryReplace(item: T): boolean { + for (const pendingToast of this.pendingToasts) { + if (this.equals(pendingToast.item, item)) { + pendingToast.item = item; + return true; + } + } + + return false; + } + + add(item: T, render: (item: T, disposables: DisposableStore) => void): void { + const disposables = new DisposableStore(); + const pendingToast: IPendingNotificationToast<T> = { item, disposables, cleanupScheduled: false }; + this.pendingToasts.add(pendingToast); + disposables.add(toDisposable(() => this.pendingToasts.delete(pendingToast))); + // Defer toast creation to avoid animation work while the renderer is busy (#107935). + disposables.add(this.scheduler(() => { + const pendingItem = pendingToast.item; + if (!this.isCurrent(pendingItem)) { + disposables.dispose(); + return; + } + + this.pendingToasts.delete(pendingToast); + render(pendingItem, disposables); + })); + } + + remove(item: T): void { + for (const pendingToast of this.pendingToasts) { + if (pendingToast.item === item && !pendingToast.cleanupScheduled) { + pendingToast.cleanupScheduled = true; + // Allow a synchronous duplicate ADD to retarget the pending toast before cleanup. + queueMicrotask(() => { + pendingToast.cleanupScheduled = false; + if (this.pendingToasts.has(pendingToast) && !this.isCurrent(pendingToast.item)) { + pendingToast.disposables.dispose(); + } + }); + break; + } + } + } + + clear(): void { + for (const pendingToast of this.pendingToasts) { + pendingToast.disposables.dispose(); + } + this.pendingToasts.clear(); + } + + dispose(): void { + this.clear(); + } +} diff --git a/src/vs/workbench/common/editor.ts b/src/vs/workbench/common/editor.ts index 88501d42223..93a4c38804a 100644 --- a/src/vs/workbench/common/editor.ts +++ b/src/vs/workbench/common/editor.ts @@ -966,6 +966,19 @@ export function isDiffEditorInput(editor: unknown): editor is IDiffEditorInput { return isEditorInput(candidate?.modified) && isEditorInput(candidate?.original); } +export interface IEditorInputWithDiffResources extends EditorInput { + readonly diffResources: { + readonly original: URI; + readonly modified: URI; + }; +} + +export function isEditorInputWithDiffResources(editor: unknown): editor is IEditorInputWithDiffResources { + const candidate = editor as IEditorInputWithDiffResources | undefined; + + return URI.isUri(candidate?.diffResources?.original) && URI.isUri(candidate.diffResources.modified); +} + export interface IUntypedFileEditorInput extends ITextResourceEditorInput { /** diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index 5af7e21124f..8fabfbd6a4f 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -72,7 +72,8 @@ export const enum AccessibilityVerbositySettingId { SessionsChanges = 'accessibility.verbosity.sessionsChanges', ChatQuestionCarousel = 'accessibility.verbosity.chatQuestionCarousel', Survey = 'accessibility.verbosity.survey', - Automations = 'accessibility.verbosity.automations' + Automations = 'accessibility.verbosity.automations', + BrowserElementCommenting = 'accessibility.verbosity.browserElementCommenting' } const baseVerbosityProperty: IConfigurationPropertySchema = { @@ -230,6 +231,10 @@ const configuration: IConfigurationNode = { description: localize('verbosity.automations', 'Provide information about how to use the Automations section of the Agent Customizations editor, including keyboard navigation and how to inspect scheduled runs.'), ...baseVerbosityProperty }, + [AccessibilityVerbositySettingId.BrowserElementCommenting]: { + description: localize('verbosity.browserElementCommenting', 'Provide information about how to access element commenting accessibility help in the Integrated Browser.'), + ...baseVerbosityProperty + }, 'accessibility.signalOptions.volume': { 'description': localize('accessibility.signalOptions.volume', "The volume of the sounds in percent (0-100)."), 'type': 'number', diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index 79759b047b7..eb9dea4dc1c 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -8,6 +8,7 @@ import '../../chat/browser/voiceClient/micCaptureService.js'; import '../../chat/browser/voiceClient/ttsPlaybackService.js'; import '../../chat/browser/voiceClient/voiceClientService.js'; import { IVoiceSessionController } from '../../chat/browser/voiceClient/voiceSessionController.js'; +import { VOICE_AGENT_PROGRESS_SETTING } from '../../chat/common/voiceClient/voiceClientService.js'; import '../../chat/browser/voiceClient/voiceToolDispatchService.js'; import '../../chat/common/voicePlaybackService.js'; @@ -615,6 +616,13 @@ configurationRegistry.registerConfiguration({ default: true, scope: ConfigurationScope.APPLICATION, }, + [VOICE_AGENT_PROGRESS_SETTING]: { + type: 'boolean', + markdownDescription: nls.localize('agents.voice.agentProgress', "Allow Agent mode to speak brief semantic progress updates while it investigates, plans, edits, validates, or recovers from a problem."), + default: false, + tags: ['experimental'], + scope: ConfigurationScope.APPLICATION, + }, 'agents.voice.voice': { type: 'string', enum: ['victoria_neutral', 'kevin_neutral', 'maya_neutral', 'daniel_neutral'], @@ -644,7 +652,7 @@ configurationRegistry.registerConfiguration({ nls.localize('agents.voice.language.ko', "Korean"), nls.localize('agents.voice.language.zh', "Chinese"), ], - markdownDescription: nls.localize('agents.voice.language', "The language used for speech recognition and spoken responses. The selectable languages support native voice output. Automatic follows the system or browser locale for speech recognition and uses English voice output when the detected language does not support native voice output. Changing this while voice mode is connected takes effect immediately."), + markdownDescription: nls.localize('agents.voice.language', "The language used for speech recognition, dictation, and spoken responses. The selectable languages support native voice output. Automatic follows the system or browser locale for speech recognition and dictation, and uses English voice output when the detected language does not support native voice output. Changing this while voice mode is connected takes effect immediately."), default: 'auto', scope: ConfigurationScope.APPLICATION, }, diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/de_marc_neutral.mp3 b/src/vs/workbench/contrib/agentsVoice/browser/media/de_marc_neutral.mp3 new file mode 100644 index 00000000000..ea6bb7b1c24 Binary files /dev/null and b/src/vs/workbench/contrib/agentsVoice/browser/media/de_marc_neutral.mp3 differ diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/es-ES_maria_neutral.mp3 b/src/vs/workbench/contrib/agentsVoice/browser/media/es-ES_maria_neutral.mp3 new file mode 100644 index 00000000000..e6b6cac8669 Binary files /dev/null and b/src/vs/workbench/contrib/agentsVoice/browser/media/es-ES_maria_neutral.mp3 differ diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/fr_david_neutral.mp3 b/src/vs/workbench/contrib/agentsVoice/browser/media/fr_david_neutral.mp3 new file mode 100644 index 00000000000..f08d15fab1e Binary files /dev/null and b/src/vs/workbench/contrib/agentsVoice/browser/media/fr_david_neutral.mp3 differ diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/it_eva_neutral.mp3 b/src/vs/workbench/contrib/agentsVoice/browser/media/it_eva_neutral.mp3 new file mode 100644 index 00000000000..88fba1b9351 Binary files /dev/null and b/src/vs/workbench/contrib/agentsVoice/browser/media/it_eva_neutral.mp3 differ diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/ja_aruha_neutral.mp3 b/src/vs/workbench/contrib/agentsVoice/browser/media/ja_aruha_neutral.mp3 new file mode 100644 index 00000000000..acd0de8515e Binary files /dev/null and b/src/vs/workbench/contrib/agentsVoice/browser/media/ja_aruha_neutral.mp3 differ diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/ko_jiyon_neutral.mp3 b/src/vs/workbench/contrib/agentsVoice/browser/media/ko_jiyon_neutral.mp3 new file mode 100644 index 00000000000..270633bcacc Binary files /dev/null and b/src/vs/workbench/contrib/agentsVoice/browser/media/ko_jiyon_neutral.mp3 differ diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/pt-BR_gil_neutral.mp3 b/src/vs/workbench/contrib/agentsVoice/browser/media/pt-BR_gil_neutral.mp3 new file mode 100644 index 00000000000..98cf005d497 Binary files /dev/null and b/src/vs/workbench/contrib/agentsVoice/browser/media/pt-BR_gil_neutral.mp3 differ diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/zh_wuzhi_neutral.mp3 b/src/vs/workbench/contrib/agentsVoice/browser/media/zh_wuzhi_neutral.mp3 new file mode 100644 index 00000000000..3397d8fda3b Binary files /dev/null and b/src/vs/workbench/contrib/agentsVoice/browser/media/zh_wuzhi_neutral.mp3 differ diff --git a/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts b/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts index e4345ca7945..ecc5fd0a2db 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/voiceModeOnboarding.ts @@ -33,10 +33,13 @@ import './media/voiceModeOnboarding.css'; /** Setting the banner writes when a voice chip is picked. */ const VOICE_SETTING = 'agents.voice.voice'; +/** Setting that controls the language Voice Mode speaks. */ +const VOICE_LANGUAGE_SETTING = 'agents.voice.language'; + /** Where the first link sends anyone who wants to change their mind later. */ const VOICE_SETTINGS_COMMAND = 'agentsVoice.openSettings'; -type VoiceModeOnboardingAction = 'shown' | 'selectVoice' | 'selectMicrophone' | 'openSettings' | 'close' | 'escape'; +type VoiceModeOnboardingAction = 'shown' | 'selectVoice' | 'previewVoice' | 'selectMicrophone' | 'openSettings' | 'close' | 'escape'; type VoiceModeOnboardingActionClassification = { action: { classification: 'PublicNonPersonalData'; purpose: 'FeatureInsight'; comment: 'The action taken in the Voice Mode onboarding card.' }; @@ -122,6 +125,42 @@ const VOICES: readonly IVoiceModeVoice[] = [ }, ]; +/** + * A language Voice Mode speaks natively, and the single voice its backend uses + * for that language. Choosing between voices is an English-only affordance, so + * for these languages the card previews this one voice rather than the four + * English options. + */ +interface ILocalizedVoice { + readonly id: string; + readonly label: string; +} + +const LOCALIZED_VOICES: Readonly<Record<string, ILocalizedVoice>> = { + de: { id: 'de_marc_neutral', label: localize('voiceMode.onboarding.voice.marc', "Marc") }, + es: { id: 'es-ES_maria_neutral', label: localize('voiceMode.onboarding.voice.maria', "Maria") }, + fr: { id: 'fr_david_neutral', label: localize('voiceMode.onboarding.voice.david', "David") }, + it: { id: 'it_eva_neutral', label: localize('voiceMode.onboarding.voice.eva', "Eva") }, + ja: { id: 'ja_aruha_neutral', label: localize('voiceMode.onboarding.voice.aruha', "Aruha") }, + ko: { id: 'ko_jiyon_neutral', label: localize('voiceMode.onboarding.voice.jiyon', "Jiyon") }, + pt: { id: 'pt-BR_gil_neutral', label: localize('voiceMode.onboarding.voice.gil', "Gil") }, + zh: { id: 'zh_wuzhi_neutral', label: localize('voiceMode.onboarding.voice.wuzhi', "Wuzhi") }, +}; + +/** + * The native voice for a spoken language, or `undefined` when the language has + * no native voice and the card should fall back to the English voice chooser. + */ +function localizedVoiceForLanguage(language: string): ILocalizedVoice | undefined { + try { + const canonical = Intl.getCanonicalLocales(language.trim())[0]; + const base = canonical?.split('-')[0].toLowerCase(); + return base ? LOCALIZED_VOICES[base] : undefined; + } catch { + return undefined; + } +} + /** * The trace before anyone has chosen: the four signatures averaged component by * component, so it belongs to no voice in particular rather than quietly being @@ -447,11 +486,11 @@ class VoiceSamplePlayer extends Disposable { return Math.min(1, Math.sqrt(sum / this.levels.length) * 3.2); } - play(voiceId: string): void { + play(sampleId: string): void { this.stop(); try { const audio = this.ensureAudio(); - audio.src = FileAccess.asBrowserUri(`vs/workbench/contrib/agentsVoice/browser/media/${voiceId}.mp3`).toString(true); + audio.src = FileAccess.asBrowserUri(`vs/workbench/contrib/agentsVoice/browser/media/${sampleId}.mp3`).toString(true); const store = new DisposableStore(); store.add(dom.addDisposableListener(audio, 'ended', () => this.stop())); @@ -459,7 +498,7 @@ class VoiceSamplePlayer extends Disposable { store.add(toDisposable(() => audio.pause())); this.playback.value = store; - this.setPlayingVoice(voiceId); + this.setPlayingVoice(sampleId); audio.play().catch(error => { this.logService.trace(`[voice] Voice Mode onboarding preview failed: ${error}`); this.stop(); @@ -525,6 +564,15 @@ export interface IVoiceModeOnboardingBannerOptions { readonly source: 'automatic' | 'manual'; /** Allows tests to provide a deterministic media element. */ readonly audioFactory?: () => HTMLAudioElement; + /** Allows tests to provide a deterministic spoken language. */ + readonly voiceLanguage?: string; +} + +/** A rendered voice option, with the strings its play state swaps between. */ +interface IVoiceElement { + readonly element: HTMLElement; + readonly label: string; + readonly restingAria: string; } /** @@ -547,7 +595,10 @@ export class VoiceModeOnboardingBanner extends Disposable { private microphoneOptions: IMicrophoneOption[] = []; private microphonePickerContainer: HTMLElement | undefined; - private readonly voiceElements = new Map<string, HTMLElement>(); + private readonly voiceElements = new Map<string, IVoiceElement>(); + + /** The native voice for the spoken language, when one exists. */ + private readonly localizedVoice: ILocalizedVoice | undefined; /** The voice being auditioned, and the one that will be committed. */ private selectedVoice: IVoiceModeVoice | undefined; @@ -577,6 +628,7 @@ export class VoiceModeOnboardingBanner extends Disposable { }, })); this.domNode = this.card.domNode; + this.localizedVoice = localizedVoiceForLanguage(this.resolveSpokenLanguage()); this.player = this._register(instantiationService.createInstance(VoiceSamplePlayer, this.domNode, options.audioFactory)); this._register(this.player.onDidChangePlayingVoice(voiceId => this.updatePlaying(voiceId))); @@ -705,14 +757,21 @@ export class VoiceModeOnboardingBanner extends Disposable { } /** - * The four voices as real buttons - border, hover lift, pressed feedback - - * because bare text gave no sign it could be clicked at all. + * The voices as real buttons - border, hover lift, pressed feedback - + * because bare text gave no sign it could be clicked at all. In a language + * Voice Mode speaks natively there is only one voice, so the card previews + * that voice instead of offering the English chooser. */ private renderVoices(container: HTMLElement): void { const labelText = localize('voiceMode.onboarding.voices', "Agent Voice:"); const label = dom.append(container, dom.$('.voice-mode-onboarding-voices-label')); label.textContent = labelText; + if (this.localizedVoice) { + this.renderLocalizedVoice(container, labelText, this.localizedVoice); + return; + } + const group = dom.append(container, dom.$('.voice-mode-onboarding-voices')); group.setAttribute('role', 'radiogroup'); group.setAttribute('aria-label', labelText); @@ -720,22 +779,14 @@ export class VoiceModeOnboardingBanner extends Disposable { for (const voice of VOICES) { const option = dom.append(group, dom.$('.voice-mode-onboarding-voice')); option.setAttribute('role', 'radio'); - option.setAttribute('aria-label', this.voiceAriaLabel(voice, false)); + const restingAria = localize('voiceMode.onboarding.voice.ariaLabel', "{0}. Hear this voice and use it for every conversation.", voice.label); + option.setAttribute('aria-label', restingAria); - // The icon is the affordance: it says "this will speak" before the - // click, and "this is yours" after it. - const icon = dom.append(option, dom.$('span.voice-mode-onboarding-voice-icon')); - dom.append(icon, dom.$(`span.codicon.codicon-${Codicon.play.id}.voice-mode-onboarding-voice-idle`)).setAttribute('aria-hidden', 'true'); - dom.append(icon, dom.$(`span.codicon.codicon-${Codicon.checkCompact.id}.voice-mode-onboarding-voice-chosen`)).setAttribute('aria-hidden', 'true'); - const bars = dom.append(icon, dom.$('span.voice-mode-onboarding-voice-bars')); - bars.setAttribute('aria-hidden', 'true'); - for (let bar = 0; bar < 3; bar++) { - dom.append(bars, dom.$('span.voice-mode-onboarding-voice-bar')); - } + this.appendVoiceIcon(option); const label = dom.append(option, dom.$('span.voice-mode-onboarding-voice-label')); label.textContent = voice.label; - this.voiceElements.set(voice.id, option); + this.voiceElements.set(voice.id, { element: option, label: voice.label, restingAria }); this._register(dom.addDisposableListener(option, dom.EventType.CLICK, () => this.selectVoice(voice))); this._register(dom.addDisposableListener(option, dom.EventType.KEY_DOWN, event => this.handleOptionKey(event, voice))); @@ -744,14 +795,53 @@ export class VoiceModeOnboardingBanner extends Disposable { this.updateSelection(); } - // --- Shared behaviour --- + /** + * The single native voice for the spoken language, as a preview button: + * there is nothing to choose, so it only ever plays and stops. + */ + private renderLocalizedVoice(container: HTMLElement, ariaLabel: string, voice: ILocalizedVoice): void { + const group = dom.append(container, dom.$('.voice-mode-onboarding-voices')); + group.setAttribute('aria-label', ariaLabel); - private voiceAriaLabel(voice: IVoiceModeVoice, playing: boolean): string { - return playing - ? localize('voiceMode.onboarding.voice.stopPreview', "Stop {0} preview.", voice.label) - : localize('voiceMode.onboarding.voice.ariaLabel', "{0}. Hear this voice and use it for every conversation.", voice.label); + const option = dom.append(group, dom.$('.voice-mode-onboarding-voice')); + option.setAttribute('role', 'button'); + option.tabIndex = 0; + const restingAria = localize('voiceMode.onboarding.voice.previewAriaLabel', "{0}. Hear how your agent will sound.", voice.label); + option.setAttribute('aria-label', restingAria); + + this.appendVoiceIcon(option); + + const label = dom.append(option, dom.$('span.voice-mode-onboarding-voice-label')); + label.textContent = voice.label; + this.voiceElements.set(voice.id, { element: option, label: voice.label, restingAria }); + + this._register(dom.addDisposableListener(option, dom.EventType.CLICK, () => this.previewLocalizedVoice(voice))); + this._register(dom.addDisposableListener(option, dom.EventType.KEY_DOWN, event => { + const keyboardEvent = new StandardKeyboardEvent(event); + if (keyboardEvent.equals(KeyCode.Enter) || keyboardEvent.equals(KeyCode.Space)) { + keyboardEvent.preventDefault(); + this.previewLocalizedVoice(voice); + } + })); } + /** + * The icon is the affordance: it says "this will speak" before the click, + * animating bars while it speaks, then a check once a voice is chosen. + */ + private appendVoiceIcon(option: HTMLElement): void { + const icon = dom.append(option, dom.$('span.voice-mode-onboarding-voice-icon')); + dom.append(icon, dom.$(`span.codicon.codicon-${Codicon.play.id}.voice-mode-onboarding-voice-idle`)).setAttribute('aria-hidden', 'true'); + dom.append(icon, dom.$(`span.codicon.codicon-${Codicon.checkCompact.id}.voice-mode-onboarding-voice-chosen`)).setAttribute('aria-hidden', 'true'); + const bars = dom.append(icon, dom.$('span.voice-mode-onboarding-voice-bars')); + bars.setAttribute('aria-hidden', 'true'); + for (let bar = 0; bar < 3; bar++) { + dom.append(bars, dom.$('span.voice-mode-onboarding-voice-bar')); + } + } + + // --- Shared behaviour --- + private handleOptionKey(event: KeyboardEvent, voice: IVoiceModeVoice): void { const keyboardEvent = new StandardKeyboardEvent(event); if (keyboardEvent.equals(KeyCode.Enter) || keyboardEvent.equals(KeyCode.Space)) { @@ -770,7 +860,7 @@ export class VoiceModeOnboardingBanner extends Disposable { const index = VOICES.indexOf(voice); const next = VOICES[(index + (forward ? 1 : VOICES.length - 1)) % VOICES.length]; this.selectVoice(next); - this.voiceElements.get(next.id)?.focus(); + this.voiceElements.get(next.id)?.element.focus(); } } @@ -857,11 +947,43 @@ export class VoiceModeOnboardingBanner extends Disposable { .catch(error => this.logService.error(`[voice] Failed to persist the Voice Mode voice: ${error}`)); } + /** + * The localized voice is not a choice - it is the only voice for the + * language - so previewing it just plays and stops, and never persists. + */ + private previewLocalizedVoice(voice: ILocalizedVoice): void { + if (this.player.playingVoice === voice.id) { + this.player.stop(); + status(localize('voiceMode.onboarding.voice.localizedStopped', "{0} preview stopped.", voice.label)); + return; + } + this.logAction('previewVoice'); + this.player.play(voice.id); + status(localize('voiceMode.onboarding.voice.localizedPlaying', "Playing {0} preview.", voice.label)); + } + + /** + * The spoken language, mirroring the resolution the voice client uses: an + * explicit test override, then the configured language (unless `auto`), then + * the window's language. + */ + private resolveSpokenLanguage(): string { + if (this.options.voiceLanguage) { + return this.options.voiceLanguage; + } + + const configuredLanguage = this.configurationService.getValue<string>(VOICE_LANGUAGE_SETTING)?.trim(); + if (configuredLanguage && configuredLanguage.toLowerCase() !== 'auto') { + return configuredLanguage; + } + return dom.getWindow(this.domNode).navigator.language; + } + private updateSelection(): void { - for (const [id, element] of this.voiceElements) { + for (const [id, entry] of this.voiceElements) { const selected = id === this.selectedVoice?.id; - element.classList.toggle('selected', selected); - element.setAttribute('aria-checked', String(selected)); + entry.element.classList.toggle('selected', selected); + entry.element.setAttribute('aria-checked', String(selected)); } this.updateTabStop(); } @@ -872,21 +994,20 @@ export class VoiceModeOnboardingBanner extends Disposable { */ private updateTabStop(): void { let first = true; - for (const [id, element] of this.voiceElements) { + for (const [id, entry] of this.voiceElements) { const isTabStop = this.selectedVoice === undefined ? first : id === this.selectedVoice.id; - element.tabIndex = isTabStop ? 0 : -1; + entry.element.tabIndex = isTabStop ? 0 : -1; first = false; } } private updatePlaying(playingVoice: string | undefined): void { - for (const [id, element] of this.voiceElements) { + for (const [id, entry] of this.voiceElements) { const playing = id === playingVoice; - element.classList.toggle('playing', playing); - const voice = VOICES.find(candidate => candidate.id === id); - if (voice) { - element.setAttribute('aria-label', this.voiceAriaLabel(voice, playing)); - } + entry.element.classList.toggle('playing', playing); + entry.element.setAttribute('aria-label', playing + ? localize('voiceMode.onboarding.voice.stopPreview', "Stop {0} preview.", entry.label) + : entry.restingAria); } this.domNode.classList.toggle('playing', playingVoice !== undefined); } diff --git a/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts b/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts index f6cf4dfb1fb..5edee20f9aa 100644 --- a/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts +++ b/src/vs/workbench/contrib/agentsVoice/test/browser/voiceModeOnboarding.test.ts @@ -188,6 +188,53 @@ suite('Voice Mode onboarding', () => { }); }); + test('previews the native voice per language and keeps the chooser only for English', () => { + const instantiationService = workbenchInstantiationService(undefined, disposables); + instantiationService.stub(IAccessibilityService, new class extends mock<IAccessibilityService>() { + override readonly onDidChangeScreenReaderOptimized = Event.None; + override readonly onDidChangeReducedMotion = Event.None; + override isScreenReaderOptimized(): boolean { return false; } + override isMotionReduced(): boolean { return false; } + }); + + // A language Voice Mode speaks natively shows its one voice with no + // chooser; English and languages without a native voice keep the four. + const cases = [ + { language: 'de-DE', options: 1, chooser: false, sample: 'de_marc_neutral.mp3' }, + { language: 'es-MX', options: 1, chooser: false, sample: 'es-ES_maria_neutral.mp3' }, + { language: 'fr-CA', options: 1, chooser: false, sample: 'fr_david_neutral.mp3' }, + { language: 'it-IT', options: 1, chooser: false, sample: 'it_eva_neutral.mp3' }, + { language: 'ja-JP', options: 1, chooser: false, sample: 'ja_aruha_neutral.mp3' }, + { language: 'ko-KR', options: 1, chooser: false, sample: 'ko_jiyon_neutral.mp3' }, + { language: 'pt-PT', options: 1, chooser: false, sample: 'pt-BR_gil_neutral.mp3' }, + { language: 'zh-TW', options: 1, chooser: false, sample: 'zh_wuzhi_neutral.mp3' }, + { language: 'en-GB', options: 4, chooser: true, sample: 'maya_neutral.mp3' }, + { language: 'is', options: 4, chooser: true, sample: 'maya_neutral.mp3' }, + ]; + const actual: { language: string; options: number; chooser: boolean; sample: string }[] = []; + + for (const { language } of cases) { + const host = createHost(disposables); + const audio = document.createElement('audio'); + audio.play = () => Promise.resolve(); + disposables.add(instantiationService.createInstance(VoiceModeOnboardingBanner, { + container: host.container, + onDismiss: () => undefined, + source: 'manual', + audioFactory: () => audio, + voiceLanguage: language, + })); + + const options = host.container.querySelectorAll('.voice-mode-onboarding-voice').length; + const chooser = !!host.container.querySelector('.voice-mode-onboarding-voices[role="radiogroup"]'); + host.container.querySelector<HTMLElement>('.voice-mode-onboarding-voice')!.click(); + const sample = audio.src.split(/[?#]/)[0].split('/').pop() ?? ''; + actual.push({ language, options, chooser, sample }); + } + + assert.deepStrictEqual(actual, cases); + }); + test('can be shown again manually', () => { const telemetryEvents: ITelemetryEvent[] = []; const service = createService(disposables, [], [], telemetryEvents); diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 7ca29954cf4..3c1da670e5d 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -44,6 +44,8 @@ import { IBrowserViewVisibilityEvent, IBrowserViewCertificateError, IElementData, + IBrowserElementCommentsUpdate, + IBrowserElementSelectionOptions, IBrowserViewOwner, IBrowserViewOpenOptions, IBrowserViewRect, @@ -52,7 +54,7 @@ import { IBrowserViewState, IBrowserDeviceProfile, IBrowserViewPermissionRequestEvent, - IBrowserElementSelectionOptions, + IBrowserElementSelectionState, } from '../../../../platform/browserView/common/browserView.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; @@ -371,7 +373,7 @@ export interface IBrowserViewModel extends IDisposable { readonly zoomFactor: number; readonly canZoomIn: boolean; readonly canZoomOut: boolean; - readonly isElementSelectionActive: boolean; + readonly elementSelectionState: IBrowserElementSelectionState; readonly isAreaSelectionActive: boolean; readonly device: IBrowserDeviceProfile | undefined; @@ -390,7 +392,8 @@ export interface IBrowserViewModel extends IDisposable { readonly onDidClose: Event<void>; readonly onWillDispose: Event<void>; readonly onDidSelectElement: Event<IElementData>; - readonly onDidChangeElementSelectionActive: Event<boolean>; + readonly onDidRemoveElementComment: Event<string>; + readonly onDidChangeElementSelectionState: Event<IBrowserElementSelectionState>; readonly onDidPickArea: Event<IBrowserViewRect | undefined>; readonly onDidChangeAreaSelectionActive: Event<boolean>; readonly onDidChangeDevice: Event<IBrowserDeviceProfile | undefined>; @@ -421,6 +424,7 @@ export interface IBrowserViewModel extends IDisposable { resetZoom(): Promise<void>; getConsoleLogs(): Promise<string>; toggleElementSelection(enabled?: boolean, options?: IBrowserElementSelectionOptions): Promise<void>; + setElementComments(update: IBrowserElementCommentsUpdate): Promise<void>; toggleAreaSelection(enabled?: boolean): Promise<void>; setDevice(device: IBrowserDeviceProfile | undefined): Promise<void>; } @@ -444,7 +448,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private _zoomHost: string | undefined = undefined; private _sharedWithAgent: boolean = false; private _browserZoomIndex: number = browserZoomDefaultIndex; - private _isElementSelectionActive: boolean = false; + private _elementSelectionState: IBrowserElementSelectionState = { active: false, options: {} }; private _isAreaSelectionActive: boolean = false; private _device: IBrowserDeviceProfile | undefined; @@ -498,7 +502,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { this._storageScope = initialState.storageScope; this._isRemoteSession = initialState.isRemoteSession; this._browserZoomIndex = initialState.browserZoomIndex; - this._isElementSelectionActive = initialState.isElementSelectionActive; + this._elementSelectionState = initialState.elementSelectionState; this._isAreaSelectionActive = initialState.isAreaSelectionActive; this._device = initialState.device; this._isEphemeral = this._storageScope === BrowserViewStorageScope.Ephemeral; @@ -601,11 +605,11 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } })); - this._register(this.onDidChangeElementSelectionActive(active => { - if (active) { + this._register(this.onDidChangeElementSelectionState(state => { + if (state.active && !this._elementSelectionState.active) { this.telemetryService.publicLog2<IntegratedBrowserAddElementToChatStartEvent, IntegratedBrowserAddElementToChatStartClassification>('integratedBrowser.addElementToChat.start', {}); } - this._isElementSelectionActive = active; + this._elementSelectionState = state; })); this._register(this.onDidChangeAreaSelectionActive(active => { @@ -648,7 +652,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { get zoomFactor(): number { return browserZoomFactors[this._browserZoomIndex]; } get canZoomIn(): boolean { return this._browserZoomIndex < browserZoomFactors.length - 1; } get canZoomOut(): boolean { return this._browserZoomIndex > 0; } - get isElementSelectionActive(): boolean { return this._isElementSelectionActive; } + get elementSelectionState(): IBrowserElementSelectionState { return this._elementSelectionState; } get isAreaSelectionActive(): boolean { return this._isAreaSelectionActive; } get device(): IBrowserDeviceProfile | undefined { return this._device; } @@ -855,6 +859,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.toggleElementSelection(this.id, enabled, options); } + async setElementComments(update: IBrowserElementCommentsUpdate): Promise<void> { + return this.browserViewService.setElementComments(this.id, update); + } + async toggleAreaSelection(enabled?: boolean): Promise<void> { return this.browserViewService.toggleAreaSelection(this.id, enabled); } @@ -863,8 +871,12 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.onDynamicDidSelectElement(this.id); } - get onDidChangeElementSelectionActive(): Event<boolean> { - return this.browserViewService.onDynamicDidChangeElementSelectionActive(this.id); + get onDidRemoveElementComment(): Event<string> { + return this.browserViewService.onDynamicDidRemoveElementComment(this.id); + } + + get onDidChangeElementSelectionState(): Event<IBrowserElementSelectionState> { + return this.browserViewService.onDynamicDidChangeElementSelectionState(this.id); } get onDidPickArea(): Event<IBrowserViewRect | undefined> { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index 9084813d496..7feacaf2d37 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -25,12 +25,14 @@ import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { IsSessionsWindowContext } from '../../../common/contextkeys.js'; import { ChatConfiguration } from '../../chat/common/constants.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; -import { focusBorder } from '../../../../platform/theme/common/colors/baseColors.js'; -import { buttonForeground, buttonBackground } from '../../../../platform/theme/common/colors/inputColors.js'; +import { contrastBorder, descriptionForeground, focusBorder } from '../../../../platform/theme/common/colors/baseColors.js'; +import { buttonForeground, buttonBackground, inputPlaceholderForeground } from '../../../../platform/theme/common/colors/inputColors.js'; +import { editorWidgetBackground, editorWidgetBorder, editorWidgetForeground, toolbarHoverBackground, widgetShadow } from '../../../../platform/theme/common/colors/editorColors.js'; import { DEFAULT_FONT_FAMILY } from '../../../../base/browser/fonts.js'; import { findGroup } from '../../../services/editor/common/editorGroupFinder.js'; import { ChatEditorInput } from '../../chat/browser/widgetHosts/editor/chatEditorInput.js'; import { IChatWidgetService } from '../../chat/browser/chat.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; import { Schemas } from '../../../../base/common/network.js'; @@ -122,6 +124,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV @INativeWorkbenchEnvironmentService private readonly environmentService: INativeWorkbenchEnvironmentService, @IThemeService private readonly themeService: IThemeService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, ) { super(); const channel = mainProcessService.getChannel(ipcBrowserViewChannelName); @@ -134,6 +137,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV const chatEnabledKeys = new Set(ChatContextKeys.enabled.keys()); this._register(this.keybindingService.onDidUpdateKeybindings(() => this._updateWindowConfiguration())); this._register(this.themeService.onDidColorThemeChange(() => this._updateWindowConfiguration())); + this._register(this.accessibilityService.onDidChangeReducedMotion(() => this._updateWindowConfiguration())); this._register(this.workspaceTrustManagementService.onDidChangeTrustedFolders(() => this._updateWindowConfiguration())); this._register(this.workspaceTrustManagementService.onDidChangeTrust(() => this._updateWindowConfiguration())); this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this._updateWindowConfiguration())); @@ -518,7 +522,16 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV focusBorder: theme.getColor(focusBorder)?.toString(), buttonBackground: theme.getColor(buttonBackground)?.toString(), buttonForeground: theme.getColor(buttonForeground)?.toString(), + widgetBackground: theme.getColor(editorWidgetBackground)?.toString(), + widgetForeground: theme.getColor(editorWidgetForeground)?.toString(), + widgetBorder: theme.getColor(editorWidgetBorder)?.toString(), + widgetShadow: theme.getColor(widgetShadow)?.toString(), + contrastBorder: theme.getColor(contrastBorder)?.toString(), + descriptionForeground: theme.getColor(descriptionForeground)?.toString(), + inputPlaceholderForeground: theme.getColor(inputPlaceholderForeground)?.toString(), + toolbarHoverBackground: theme.getColor(toolbarHoverBackground)?.toString(), font: DEFAULT_FONT_FAMILY, + reducedMotion: this.accessibilityService.isMotionReduced(), }; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index f46c3ee4d3d..dabfa9bc59e 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -14,7 +14,7 @@ import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { DisposableMap, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; @@ -26,7 +26,7 @@ import { IChatWidget, IChatWidgetService } from '../../../chat/browser/chat.js'; import { IChatService } from '../../../chat/common/chatService/chatService.js'; import { IChatRequestVariableEntry } from '../../../chat/common/attachments/chatVariableEntries.js'; import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; -import { IBrowserElementSelectionOptions, IElementData, IElementAncestor, BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { BrowserElementSelectionMode, IBrowserElementSelectionOptions, IElementData, IElementAncestor, BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; import { IBrowserViewModel, BrowserViewSharingState } from '../../../browserView/common/browserView.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; @@ -39,6 +39,13 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; import { Extensions as ConfigurationMigrationExtensions, IConfigurationMigrationRegistry, workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; import { safeSetInnerHtml } from '../../../../../base/browser/domSanitize.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { ChatDynamicVariableModel } from '../../../chat/browser/attachments/chatDynamicVariables.js'; +import { toAttachedContextDynamicVariable } from '../../../chat/common/attachments/chatVariables.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType, IAccessibleViewService } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { AccessibleViewRegistry, IAccessibleViewImplementation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; // Register tools import '../tools/browserTools.contribution.js'; @@ -98,9 +105,38 @@ function createElementContextValue(elementData: IElementData, displayName: strin const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); const BrowserCategory = localize2('browserCategory', "Browser"); -const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey<boolean>('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); +const CONTEXT_BROWSER_ELEMENT_SELECTION_MODE = new RawContextKey<BrowserElementSelectionMode | undefined>('browserElementSelectionMode', undefined, localize('browser.elementSelectionMode', "The active element selection mode")); const CONTEXT_BROWSER_AREA_SELECTION_ACTIVE = new RawContextKey<boolean>('browserAreaSelectionActive', false, localize('browser.areaSelectionActive', "Whether area selection is currently active")); +class BrowserElementCommentingAccessibilityHelp implements IAccessibleViewImplementation { + readonly type = AccessibleViewType.Help; + readonly priority = 110; + readonly name = 'browserElementCommenting'; + readonly when = CONTEXT_BROWSER_ELEMENT_SELECTION_MODE.isEqualTo(BrowserElementSelectionMode.Comment); + + getProvider(accessor: ServicesAccessor): AccessibleContentProvider | undefined { + const editorPane = accessor.get(IEditorService).activeEditorPane; + if (!(editorPane instanceof BrowserEditor)) { + return undefined; + } + return new AccessibleContentProvider( + AccessibleViewProviderId.BrowserElementCommenting, + { type: AccessibleViewType.Help }, + () => [ + localize('browser.elementCommentingAccessibilityHelp.overview', "You are in Integrated Browser element commenting mode."), + localize('browser.elementCommentingAccessibilityHelp.navigation', "Use Tab and Shift+Tab to move through focusable page elements. Press Enter to comment on the focused element."), + localize('browser.elementCommentingAccessibilityHelp.composer', "In the comment input, press Enter to add the comment or Escape to cancel it."), + localize('browser.elementCommentingAccessibilityHelp.continuous', "Commenting mode remains active after adding a comment. Press Escape outside the comment input to stop commenting."), + localize('browser.elementCommentingAccessibilityHelp.pins', "Numbered comment pins are in the page tab order. Focus a pin to preview its comment, then Tab to its Remove Comment button."), + ].join('\n'), + () => editorPane.focus(), + AccessibilityVerbositySettingId.BrowserElementCommenting + ); + } +} + +AccessibleViewRegistry.register(new BrowserElementCommentingAccessibilityHelp()); + type IntegratedBrowserAddScreenshotToChatAddedEvent = { screenshotType: 'viewport' | 'area' | 'fullPage'; }; @@ -117,8 +153,14 @@ type IntegratedBrowserAddScreenshotToChatAddedClassification = { * console log attachment to chat, and agent sharing. */ export class BrowserEditorChatIntegration extends BrowserEditorContribution { - private readonly _elementSelectionActiveContext: IContextKey<boolean>; + private readonly _elementSelectionModeContext: IContextKey<BrowserElementSelectionMode | undefined>; private readonly _areaSelectionActiveContext: IContextKey<boolean>; + private _elementSelectionMode: BrowserElementSelectionMode | undefined; + private readonly _commentReferences = new Map<string, { elementId: string; attachmentIds: readonly string[]; widget: IChatWidget; browserModel: IBrowserViewModel }>(); + private readonly _commentReferenceListeners = this._register(new DisposableMap<IChatWidget, DisposableStore>()); + private readonly _commentModelListeners = this._register(new DisposableMap<IBrowserViewModel, DisposableStore>()); + private readonly _disposedCommentModels = new WeakSet<IBrowserViewModel>(); + private readonly _commentSessionsWithComments = new Set<IBrowserViewModel>(); // Share with Agent private readonly _shareButtonContainer: HTMLElement; @@ -137,9 +179,10 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { @IStorageService private readonly storageService: IStorageService, @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + @IAccessibleViewService private readonly accessibleViewService: IAccessibleViewService, ) { super(editor); - this._elementSelectionActiveContext = CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE.bindTo(contextKeyService); + this._elementSelectionModeContext = CONTEXT_BROWSER_ELEMENT_SELECTION_MODE.bindTo(contextKeyService); this._areaSelectionActiveContext = CONTEXT_BROWSER_AREA_SELECTION_ACTIVE.bindTo(contextKeyService); // Build share toggle button @@ -165,10 +208,26 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { })); // Auto-disable element selection when the user sends a chat request. - this._register(this.chatService.onDidSubmitRequest(() => { - if (this.editor.model?.isElementSelectionActive) { + this._register(this.chatService.onDidSubmitRequest(event => { + if (this.editor.model?.elementSelectionState.active) { void this.editor.model.toggleElementSelection(false); } + const submittedComments = [...this._commentReferences] + .filter(([, reference]) => reference.widget.viewModel && isEqual(reference.widget.viewModel.sessionResource, event.chatSessionResource)); + if (submittedComments.length > 0) { + const browserModels = new Set(submittedComments.map(([, reference]) => reference.browserModel)); + const widgets = new Set(submittedComments.map(([, reference]) => reference.widget)); + for (const [attachmentId] of submittedComments) { + this._commentReferences.delete(attachmentId); + } + for (const widget of widgets) { + this._disposeCommentReferenceListenerIfUnused(widget); + } + for (const browserModel of browserModels) { + this._syncElementComments(browserModel); + this._disposeCommentModelListenerIfUnused(browserModel); + } + } })); } @@ -183,20 +242,49 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { this._updateSharingState(false); })); store.add(model.onDidSelectElement(async data => { + const tracksComment = data.comment !== undefined && data.elementId !== undefined; + if (tracksComment) { + this._ensureCommentModelListeners(model); + } + let attached = false; try { - await this._attachElementDataToChat(data, model); + attached = await this._attachElementDataToChat(data, model); } catch (error) { this.logService.error('BrowserEditor.addElementToChat: Failed to attach element', error); } + if (!attached && data.comment !== undefined && data.elementId && !this._disposedCommentModels.has(model)) { + this._syncElementComments(model, [data.elementId]); + } + if (tracksComment) { + this._disposeCommentModelListenerIfUnused(model); + } })); // Sync context key with model state - this._elementSelectionActiveContext.set(model.isElementSelectionActive); - store.add(model.onDidChangeElementSelectionActive(active => { - this._elementSelectionActiveContext.set(active); - this.accessibilityService.status(active - ? localize('browser.elementSelectionEnabled', "Element selection enabled. Press Enter to add the focused element to chat.") - : localize('browser.elementSelectionDisabled', "Element selection disabled.")); + this._elementSelectionMode = model.elementSelectionState.active ? model.elementSelectionState.options.mode : undefined; + this._elementSelectionModeContext.set(this._elementSelectionMode); + store.add(model.onDidChangeElementSelectionState(state => { + const wasCommenting = this._elementSelectionMode === BrowserElementSelectionMode.Comment; + this._elementSelectionMode = state.active ? state.options.mode : undefined; + this._elementSelectionModeContext.set(this._elementSelectionMode); + const isCommenting = this._elementSelectionMode === BrowserElementSelectionMode.Comment; + const accessibilityHelpHint = isCommenting && state.active + ? this.accessibleViewService.getOpenAriaHint(AccessibilityVerbositySettingId.BrowserElementCommenting) + : undefined; + this.accessibilityService.status(isCommenting + ? state.active + ? accessibilityHelpHint + ? localize('browser.elementCommentingEnabledWithAccessibilityHelp', "Element commenting enabled. Press Enter to comment on the focused element. {0}", accessibilityHelpHint) + : localize('browser.elementCommentingEnabled', "Element commenting enabled. Press Enter to comment on the focused element.") + : localize('browser.elementCommentingDisabled', "Element commenting disabled.") + : state.active + ? localize('browser.elementSelectionEnabled', "Element selection enabled. Press Enter to add the focused element to chat.") + : localize('browser.elementSelectionDisabled', "Element selection disabled.")); + if (isCommenting && !wasCommenting) { + this._commentSessionsWithComments.delete(model); + } else if (wasCommenting && !isCommenting && this._commentSessionsWithComments.delete(model)) { + this._focusChatInputForComments(model); + } })); this._areaSelectionActiveContext.set(model.isAreaSelectionActive); store.add(model.onDidChangeAreaSelectionActive(active => { @@ -205,7 +293,11 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { } override onModelDetached(): void { - this._elementSelectionActiveContext.reset(); + if (this.editor.model) { + this._commentSessionsWithComments.delete(this.editor.model); + } + this._elementSelectionModeContext.reset(); + this._elementSelectionMode = undefined; this._areaSelectionActiveContext.reset(); } @@ -295,8 +387,8 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { * {@linkcode IChatWidget.attachmentModel.addContext} so the attachment is * not silently discarded. */ - private async _revealChatWidgetForAttachment(): Promise<IChatWidget | undefined> { - const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; + private async _revealChatWidgetForAttachment(preserveFocus = false): Promise<IChatWidget | undefined> { + const widget = await this.chatWidgetService.revealWidget(preserveFocus) ?? this.chatWidgetService.lastFocusedWidget; if (widget && !widget.viewModel) { await Event.toPromise(widget.onDidChangeViewModel); } @@ -318,7 +410,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { // -- Element Selection ---------------------------------------------- - private async _attachElementDataToChat(elementData: IElementData, model: IBrowserViewModel) { + private async _attachElementDataToChat(elementData: IElementData, model: IBrowserViewModel): Promise<boolean> { const bounds = elementData.bounds; const toAttach: IChatRequestVariableEntry[] = []; @@ -349,7 +441,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { }) : undefined; - toAttach.push({ + const elementEntry: IChatRequestVariableEntry = { id: 'element-' + Date.now(), name: displayNameShort, fullName: displayNameFull, @@ -364,13 +456,27 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { innerText, imageData: screenshotBuffer?.buffer, imageMimeType: screenshotBuffer ? 'image/jpeg' : undefined, - }); + }; + toAttach.push(elementEntry); if (!await this._confirmContentAttachmentRisk(elementData.url ?? model.url)) { - return; + return false; } - if (!await this._attachToChat(toAttach)) { - return; + const widget = await this._revealChatWidgetForAttachment(elementData.comment !== undefined); + if (!widget?.attachmentModel || this._disposedCommentModels.has(model)) { + return false; + } + widget.attachmentModel.addContext(...toAttach); + if (elementData.comment !== undefined && elementData.elementId) { + if (!this._insertElementCommentReference(widget, model, elementEntry, toAttach.map(attachment => attachment.id), elementData.elementId, elementData.comment)) { + widget.attachmentModel.delete(...toAttach.map(attachment => attachment.id)); + return false; + } + if (model.elementSelectionState.active) { + this._commentSessionsWithComments.add(model); + } else { + widget.focusInput(); + } } type IntegratedBrowserAddElementToChatAddedEvent = { @@ -386,6 +492,182 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { this.telemetryService.publicLog2<IntegratedBrowserAddElementToChatAddedEvent, IntegratedBrowserAddElementToChatAddedClassification>('integratedBrowser.addElementToChat.added', { attachImages }); + return true; + } + + private _insertElementCommentReference(widget: IChatWidget, browserModel: IBrowserViewModel, attachment: IChatRequestVariableEntry, attachmentIds: readonly string[], elementId: string, comment: string): boolean { + const inputModel = widget.inputEditor.getModel(); + const dynamicVariableModel = widget.getContrib<ChatDynamicVariableModel>(ChatDynamicVariableModel.ID); + if (!inputModel || !dynamicVariableModel) { + return false; + } + + const insertionPosition = widget.inputEditor.getPosition() ?? inputModel.getFullModelRange().getEndPosition(); + const prefix = insertionPosition.column > 1 ? '\n' : ''; + const suffix = insertionPosition.column < inputModel.getLineMaxColumn(insertionPosition.lineNumber) ? '\n' : ''; + const reference = `@${attachment.name}`; + const commentText = comment ? ` ${comment}` : ''; + const text = `${prefix}${reference}${commentText}${suffix}`; + if (!widget.inputEditor.executeEdits('browserElementComment', [{ range: Range.fromPositions(insertionPosition), text }])) { + return false; + } + const referenceStart = prefix ? { lineNumber: insertionPosition.lineNumber + 1, column: 1 } : insertionPosition; + const referenceRange = new Range(referenceStart.lineNumber, referenceStart.column, referenceStart.lineNumber, referenceStart.column + reference.length); + dynamicVariableModel.addReference(toAttachedContextDynamicVariable(attachment, referenceRange)); + widget.inputEditor.setPosition({ + lineNumber: referenceRange.endLineNumber, + column: referenceRange.endColumn + commentText.length + }); + + this._commentReferences.set(attachment.id, { elementId, attachmentIds, widget, browserModel }); + this._ensureCommentReferenceListeners(widget, dynamicVariableModel); + this._ensureCommentModelListeners(browserModel); + this._syncElementComments(browserModel); + return true; + } + + private _ensureCommentReferenceListeners(widget: IChatWidget, dynamicVariableModel: ChatDynamicVariableModel): void { + if (this._commentReferenceListeners.has(widget)) { + return; + } + const store = new DisposableStore(); + store.add(dynamicVariableModel.onDidChangeReferences(() => this._syncElementCommentsForWidget(widget))); + store.add(widget.inputEditor.onDidChangeModelContent(() => this._syncElementCommentsForWidget(widget))); + store.add(widget.attachmentModel.onDidChange(event => { + for (const [attachmentId, tracked] of this._commentReferences) { + if (tracked.widget === widget && event.deleted.includes(attachmentId)) { + this._removeElementCommentReference(tracked.browserModel, tracked.elementId); + } + } + })); + this._commentReferenceListeners.set(widget, store); + } + + private _ensureCommentModelListeners(browserModel: IBrowserViewModel): void { + if (this._commentModelListeners.has(browserModel)) { + return; + } + const store = new DisposableStore(); + store.add(browserModel.onDidRemoveElementComment(elementId => this._removeElementCommentReference(browserModel, elementId))); + store.add(browserModel.onDidNavigate(() => this._detachElementCommentReferences(browserModel))); + store.add(browserModel.onWillDispose(() => { + this._disposedCommentModels.add(browserModel); + this._detachElementCommentReferences(browserModel, false); + })); + this._commentModelListeners.set(browserModel, store); + } + + private _syncElementCommentsForWidget(widget: IChatWidget): void { + const browserModels = new Set<IBrowserViewModel>(); + for (const reference of this._commentReferences.values()) { + if (reference.widget === widget) { + browserModels.add(reference.browserModel); + } + } + for (const browserModel of browserModels) { + this._syncElementComments(browserModel); + } + } + + private _syncElementComments(browserModel: IBrowserViewModel, pendingCommentIdsToDiscard?: readonly string[]): void { + const comments: { elementId: string; body: string }[] = []; + for (const [attachmentId, tracked] of this._commentReferences) { + if (tracked.browserModel !== browserModel) { + continue; + } + const inputModel = tracked.widget.inputEditor.getModel(); + const dynamicVariableModel = tracked.widget.getContrib<ChatDynamicVariableModel>(ChatDynamicVariableModel.ID); + if (!inputModel || !dynamicVariableModel) { + continue; + } + const variable = dynamicVariableModel.variables.find(candidate => candidate.id === attachmentId && candidate.isAttachmentReference); + if (!variable) { + this._deleteCommentAttachments(attachmentId, tracked); + continue; + } + const line = inputModel.getLineContent(variable.range.endLineNumber); + comments.push({ + elementId: tracked.elementId, + body: line.slice(variable.range.endColumn - 1).trimStart() + }); + } + void browserModel.setElementComments({ comments, pendingCommentIdsToDiscard }); + } + + private _removeElementCommentReference(browserModel: IBrowserViewModel, elementId: string): void { + for (const [attachmentId, tracked] of this._commentReferences) { + if (tracked.browserModel !== browserModel || tracked.elementId !== elementId) { + continue; + } + const dynamicVariableModel = tracked.widget.getContrib<ChatDynamicVariableModel>(ChatDynamicVariableModel.ID); + const variable = dynamicVariableModel?.variables.find(candidate => candidate.id === attachmentId && candidate.isAttachmentReference); + const inputModel = tracked.widget.inputEditor.getModel(); + if (variable && inputModel) { + const lineNumber = variable.range.startLineNumber; + const lineRange = lineNumber < inputModel.getLineCount() + ? new Range(lineNumber, 1, lineNumber + 1, 1) + : lineNumber > 1 + ? new Range(lineNumber - 1, inputModel.getLineMaxColumn(lineNumber - 1), lineNumber, inputModel.getLineMaxColumn(lineNumber)) + : inputModel.getFullModelRange(); + tracked.widget.inputEditor.executeEdits('browserElementComment', [{ + range: lineRange, + text: '' + }]); + } + this._deleteCommentAttachments(attachmentId, tracked); + } + } + + private _detachElementCommentReferences(browserModel: IBrowserViewModel, syncComments = true): void { + this._commentSessionsWithComments.delete(browserModel); + const widgets = new Set<IChatWidget>(); + for (const [attachmentId, reference] of this._commentReferences) { + if (reference.browserModel === browserModel) { + widgets.add(reference.widget); + this._commentReferences.delete(attachmentId); + } + } + for (const widget of widgets) { + this._disposeCommentReferenceListenerIfUnused(widget); + } + this._commentModelListeners.deleteAndDispose(browserModel); + if (syncComments) { + void browserModel.setElementComments({ comments: [] }); + } + } + + private _focusChatInputForComments(browserModel: IBrowserViewModel): void { + for (const reference of this._commentReferences.values()) { + if (reference.browserModel === browserModel) { + reference.widget.focusInput(); + return; + } + } + } + + private _deleteCommentAttachments(elementAttachmentId: string, tracked: { attachmentIds: readonly string[]; widget: IChatWidget; browserModel: IBrowserViewModel }): void { + this._commentReferences.delete(elementAttachmentId); + tracked.widget.attachmentModel.delete(...tracked.attachmentIds); + this._disposeCommentReferenceListenerIfUnused(tracked.widget); + this._disposeCommentModelListenerIfUnused(tracked.browserModel); + } + + private _disposeCommentReferenceListenerIfUnused(widget: IChatWidget): void { + for (const reference of this._commentReferences.values()) { + if (reference.widget === widget) { + return; + } + } + this._commentReferenceListeners.deleteAndDispose(widget); + } + + private _disposeCommentModelListenerIfUnused(browserModel: IBrowserViewModel): void { + for (const reference of this._commentReferences.values()) { + if (reference.browserModel === browserModel) { + return; + } + } + this._commentModelListeners.deleteAndDispose(browserModel); } // -- Console Logs --------------------------------------------------- @@ -582,7 +864,7 @@ class AddElementToChatAction extends Action2 { icon: Codicon.inspect, f1: true, precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), ChatContextKeys.enabled), - toggled: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, + toggled: CONTEXT_BROWSER_ELEMENT_SELECTION_MODE.isEqualTo(BrowserElementSelectionMode.Select), menu: { id: MenuId.BrowserChatActionsMenu, group: '1_element', @@ -593,19 +875,82 @@ class AddElementToChatAction extends Action2 { weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over terminal primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyC, args: { highlightFocusedElement: true }, - }, { - when: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyCode.Escape }] }); } - async run(accessor: ServicesAccessor, argument?: IBrowserElementSelectionOptions | BrowserEditor): Promise<void> { + run(accessor: ServicesAccessor, argument?: IBrowserElementSelectionOptions | BrowserEditor): void { const browserEditor = argument instanceof BrowserEditor ? argument : accessor.get(IEditorService).activeEditorPane; if (browserEditor instanceof BrowserEditor) { browserEditor.ensureBrowserFocus(); - void browserEditor.model?.toggleElementSelection(undefined, argument instanceof BrowserEditor ? undefined : argument); + const model = browserEditor.model; + if (model) { + const options = argument instanceof BrowserEditor ? undefined : argument; + const isActiveMode = model.elementSelectionState.active && model.elementSelectionState.options.mode !== BrowserElementSelectionMode.Comment; + void model.toggleElementSelection(!isActiveMode, { ...options, continuous: false, mode: BrowserElementSelectionMode.Select }); + } + } + } +} + +class AddElementCommentToChatAction extends Action2 { + static readonly ID = BrowserViewCommandId.AddElementCommentToChat; + + constructor() { + super({ + id: AddElementCommentToChatAction.ID, + title: localize2('browser.addElementCommentToChatAction', 'Comment on Elements'), + category: BrowserCategory, + icon: Codicon.comment, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), ChatContextKeys.enabled), + toggled: CONTEXT_BROWSER_ELEMENT_SELECTION_MODE.isEqualTo(BrowserElementSelectionMode.Comment), + menu: { + id: MenuId.BrowserChatActionsMenu, + group: '1_element', + order: 2, + when: ChatContextKeys.enabled + }, + keybinding: [{ + weight: KeybindingWeight.WorkbenchContrib + 50, + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyC, + args: { continuous: true, mode: BrowserElementSelectionMode.Comment, highlightFocusedElement: true } + }], + }); + } + + run(accessor: ServicesAccessor, argument?: IBrowserElementSelectionOptions | BrowserEditor): void { + const browserEditor = argument instanceof BrowserEditor ? argument : accessor.get(IEditorService).activeEditorPane; + if (browserEditor instanceof BrowserEditor) { + browserEditor.ensureBrowserFocus(); + const options = argument instanceof BrowserEditor ? undefined : argument; + const model = browserEditor.model; + if (model) { + const isActiveMode = model.elementSelectionState.active && model.elementSelectionState.options.mode === BrowserElementSelectionMode.Comment; + void model.toggleElementSelection(!isActiveMode, { ...options, continuous: true, mode: BrowserElementSelectionMode.Comment }); + } + } + } +} + +class StopElementSelectionAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.browser.stopElementSelection', + title: localize2('browser.stopElementSelectionAction', 'Stop Element Selection'), + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, ContextKeyExpr.has(CONTEXT_BROWSER_ELEMENT_SELECTION_MODE.key)), + keybinding: { + when: ContextKeyExpr.has(CONTEXT_BROWSER_ELEMENT_SELECTION_MODE.key), + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.Escape + } + }); + } + + run(accessor: ServicesAccessor): void { + const browserEditor = accessor.get(IEditorService).activeEditorPane; + if (browserEditor instanceof BrowserEditor) { + void browserEditor.model?.toggleElementSelection(false); } } } @@ -623,8 +968,8 @@ class AddConsoleLogsToChatAction extends Action2 { precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), ChatContextKeys.enabled), menu: { id: MenuId.BrowserChatActionsMenu, - group: '1_element', - order: 2, + group: '2_logs', + order: 1, when: ChatContextKeys.enabled } }); @@ -650,7 +995,7 @@ class AddScreenshotToChatAction extends Action2 { precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), ChatContextKeys.enabled), menu: { id: MenuId.BrowserChatActionsMenu, - group: '2_screenshots', + group: '3_screenshots', order: 1, when: ChatContextKeys.enabled } @@ -678,7 +1023,7 @@ class AddAreaScreenshotToChatAction extends Action2 { toggled: CONTEXT_BROWSER_AREA_SELECTION_ACTIVE, menu: { id: MenuId.BrowserChatActionsMenu, - group: '2_screenshots', + group: '3_screenshots', order: 2, when: ChatContextKeys.enabled } @@ -706,7 +1051,7 @@ class AddFullPageScreenshotToChatAction extends Action2 { precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), ChatContextKeys.enabled, enabledSetting), menu: { id: MenuId.BrowserChatActionsMenu, - group: '2_screenshots', + group: '3_screenshots', order: 3, when: ContextKeyExpr.and(ChatContextKeys.enabled, enabledSetting) } @@ -721,6 +1066,8 @@ class AddFullPageScreenshotToChatAction extends Action2 { } registerAction2(AddElementToChatAction); +registerAction2(AddElementCommentToChatAction); +registerAction2(StopElementSelectionAction); registerAction2(AddConsoleLogsToChatAction); registerAction2(AddScreenshotToChatAction); registerAction2(AddAreaScreenshotToChatAction); @@ -735,7 +1082,10 @@ MenuRegistry.appendMenuItem(MenuId.BrowserActionsToolbar, { group: BrowserActionGroup.Tools, order: 1, when: ChatContextKeys.enabled, - isSplitButton: true + isSplitButton: { + togglePrimaryAction: true, + primaryActionIds: [AddElementToChatAction.ID, AddElementCommentToChatAction.ID] + } }); Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({ diff --git a/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts b/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts index cb192663bb7..c25f4cb432f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts @@ -33,13 +33,22 @@ const OVERLAY_DEFINITIONS: ReadonlyArray<{ className: string; type: BrowserOverl { className: 'context-view', type: BrowserOverlayType.Unknown } ]; -// Transparent full-screen layers that context menus and action widgets render to capture clicks. -// They sit in higher z-index stacking contexts above other UI, but are not tracked overlays, -// so hit-testing must skip them to find the overlay actually painted underneath. -const CONTEXT_VIEW_BLOCKER_CLASSES = ['context-view-block', 'context-view-pointerBlock']; +const HIT_TEST_EXCLUDED_CLASSES = [ + // Transparent full-screen layers that context menus and action widgets render to capture clicks. + // They sit in higher z-index stacking contexts above other UI, but are not tracked overlays, + // so hit-testing must skip them to find the overlay actually painted underneath. + 'context-view-block', + 'context-view-pointerBlock', -function isContextViewBlocker(element: Element): boolean { - return CONTEXT_VIEW_BLOCKER_CLASSES.some(className => element.classList.contains(className)); + // Webview overlay elements exist in their own DOM structure and are positioned dynamically, + // so they interfere with hit-testing because they are not descendants of the tracked overlay. + // Ignore them and depend on the element the webview is anchored to for overlay detection. + 'webview', + 'webview-overlay-content' +]; + +function isExcludedFromOverlayHitTest(element: Element): boolean { + return HIT_TEST_EXCLUDED_CLASSES.some(className => element.classList.contains(className)); } export const IBrowserOverlayManager = createDecorator<IBrowserOverlayManager>('browserOverlayManager'); @@ -293,14 +302,14 @@ export class BrowserOverlayManager extends Disposable implements IBrowserOverlay // overlay state change, which can fire frequently, so favor it whenever the // topmost hit is a real element we care about. const elementAtPoint = root.elementFromPoint(clientX, clientY); - if (elementAtPoint && !isContextViewBlocker(elementAtPoint)) { + if (elementAtPoint && !isExcludedFromOverlayHitTest(elementAtPoint)) { return elementAtPoint; } - // Slow path: the topmost hit is a transparent context-view blocker (or there - // was no hit). Walk the full front-to-back hit list and return the first - // element that is not a blocker, i.e. the overlay actually painted beneath it. + // Slow path: the topmost hit is an excluded overlay (or there was no hit). + // Walk the full front-to-back hit list and return the first element + // that is not excluded, i.e. the overlay actually painted beneath it. return root.elementsFromPoint(clientX, clientY) - .find(el => !isContextViewBlocker(el)) ?? null; + .find(el => !isExcludedFromOverlayHitTest(el)) ?? null; }; const elementAtPoint = topmostAt(this.targetWindow.document); diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/overlayManager.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/overlayManager.test.ts index 6d8decc9a51..28e7cc9305b 100644 --- a/src/vs/workbench/contrib/browserView/test/electron-browser/overlayManager.test.ts +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/overlayManager.test.ts @@ -62,6 +62,29 @@ suite('BrowserOverlayManager', () => { assert.deepStrictEqual(overlays, []); }); + test('detects an overlay beneath detached webview content', () => { + const browserContainer = addElement('browser-container', { + position: 'absolute', left: '0px', top: '0px', width: '300px', height: '300px' + }); + const contextView = addElement('context-view', { + position: 'fixed', left: '0px', top: '0px', width: '200px', height: '200px' + }); + addElement('overlay-anchor', { + position: 'absolute', left: '0px', top: '0px', width: '200px', height: '200px' + }, contextView); + + const overlayContent = addElement('webview-overlay-content', { + position: 'fixed', left: '0px', top: '0px', width: '200px', height: '200px', zIndex: '1' + }); + addElement('webview', { + width: '100%', height: '100%' + }, overlayContent); + + const overlays = manager.getOverlappingOverlays(browserContainer); + + assert.deepStrictEqual(overlays.map(o => o.type), [BrowserOverlayType.Unknown]); + }); + // Regression test for #321088: a context menu (e.g. the "Add Models" // dropdown) renders a full-screen `.context-view-block` inside `.context-view` // that stacks above an already-open modal. The block isn't a tracked overlay diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index b0f1d9d3494..8bdd38909e1 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -82,7 +82,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.agentHostApprovalsPicker', 'When an agent session exposes approval presets, use Tab to reach the Approvals picker and choose how it handles workspace access, commands, and the internet.')); } content.push(localize('chat.requestHistory', 'In the input box, use up and down arrows to navigate your request history. Edit input and use enter or the submit button to run a new request.')); - content.push(localize('chat.vscodePet', 'Type /vscode-pet to show or hide the VS Code pet above the input. Drag it horizontally to reposition it, or use Tab to focus it and the left and right arrow keys to move it. Press Enter or Space to show it some love.')); + content.push(localize('chat.vscodePet', 'Type /vscode-pet to show or hide the VS Code pet above the input. Drag it horizontally to reposition it, or use Tab to focus it and the left and right arrow keys to move it. Press Enter or Space to show it some love. Open its context menu{0} (for example Shift+F10), use the up and down arrow keys to choose Go on the Run, Come Back, Stable Colors, or Insiders Colors, and press Enter to activate the choice.', '<keybinding:editor.action.showContextMenu>')); if (supportsFileReferences) { content.push(localize('chat.attachments.inlineReferences', 'To mention an attached context item at a specific position without removing it from the attached context, type # or @ and select the attachment from the suggestions.')); content.push(localize('chat.attachments.inlineReferenceHover', 'To inspect an inline attachment reference, place the cursor on it and invoke Show or Focus Hover{0}. Image references include a preview, while file and folder references include their path.', '<keybinding:editor.action.showHover>')); @@ -94,6 +94,9 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.voiceInputMode.segmented', 'When the segmented voice input control is enabled, the input toolbar offers Dictation, Voice Mode, and, in manual Voice Mode, a Start or Stop Listening button. Stopping listening sends the completed turn. Each button can be focused and activated with Enter or Space.')); content.push(localize('chat.voiceInputMode.holdToTalk', 'In manual Voice Mode, the Start or Stop Listening button toggles listening when tapped, or you can press and hold it to talk and release to send. You can also hold the Voice Mode: Hold to Talk keybinding{0} to talk and release to send; this interrupts the assistant to barge in.', '<keybinding:workbench.action.chat.voiceInputMode.holdToTalk>')); content.push(localize('chat.voiceMode.introduction', 'The first time Voice Mode starts, an introduction appears above the input box. Tab to reach it, then use the arrow keys to move between the available voices; Enter or Space plays a voice and keeps it for future conversations. Its description also contains two links: Settings, which opens the Voice Mode settings, and How It Responds, which opens a file for customizing what the agent says back. Voice Mode stays connected but does not listen while the introduction is open. Press Escape, or activate the Close button, to dismiss it and return to the input box.')); + if (type === 'agentView') { + content.push(localize('chat.voiceInputMode.agentProgress', 'When the experimental agents.voice.agentProgress setting is enabled, Voice Mode Agent requests may speak brief progress updates while investigating, planning, editing, validating, or recovering from a problem.')); + } content.push(localize('chat.inspectResponse', 'In the input box, inspect the last response in the accessible view{0}. Thinking content is included in order by default.', '<keybinding:editor.action.accessibleView>')); content.push(localize('chat.inspectResponseThinkingToggle', 'To include or exclude thinking content in the accessible view, run the Toggle Thinking Content in Accessible View command from the Command Palette.')); content.push(localize('chat.completedResponseDisclosure', 'When completed response collapsing is enabled, the final response remains visible while earlier work is collapsed. Use Tab to focus the work disclosure and press Enter or Space to show or hide that work.')); diff --git a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts index f0f6e223177..d4c5fcebfc1 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts @@ -207,7 +207,7 @@ export async function collectAgentHostDebugLogs( } } - // 5. Copilot SDK process logs under ~/.copilot/logs do not include the + // 5. Copilot SDK process logs under <COPILOT_HOME>/logs do not include the // session id in the filename, but relevant entries include it in the content. const rawSessionId = getCopilotCliSessionRawId(activeSession?.resource); if (rawSessionId) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts index bd2a7b9defe..27a22293686 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts @@ -6,13 +6,15 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { IAgentHostByokLmHandler, - IByokLmChatMessage, IByokLmChatRequest, IByokLmChatResult, + IByokLmInputItem, IByokLmModelInfo, - IByokLmToolCall, + IByokLmOutputItem, + IByokLmReasoningItem, } from '../../../../../../platform/agentHost/common/agentHostByokLm.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { @@ -23,6 +25,10 @@ import { ILanguageModelsService, } from '../../../common/languageModels.js'; +const STATEFUL_MARKER_MIME_TYPE = 'stateful_marker'; +const USAGE_MIME_TYPE = 'usage'; +const REASONING_METADATA_PREFIX = 'vscode-reasoning-metadata:'; + /** * Renderer-side {@link IAgentHostByokLmHandler}. Services BYOK chat requests * forwarded by the node agent host's OpenAI proxy by calling the VS Code LM @@ -55,50 +61,72 @@ export class AgentHostByokLmHandler extends Disposable implements IAgentHostByok async chat(request: IByokLmChatRequest, token: CancellationToken): Promise<IByokLmChatResult> { const modelIdentifier = this._resolveModelIdentifier(request.vendor, request.modelId); if (!modelIdentifier) { - return { content: '', error: `No BYOK model found for ${request.vendor}/${request.modelId}` }; + return { output: [], error: `No BYOK model found for ${request.vendor}/${request.modelId}` }; } - const messages = request.messages.map(message => this._toChatMessage(message)); + const messages = this._toChatMessages(request); const tools = request.tools?.length ? request.tools.map(tool => ({ name: tool.name, description: tool.description ?? '', - inputSchema: tool.parametersSchema, + inputSchema: tool.type === 'function' + ? tool.parametersSchema + : { type: 'object', properties: { input: { type: 'string' } }, required: ['input'] }, })) : undefined; const options: ILanguageModelChatRequestOptions = { modelOptions: request.modelOptions, + includeEncryptedThinking: true, + ...(request.reasoningEffort ? { configuration: { reasoningEffort: request.reasoningEffort } } : {}), ...(tools ? { tools } : {}), }; try { const response = await this._languageModelsService.sendChatRequest(modelIdentifier, undefined, messages, options, token); - let content = ''; - const toolCalls: IByokLmToolCall[] = []; + const output: IByokLmOutputItem[] = []; + const customToolNames = new Set(request.tools?.filter(tool => tool.type === 'custom').map(tool => tool.name)); + let responseId: string | undefined; + let usage: IByokLmChatResult['usage']; const streaming = (async () => { for await (const part of response.stream) { const parts = Array.isArray(part) ? part : [part]; for (const p of parts) { if (p.type === 'text') { - content += p.value; + this._appendTextOutput(output, p.value); + } else if (p.type === 'thinking') { + this._appendReasoningOutput(output, p); } else if (p.type === 'tool_use') { - toolCalls.push({ - id: p.toolCallId, - name: p.name, - argumentsJson: JSON.stringify(p.parameters ?? {}), - }); + if (customToolNames.has(p.name)) { + output.push({ + type: 'custom_tool_call', + callId: p.toolCallId, + name: p.name, + input: this._customToolInput(p.parameters), + }); + } else { + output.push({ + type: 'function_call', + callId: p.toolCallId, + name: p.name, + argumentsJson: JSON.stringify(p.parameters ?? {}), + }); + } + } else if (p.type === 'data' && p.mimeType === STATEFUL_MARKER_MIME_TYPE) { + responseId = this._decodeStatefulMarker(p.data, request.modelId); + } else if (p.type === 'data' && p.mimeType === USAGE_MIME_TYPE) { + usage = this._decodeUsage(p.data); } } } })(); await Promise.all([response.result, streaming]); - return { content, toolCalls: toolCalls.length ? toolCalls : undefined }; + return { output, responseId, usage }; } catch (err) { const message = err instanceof Error ? err.message : String(err); this._logService.warn(`[AgentHostByokLmHandler] chat request failed for ${request.vendor}/${request.modelId}: ${message}`); - return { content: '', error: message }; + return { output: [], error: message }; } } @@ -109,6 +137,9 @@ export class AgentHostByokLmHandler extends Disposable implements IAgentHostByok // Only genuine renderer BYOK models — exclude agent-host copies, which // carry a `targetChatSessionType` and would otherwise re-enter the bridge. if (metadata?.isBYOK && !metadata.targetChatSessionType) { + const reasoningEffortSchema = metadata.configurationSchema?.properties?.reasoningEffort; + const supportedReasoningEfforts = reasoningEffortSchema?.enum?.filter((value): value is string => typeof value === 'string'); + const defaultReasoningEffort = typeof reasoningEffortSchema?.default === 'string' ? reasoningEffortSchema.default : undefined; models.push({ vendor: metadata.vendor, id: metadata.id, @@ -116,6 +147,8 @@ export class AgentHostByokLmHandler extends Disposable implements IAgentHostByok modelIdentifier: identifier, maxContextWindowTokens: metadata.maxInputTokens + metadata.maxOutputTokens, supportsVision: !!metadata.capabilities?.vision, + ...(supportedReasoningEfforts?.length ? { supportedReasoningEfforts } : {}), + ...(defaultReasoningEffort !== undefined ? { defaultReasoningEffort } : {}), }); } } @@ -136,45 +169,211 @@ export class AgentHostByokLmHandler extends Disposable implements IAgentHostByok return undefined; } - private _toChatMessage(message: IByokLmChatMessage): IChatMessage { - // A tool-result message carries its payload solely in the `tool_result` - // part — the renderer/extension turns that into a wire `role: 'tool'` - // message on its own. Emit it and return early so the shared text branch - // below doesn't also inject a duplicate `role: 'user'` copy of the output. - // Tool messages that lack a `toolCallId` fall through to the plain text branch. - if (message.role === 'tool' && message.toolCallId) { - return { - role: ChatMessageRole.User, - content: [{ type: 'tool_result', toolCallId: message.toolCallId, value: [{ type: 'text', value: message.content }] }], - }; + private _toChatMessages(request: IByokLmChatRequest): IChatMessage[] { + const messages: IChatMessage[] = []; + if (request.previousResponseId) { + messages.push({ + role: ChatMessageRole.Assistant, + content: [{ + type: 'data', + mimeType: STATEFUL_MARKER_MIME_TYPE, + data: VSBuffer.fromString(`${request.modelId}\\${request.previousResponseId}`), + }], + }); } - - const content: IChatMessagePart[] = []; - if (message.content) { - content.push({ type: 'text', value: message.content }); + if (request.instructions) { + messages.push({ + role: ChatMessageRole.System, + content: [{ type: 'text', value: request.instructions }], + }); } - - if (message.role === 'assistant' && message.toolCalls?.length) { - for (const call of message.toolCalls) { - content.push({ - type: 'tool_use', - name: call.name, - toolCallId: call.id, - parameters: this._safeParseJson(call.argumentsJson), - }); + for (const item of request.input) { + const message = this._toChatMessage(item); + const previous = messages.at(-1); + if (message.role === ChatMessageRole.Assistant && previous?.role === ChatMessageRole.Assistant) { + messages[messages.length - 1] = { + ...previous, + content: [...previous.content, ...message.content], + }; + } else { + messages.push(message); } } - - return { role: this._toChatRole(message.role), content }; + return messages; } - private _toChatRole(role: IByokLmChatMessage['role']): ChatMessageRole { + private _toChatMessage(item: IByokLmInputItem): IChatMessage { + switch (item.type) { + case 'message': + return { + role: this._toChatRole(item.role), + content: [{ type: 'text', value: item.content.map(part => part.text).join('') }], + }; + case 'reasoning': { + return { + role: ChatMessageRole.Assistant, + content: [{ + type: 'thinking', + value: item.summary, + id: item.id, + metadata: { + ...item.metadata, + ...(item.encryptedContent ? this._decodeReasoningMetadata(item.encryptedContent) : {}), + }, + }], + }; + } + case 'function_call': + return { + role: ChatMessageRole.Assistant, + content: [{ + type: 'tool_use', + name: item.name, + toolCallId: item.callId, + parameters: this._safeParseJson(item.argumentsJson), + }], + }; + case 'custom_tool_call': + return { + role: ChatMessageRole.Assistant, + content: [{ + type: 'tool_use', + name: item.name, + toolCallId: item.callId, + parameters: { input: item.input }, + }], + }; + case 'function_call_output': + case 'custom_tool_call_output': + return { + role: ChatMessageRole.User, + content: [{ + type: 'tool_result', + toolCallId: item.callId, + value: [{ type: 'text', value: item.output }], + }], + }; + } + } + + private _appendTextOutput(output: IByokLmOutputItem[], value: string): void { + const previous = output.at(-1); + if (previous?.type === 'message') { + output[output.length - 1] = { + ...previous, + content: [{ type: 'text', text: previous.content.map(part => part.text).join('') + value }], + }; + } else { + output.push({ type: 'message', content: [{ type: 'text', text: value }] }); + } + } + + private _appendReasoningOutput(output: IByokLmOutputItem[], part: Extract<IChatMessagePart, { type: 'thinking' }>): void { + if (part.metadata?.vscode_reasoning_done === true) { + return; + } + const summary = Array.isArray(part.value) ? part.value : [part.value]; + const encryptedContent = this._encodeReasoningMetadata(part.metadata); + const reasoning: IByokLmReasoningItem = { + type: 'reasoning', + id: part.id, + summary, + encryptedContent, + metadata: part.metadata, + }; + const previous = output.at(-1); + if (previous?.type === 'reasoning' && previous.id === reasoning.id) { + output[output.length - 1] = { + ...previous, + summary: [...previous.summary, ...reasoning.summary], + encryptedContent: reasoning.encryptedContent ?? previous.encryptedContent, + metadata: previous.metadata || reasoning.metadata ? { ...previous.metadata, ...reasoning.metadata } : undefined, + }; + } else { + output.push(reasoning); + } + } + + private _encodeReasoningMetadata(metadata: Readonly<Record<string, unknown>> | undefined): string | undefined { + const encryptedContent = this._stringMetadata(metadata, 'encrypted_content') ?? this._stringMetadata(metadata, 'encrypted'); + if (encryptedContent) { + return encryptedContent; + } + const continuationMetadata = { + ...(this._stringMetadata(metadata, 'signature') ? { signature: this._stringMetadata(metadata, 'signature') } : {}), + ...(this._stringMetadata(metadata, '_completeThinking') ? { _completeThinking: this._stringMetadata(metadata, '_completeThinking') } : {}), + ...(this._stringMetadata(metadata, 'redactedData') ? { redactedData: this._stringMetadata(metadata, 'redactedData') } : {}), + }; + return Object.keys(continuationMetadata).length > 0 + ? `${REASONING_METADATA_PREFIX}${JSON.stringify(continuationMetadata)}` + : undefined; + } + + private _decodeReasoningMetadata(value: string): Record<string, unknown> { + if (!value.startsWith(REASONING_METADATA_PREFIX)) { + return { encrypted_content: value }; + } + const metadata = JSON.parse(value.slice(REASONING_METADATA_PREFIX.length)); + if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) { + throw new Error('Invalid Agent Host BYOK reasoning metadata'); + } + return metadata as Record<string, unknown>; + } + + private _customToolInput(parameters: unknown): string { + if (typeof parameters === 'object' && parameters !== null) { + const input = Object.getOwnPropertyDescriptor(parameters, 'input')?.value; + if (typeof input === 'string') { + return input; + } + } + return typeof parameters === 'string' ? parameters : JSON.stringify(parameters ?? {}); + } + + private _decodeStatefulMarker(data: VSBuffer, expectedModelId: string): string | undefined { + const decoded = data.toString(); + const separator = decoded.indexOf('\\'); + if (separator === -1 || decoded.slice(0, separator) !== expectedModelId) { + return undefined; + } + return decoded.slice(separator + 1) || undefined; + } + + private _decodeUsage(data: VSBuffer): IByokLmChatResult['usage'] { + try { + const value = JSON.parse(data.toString()) as Record<string, unknown>; + const outputDetails = typeof value.completion_tokens_details === 'object' && value.completion_tokens_details !== null + ? value.completion_tokens_details as Record<string, unknown> + : undefined; + return { + inputTokens: this._numberProperty(value, 'prompt_tokens'), + outputTokens: this._numberProperty(value, 'completion_tokens'), + reasoningTokens: outputDetails ? this._numberProperty(outputDetails, 'reasoning_tokens') : undefined, + }; + } catch { + return undefined; + } + } + + private _numberProperty(value: Record<string, unknown>, key: string): number | undefined { + const property = value[key]; + return typeof property === 'number' ? property : undefined; + } + + private _stringMetadata(metadata: Readonly<Record<string, unknown>> | undefined, key: string): string | undefined { + const value = metadata?.[key]; + return typeof value === 'string' ? value : undefined; + } + + private _toChatRole(role: Extract<IByokLmInputItem, { type: 'message' }>['role']): ChatMessageRole { switch (role) { - case 'system': return ChatMessageRole.System; - case 'assistant': return ChatMessageRole.Assistant; + case 'system': + case 'developer': + return ChatMessageRole.System; + case 'assistant': + return ChatMessageRole.Assistant; case 'user': - case 'tool': - default: return ChatMessageRole.User; + return ChatMessageRole.User; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostPromptCacheNotification.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostPromptCacheNotification.ts index 1fe1763af12..49427d123f2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostPromptCacheNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostPromptCacheNotification.ts @@ -17,7 +17,6 @@ import { IWorkbenchAssignmentService } from '../../../../../services/assignment/ import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotificationService } from '../../widget/input/chatInputNotificationService.js'; const PROMPT_CACHE_EXPIRATION_NOTIFICATION_EXPERIMENT = 'copilotchat.promptCacheExpirationNotification'; -const PROMPT_CACHE_EXPIRATION_GRACE_PERIOD_MS = 10 * 60 * 1000; const PROMPT_CACHE_EXPIRATION_DISABLED_STORAGE_KEY = 'chat.promptCacheExpirationNotification.disabled'; const DISABLE_PROMPT_CACHE_EXPIRATION_NOTIFICATION_COMMAND = 'workbench.action.chat.disablePromptCacheExpirationNotification'; const PROMPT_CACHE_EXPIRATION_LEARN_MORE_URL = 'https://code.visualstudio.com/docs/agents/agent-troubleshooting/cache-explorer#_why-prompt-caching-matters'; @@ -74,9 +73,9 @@ export class AgentHostPromptCacheNotification extends Disposable { this._cacheExpirations.set(sessionResource, promptCache.cacheExpiresAt); const expirationTime = Date.parse(promptCache.cacheExpiresAt); if (Number.isFinite(expirationTime)) { - const remainingTime = expirationTime + PROMPT_CACHE_EXPIRATION_GRACE_PERIOD_MS - Date.now(); - if (remainingTime >= 0) { - expirationScheduler.schedule(remainingTime + 1); + const remainingTime = expirationTime - Date.now(); + if (remainingTime > 0) { + expirationScheduler.schedule(remainingTime); } } } else { @@ -99,7 +98,7 @@ export class AgentHostPromptCacheNotification extends Disposable { const cacheExpiresAt = this._cacheExpirations.get(sessionResource); const expirationTime = cacheExpiresAt ? Date.parse(cacheExpiresAt) : Number.NaN; const disabled = this._storageService.getBoolean(PROMPT_CACHE_EXPIRATION_DISABLED_STORAGE_KEY, StorageScope.PROFILE, false); - if (!this._experimentEnabled || disabled || !Number.isFinite(expirationTime) || Date.now() <= expirationTime + PROMPT_CACHE_EXPIRATION_GRACE_PERIOD_MS) { + if (!this._experimentEnabled || disabled || !Number.isFinite(expirationTime) || Date.now() < expirationTime) { this._notificationService.deleteNotification(this._notificationId(sessionResource)); return; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 7f64369dba4..f50aedcba5a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -99,7 +99,7 @@ import { buildHostLocalEventsPath } from '../../copilotCliEventsUri.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; import { IAgentHostImportConversationStore } from './agentHostImportConversationStore.js'; -import { activeTurnToProgress, BOOLEAN_TRUE_OPTION_ID, completedToolCallToEditParts, completedToolCallToSerialized, convertProtocolAnswers, convertProtocolPlanReviewResult, createInputRequestCarousel, createInputRequestPlanReview, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContent, getUrlInputRequestPresentation, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rewriteAgentHostLinkTarget, stringOrMarkdownToString, systemNotificationToChatPart, toolCallAuthenticationServer, toolCallConfirmationMessages, toolCallStateToInvocation, toolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToAutoModeResolution, usageInfoToChatUsage, usageInfoToQuotas, type IAgentHostToolInvocationOptions, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; +import { activeTurnToProgress, BOOLEAN_TRUE_OPTION_ID, completedToolCallToEditParts, completedToolCallToSerialized, containsAutomaticReplyAnswer, convertProtocolAnswers, convertProtocolPlanReviewResult, createInputRequestCarousel, createInputRequestPlanReview, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContent, getUrlInputRequestPresentation, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rewriteAgentHostLinkTarget, stringOrMarkdownToString, systemNotificationToChatPart, toolCallAuthenticationServer, toolCallConfirmationMessages, toolCallStateToInvocation, toolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, turnsToHistory, updateRunningToolSpecificData, updateStreamingToolInvocation, usageInfoToAutoModeResolution, usageInfoToChatUsage, usageInfoToQuotas, type IAgentHostToolInvocationOptions, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; import { resolveMcpServerAuthentication, agentHostMcpServerId } from './agentHostAuth.js'; export { toolDataToDefinition }; @@ -2367,6 +2367,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC && lastUsage.completionTokens === usage.completionTokens && lastUsage.outputBuffer === usage.outputBuffer && lastUsage.copilotCredits === usage.copilotCredits + // The session total moves independently of this turn's own cost — + // it also covers work billed while no turn was active — so it has + // to be compared, or a session-cost update would be dropped here. + && lastUsage.sessionCopilotCredits === usage.sessionCopilotCredits && equals(lastUsage.promptTokenDetails, usage.promptTokenDetails)) { return; } @@ -2833,7 +2837,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC && previousStatus !== ToolCallStatus.PendingConfirmation; previousStatus = status; - if (enteringConfirmation) { + if (status === ToolCallStatus.Streaming) { + updateStreamingToolInvocation(invocation, tc, this._config.connectionAuthority); + } else if (enteringConfirmation) { if (!IChatToolInvocation.isComplete(invocation)) { const prepared = toolCallStateToPreparedInvocation(tc, opts.backendSession, this._config.connectionAuthority, opts.sessionResource.authority); invocation.requestConfirmation(prepared); @@ -2869,7 +2875,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if ((status === ToolCallStatus.Completed || status === ToolCallStatus.Cancelled) && !IChatToolInvocation.isComplete(invocation)) { // Detach live non-PTY output before completion synchronously rebuilds the terminal subpart. - this._ensureLeftStreaming(invocation, tc, opts); + if (status === ToolCallStatus.Completed) { + this._ensureLeftStreaming(invocation, tc, opts); + } this._reviveTerminalIfNeeded(invocation, tc, opts.backendSession, outputTerminalAttachment); const fileEdits = finalizeToolInvocation(invocation, tc, opts.backendSession, this._config.connectionAuthority); if (fileEdits.length > 0) { @@ -3167,11 +3175,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // nobody will consume. In the normal path we complete the call // ourselves first, so `invokeTool` has already settled and this // cancellation is a harmless no-op. + if (state.type === IChatToolInvocation.StateKind.Streaming) { + const fileEdits = finalizeToolInvocation(invocation, tc, opts.backendSession, this._config.connectionAuthority); + if (fileEdits.length > 0) { + opts.onFileEdits?.(tc, fileEdits); + } + } if (cts.token.isCancellationRequested) { return; } cts.cancel(); - if (!invoked && tc.status === ToolCallStatus.Cancelled) { + if (!invoked && tc.status === ToolCallStatus.Cancelled && state.type !== IChatToolInvocation.StateKind.Streaming) { // No `invokeTool` is listening to the CTS — transition // the invocation to `Cancelled` ourselves. invocation.cancelFromStreaming(ToolConfirmKind.Skipped); @@ -3285,6 +3299,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC carousel.data = carouselAnswers ?? {}; carousel.isUsed = true; carousel.answeredExternally = part.response === ChatInputResponseKind.Accept && !carouselAnswers; + carousel.autoReply = containsAutomaticReplyAnswer(protocolAnswers); + carousel.answeredExternally ||= carousel.autoReply; carousel.draftAnswers = undefined; carousel.draftCurrentIndex = undefined; carousel.draftCollapsed = undefined; @@ -4278,28 +4294,29 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // (each streaming delta), not just draft changes. let lastRemoteDraft = syncedDraft; let appliedRemoteDraft: Message | undefined; + const syncDraft = (state: IChatModelInputState | undefined): void => { + if (state?.origin === ChatInputStateOrigin.Remote) { + return; + } + const draft = this._inputStateToDraft(sessionResource, state); + if (equals(syncedDraft, draft)) { + return; + } + if (appliedRemoteDraft && sameDraftUserContent(draft, appliedRemoteDraft)) { + syncedDraft = draft; + return; + } + appliedRemoteDraft = undefined; + syncedDraft = draft; + + this._config.connection.dispatch(chatKey, { + type: ActionType.ChatDraftChanged, + draft, + }); + }; store.add(autorun(reader => { const state = inputModel.state.read(reader); - delayer.trigger(() => { - if (state?.origin === ChatInputStateOrigin.Remote) { - return; - } - const draft = this._inputStateToDraft(sessionResource, state); - if (equals(syncedDraft, draft)) { - return; - } - if (appliedRemoteDraft && sameDraftUserContent(draft, appliedRemoteDraft)) { - syncedDraft = draft; - return; - } - appliedRemoteDraft = undefined; - syncedDraft = draft; - - this._config.connection.dispatch(chatKey, { - type: ActionType.ChatDraftChanged, - draft, - }); - }).catch(() => { /* delayer disposed */ }); + delayer.trigger(() => syncDraft(state)).catch(() => { /* delayer disposed */ }); })); store.add(chatSubscription.onDidChange(() => { const remoteDraft = readRemoteDraft(); @@ -4319,6 +4336,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC appliedRemoteDraft = remoteDraft; this._applyRemoteDraft(inputModel, sessionResource, remoteDraft); })); + store.add(toDisposable(() => { + delayer.cancel(); + syncDraft(inputModel.state.get()); + })); } /** Applies a remote draft without replacing local input state the protocol does not carry. */ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index fcf690f499b..5e2aa0c344f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -19,10 +19,12 @@ import { readToolCallMeta } from '../../../../../../platform/agentHost/common/me import { getChatErrorDetailsFromMeta, IChatErrorContext } from '../../../common/chatErrorMessages.js'; import { AGENT_HOST_SCHEME, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentHostElementAttachmentDisplayKind, getElementAttachmentCorrelationId } from '../../../../../../platform/agentHost/common/meta/agentElementAttachments.js'; +import { AgentHostAutoReplyAnswer } from '../../../../../../platform/agentHost/common/agentHostSchema.js'; import { getAgentFeedbackAttachmentMetadata, isAgentFeedbackAnnotationsAttachment, isAgentFeedbackAttachment } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; import { getBrowserViewAttachmentMetadata, isBrowserViewAttachment } from '../../../../../../platform/agentHost/common/meta/browserViewAttachments.js'; import { isViewUnreviewedCommentsTool, isAddCommentTool } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; import { isCreateChatTool, isCreateSessionTool, isSendMessageTool, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../../../../../platform/agentHost/common/openSessionLink.js'; +import { parsePartialToolInputForDisplay } from '../../../../../../platform/agentHost/common/partialToolInput.js'; import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { normalizeFileEdit } from '../../../../../../platform/agentHost/common/fileEditDiff.js'; import product from '../../../../../../platform/product/common/product.js'; @@ -47,6 +49,22 @@ import { restoreChatReferenceVariableEntryFromAttachment } from './agentHostChat export const BOOLEAN_TRUE_OPTION_ID = 'true'; export const BOOLEAN_FALSE_OPTION_ID = 'false'; +const agentHostAskUserToolNames = new Set(['ask_user', 'AskUserQuestion', 'request_user_input']); + +function isAgentHostAskUserTool(toolName: string): boolean { + return agentHostAskUserToolNames.has(toolName); +} + +function shouldHideCompletedAgentHostAskUserTool(toolCall: ToolCallState): boolean { + if (!isAgentHostAskUserTool(toolCall.toolName)) { + return false; + } + if (toolCall.status === ToolCallStatus.Completed) { + return toolCall.success; + } + return toolCall.status === ToolCallStatus.Cancelled && toolCall.reason === ToolCallCancellationReason.Skipped; +} + export interface IAgentHostToolInvocationOptions { readonly currentClientId: string; readonly cancelOtherClientToolCall: (toolCall: ToolCallState) => void; @@ -110,6 +128,14 @@ export function convertProtocolAnswers(raw: Record<string, ChatInputAnswer> | un return Object.keys(answers).length > 0 ? answers : undefined; } +export function containsAutomaticReplyAnswer(raw: Record<string, ChatInputAnswer> | undefined): boolean { + return Object.values(raw ?? {}).some(answer => + answer.state === ChatInputAnswerState.Submitted + && answer.value.kind === ChatInputAnswerValueKind.Text + && answer.value.value === AgentHostAutoReplyAnswer + ); +} + function getPlanReviewAction(planReview: IAgentHostPlanReview, actionId: string | undefined) { return actionId ? planReview.actions.find(action => action.id === actionId) : undefined; } @@ -221,7 +247,7 @@ export function createInputRequestCarousel(inputReq: ChatInputRequest, connectio }); } - return new ChatQuestionCarouselData( + const carousel = new ChatQuestionCarouselData( questions, true, inputReq.id, @@ -229,6 +255,8 @@ export function createInputRequestCarousel(inputReq: ChatInputRequest, connectio undefined, inputReq.message ? rawMarkdownToString(inputReq.message, connectionAuthority) : undefined, ); + carousel.answerPresentation = 'conversation'; + return carousel; } export function createInputRequestPlanReview(inputReq: ChatInputRequest, planReview: IAgentHostPlanReview): ChatPlanReviewData { @@ -297,7 +325,8 @@ export function inputRequestResponsePartToProgress(part: InputRequestResponsePar : undefined; carousel.data = answers ?? {}; carousel.isUsed = true; - carousel.answeredExternally = part.response === ChatInputResponseKind.Accept && !answers; + carousel.autoReply = containsAutomaticReplyAnswer(inputReq.answers); + carousel.answeredExternally = part.response === ChatInputResponseKind.Accept && (carousel.autoReply || !answers); return carousel; } @@ -523,10 +552,18 @@ export function usageInfoToChatUsage(usage: UsageInfo | undefined): IChatUsage | promptTokens: usage?.inputTokens ?? 0, completionTokens: usage?.outputTokens ?? 0, copilotCredits: getCopilotCredits(usage), + sessionCopilotCredits: getSessionCopilotCredits(usage), promptTokenDetails: contextAttributionToPromptTokenDetails(usage), }; } +function getSessionCopilotCredits(usage: UsageInfo | undefined): number | undefined { + const sessionTotalNanoAiu = readUsageInfoMeta(usage).copilotUsage?.sessionTotalNanoAiu; + return typeof sessionTotalNanoAiu === 'number' && sessionTotalNanoAiu >= 0 + ? sessionTotalNanoAiu / 1_000_000_000 + : undefined; +} + function getCopilotCredits(usage: UsageInfo | undefined): number | undefined { const meta = readUsageInfoMeta(usage); const totalNanoAiu = meta?.copilotUsage?.totalNanoAiu; @@ -1655,7 +1692,7 @@ export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentIn pastTenseMessage: isTerminal ? undefined : pastTenseMsg, isConfirmed: completedToolCallConfirmedReason(tc), isComplete: true, - presentation: undefined, + presentation: shouldHideCompletedAgentHostAskUserTool(tc) ? ToolInvocationPresentation.HiddenAfterComplete : undefined, subAgentInvocationId: subAgentInvocationId, toolSpecificData, resultDetails, @@ -2160,6 +2197,10 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI const invocation = new ChatToolInvocation(undefined, toolData, tc.toolCallId, subAgentInvocationId, undefined); invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? tc.displayName; + if (isAgentHostAskUserTool(tc.toolName)) { + invocation.invocationMessage = localize('agentHost.askUser.waiting', "Waiting for answer..."); + invocation.presentation = ToolInvocationPresentation.HiddenAfterComplete; + } if (tc.status === ToolCallStatus.AuthRequired) { invocation.setAuthenticationRequired(toolCallAuthenticationServer(tc, mcpServerAuthority)); } @@ -2263,12 +2304,39 @@ export function toolCallStateToStreamingInvocation(tc: ToolCallState, subAgentIn }, subagentInvocationId: subAgentInvocationId, }); + updateStreamingToolInvocation(invocation, tc, connectionAuthority ?? ''); + if (isAgentHostAskUserTool(tc.toolName)) { + invocation.invocationMessage = localize('agentHost.askUser.asking', "Asking a question..."); + invocation.presentation = ToolInvocationPresentation.HiddenAfterComplete; + } if (sessionResource && isSubagentTool(tc)) { invocation.toolSpecificData = toolCallStateToInvocation(tc, subAgentInvocationId, sessionResource, connectionAuthority ?? '', mcpServerAuthority).toolSpecificData; } return invocation; } +function getStreamingToolInputForDisplay(tc: ToolCallState): unknown | undefined { + if (tc.status !== ToolCallStatus.Streaming || !tc.partialInput) { + return undefined; + } + return parsePartialToolInputForDisplay(tc.partialInput) ?? tc.partialInput; +} + +export function updateStreamingToolInvocation(existing: ChatToolInvocation, tc: ToolCallState, connectionAuthority: string): unknown | undefined { + if (tc.status !== ToolCallStatus.Streaming) { + return undefined; + } + const partialInput = getStreamingToolInputForDisplay(tc); + if (partialInput !== undefined) { + existing.updatePartialInput(partialInput); + } + const invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority); + if (invocationMessage) { + existing.updateStreamingMessage(invocationMessage); + } + return partialInput; +} + /** * Extracts the {@link IPreparedToolInvocation} display fields for a tool-call * state, reusing {@link toolCallStateToInvocation} so the confirmation, @@ -2299,6 +2367,10 @@ export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: return; } existing.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? existing.invocationMessage; + if (isAgentHostAskUserTool(tc.toolName)) { + existing.invocationMessage = localize('agentHost.askUser.waiting', "Waiting for answer..."); + existing.presentation = ToolInvocationPresentation.HiddenAfterComplete; + } if (isAddCommentTool(tc.toolName)) { existing.invocationMessage = addCommentReference(tc) ?? existing.invocationMessage; } @@ -2414,6 +2486,9 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolC if (isAddCommentTool(tc.toolName)) { invocation.invocationMessage = addCommentReference(tc) ?? invocation.invocationMessage; } + if (isAgentHostAskUserTool(tc.toolName)) { + invocation.presentation = ToolInvocationPresentation.HiddenAfterComplete; + } // Check for subagent content — set toolSpecificData so the UI renders a subagent widget if (isCompleted) { @@ -2496,6 +2571,11 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolC const errorMessage = isCompleted ? tc.error?.message : (isCancelled ? tc.reasonMessage : undefined); const errorString = typeof errorMessage === 'string' ? errorMessage : errorMessage?.markdown; const fileEdits = isCompleted ? fileEditsToExternalEdits(tc) : []; + if (isAgentHostAskUserTool(tc.toolName)) { + invocation.presentation = shouldHideCompletedAgentHostAskUserTool(tc) + ? ToolInvocationPresentation.HiddenAfterComplete + : undefined; + } // Hide the tool widget when file edits are shown separately via onFileEdits if (fileEdits.length > 0 && !isFailure) { @@ -2518,7 +2598,13 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolC const result: IToolResult | undefined = isFailure || resultDetails ? { content: [], toolResultError: isFailure ? errorString : undefined, toolResultDetails: resultDetails } : undefined; - invocation.didExecuteTool(result); + const cancelledFromStreaming = isCancelled && invocation.cancelFromStreaming( + tc.reason === ToolCallCancellationReason.Skipped ? ToolConfirmKind.Skipped : ToolConfirmKind.Denied, + tc.reasonMessage ? stringOrMarkdownToString(tc.reasonMessage, connectionAuthority) : undefined, + ); + if (!cancelledFromStreaming) { + invocation.didExecuteTool(result); + } return fileEdits; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 494ccc827bd..39cd98139ff 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -12,7 +12,7 @@ import { $, append, EventHelper, addDisposableListener, EventType, getWindow, hi import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { localize } from '../../../../../nls.js'; -import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection, isAgentSessionShowLess, isAgentSessionShowMore } from './agentSessionsModel.js'; +import { AgentSessionSection, getAgentSessionPullRequestContextValue, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection, isAgentSessionShowLess, isAgentSessionShowMore } from './agentSessionsModel.js'; import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionSectionLabels, AgentSessionShowLessRenderer, AgentSessionShowMoreRenderer, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; import { AgentSessionsGrouping, AgentSessionsSorting } from './agentSessionsFilter.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; @@ -713,6 +713,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo contextOverlay.push([ChatContextKeys.isPinnedAgentSession.key, session.isPinned()]); contextOverlay.push([ChatContextKeys.isReadAgentSession.key, session.isRead()]); contextOverlay.push([ChatContextKeys.agentSessionType.key, session.providerType]); + contextOverlay.push([ChatContextKeys.agentSessionPullRequest.key, getAgentSessionPullRequestContextValue(session)]); const menu = this.menuService.createMenu(MenuId.AgentSessionsContext, this.contextKeyService.createOverlay(contextOverlay)); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 39aa70190d3..ab7208e3fc0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -159,6 +159,44 @@ export function isLocalAgentSessionItem(session: IAgentSession): boolean { return session.providerType === AgentSessionProviders.Local; } +/** + * Resolves the pull request associated with an agent session from its provider metadata, + * preferring an explicit `pullRequestUrl` and falling back to `pullRequestNumber` combined + * with `owner`/`name`. Returns `undefined` when the session has no associated pull request. + */ +export function getAgentSessionPullRequestUri(session: Pick<IAgentSession, 'metadata'>): URI | undefined { + const metadata = session.metadata; + if (!metadata) { + return undefined; + } + + const url = metadata.pullRequestUrl; + if (typeof url === 'string' && url) { + try { + return URI.parse(url); + } catch { + // Fall through to the number based lookup below. + } + } + + const prNumber = metadata.pullRequestNumber; + const owner = metadata.owner; + const name = metadata.name; + if (typeof prNumber === 'number' && typeof owner === 'string' && owner && typeof name === 'string' && name) { + return URI.parse(`https://github.com/${owner}/${name}/pull/${prNumber}`); + } + + return undefined; +} + +/** + * The value for the `chatSessionPullRequest` context key for a session. Never returns an + * "unknown" value: callers here always have the session's metadata in hand. + */ +export function getAgentSessionPullRequestContextValue(session: Pick<IAgentSession, 'metadata'>): 'available' | 'none' { + return getAgentSessionPullRequestUri(session) ? 'available' : 'none'; +} + export function isAgentHostAgentSessionItem(session: IAgentSession): boolean { return isAgentHostTarget(session.providerType); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index c4c1f09d6a0..8ddfd55044a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -2463,6 +2463,10 @@ pane is first mounted. View switches inside the modal are not animated. */ position: relative; } +.monaco-workbench .monaco-dialog-box.automation-dialog:focus:not(:focus-visible) { + outline: none; +} + /* * Float the close-X over the titlebar so the title text sits flush * with the top edge of the modal (QuickInput-style). Without this, @@ -2825,6 +2829,10 @@ pane is first mounted. View switches inside the modal are not animated. */ border-top: 1px solid var(--vscode-widget-border, transparent); } +.automation-form-row.automation-form-checkbox-row > .monaco-checkbox { + margin-right: 0; +} + /* * Lay the Schedule / Time / Day controls along a single horizontal axis. * Each control lives in its own `.automation-form-schedule-group` @@ -2992,10 +3000,6 @@ pane is first mounted. View switches inside the modal are not animated. */ * `.new-chat-bottom-container` chip vocabulary (see * `sessions/contrib/chat/browser/media/chatWidget.css` lines 178-209): * compact label text and codicons, icon-foreground color, no border. - * The `|` divider between the Folder chip and the branch slot - * uses the same `box-shadow: -5px 0 0 -4px var(--vscode-editorWidget-border)` - * trick the new-session row uses to draw a 1px vertical separator - * without adding a DOM element. */ .automation-form-prompt-host .chat-secondary-toolbar .automation-form-isolation-group { display: inline-flex; @@ -3062,13 +3066,6 @@ pane is first mounted. View switches inside the modal are not animated. */ white-space: nowrap; } -/* Mirror the new-session `|` divider between repo-config chips: - * `box-shadow: -5px 0 0 -4px var(--vscode-editorWidget-border)` paints - * a 1px-wide bar in the gap to the chip's left. */ -.automation-form-prompt-host .automation-form-isolation-group .automation-form-branch-picker-slot { - box-shadow: -5px 0 0 -4px var(--vscode-editorWidget-border); -} - .automation-form-prompt-host .chat-secondary-toolbar .automation-form-harness-chip { display: inline-flex; align-items: center; diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts index d56e9f82c38..78675ef803e 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts @@ -11,6 +11,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { IRange, Range } from '../../../../../editor/common/core/range.js'; import { IDecorationOptions } from '../../../../../editor/common/editorCommon.js'; import { Command, isLocation } from '../../../../../editor/common/languages.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { IModelContentChange } from '../../../../../editor/common/model/mirrorTextModel.js'; import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -48,7 +50,7 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC return ChatDynamicVariableModel.ID; } - private decorationData: { id: string; text: string }[] = []; + private decorationData: { id: string; text: string; rangeOffset: number }[] = []; private readonly _editorListener = this._register(new MutableDisposable()); @@ -96,12 +98,23 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC const newText = model.getValueInRange(newRange); if (newText !== data.text) { + const replacement = e.changes.find(change => + change.rangeOffset <= data.rangeOffset + && change.rangeOffset + change.rangeLength >= data.rangeOffset + data.text.length + ); + const preservedRange = replacement && this.findReferenceRangeInReplacement(model, e.changes, replacement, data); + if (preservedRange) { + didChange = true; + return { ...ref, range: preservedRange }; + } - this.widget.inputEditor.executeEdits(this.id, [{ - range: newRange, - text: '', - }]); - this.widget.refreshParsedInput(); + if (!replacement) { + this.widget.inputEditor.executeEdits(this.id, [{ + range: newRange, + text: '', + }]); + this.widget.refreshParsedInput(); + } removed.push(ref); return null; @@ -129,6 +142,40 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC }); } + private findReferenceRangeInReplacement( + model: ITextModel, + changes: readonly IModelContentChange[], + replacement: IModelContentChange, + data: { text: string; rangeOffset: number } + ): Range | undefined { + if (!data.text) { + return undefined; + } + + const previousRelativeOffset = data.rangeOffset - replacement.rangeOffset; + let matchOffset = replacement.text.indexOf(data.text); + let closestMatchOffset = matchOffset; + while (matchOffset !== -1) { + if (Math.abs(matchOffset - previousRelativeOffset) < Math.abs(closestMatchOffset - previousRelativeOffset)) { + closestMatchOffset = matchOffset; + } + matchOffset = replacement.text.indexOf(data.text, matchOffset + data.text.length); + } + + if (closestMatchOffset === -1) { + return undefined; + } + + const precedingChangesDelta = changes.reduce((delta, change) => + change.rangeOffset < replacement.rangeOffset ? delta + change.text.length - change.rangeLength : delta, 0); + const startOffset = replacement.rangeOffset + precedingChangesDelta + closestMatchOffset; + const range = Range.fromPositions( + model.getPositionAt(startOffset), + model.getPositionAt(startOffset + data.text.length) + ); + return model.getValueInRange(range) === data.text ? range : undefined; + } + getInputState(contrib: Record<string, unknown>): void { contrib[ChatDynamicVariableModel.ID] = [...this._variables]; } @@ -183,9 +230,11 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC this._variables = validVariables.slice(0, decorationIds.length); this.decorationData = []; for (let i = 0; i < decorationIds.length; i++) { + const range = this._variables[i].range; this.decorationData.push({ id: decorationIds[i], - text: model.getValueInRange(this._variables[i].range) + text: model.getValueInRange(range), + rangeOffset: model.getOffsetAt({ lineNumber: range.startLineNumber, column: range.startColumn }) }); } } diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 78309b6a26e..9221889ef7a 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -14,6 +14,7 @@ import '../../../../platform/agentHost/browser/agentHostEnablementService.js'; import '../../../../platform/agentHost/common/agentHostStarter.config.contribution.js'; import { AgentHostAhpJsonlLoggingSettingId, AgentHostSdkSandboxEnabledSettingId, ClaudePreferAgentHostAgentsSettingId, ClaudePreferAgentHostEditorSettingId, CodexPreferAgentHostEditorSettingId } from '../../../../platform/agentHost/common/agentService.js'; import { AgentHostCopilotSdkLogLevelSettingId, AgentHostCustomTerminalToolEnabledSettingId, AgentHostModelCapabilityOverridesSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, AgentHostToolSearchEnabledSettingId, copilotSdkLogLevelSettingValues } from '../../../../platform/agentHost/common/copilotCliConfig.js'; +import { DEFAULT_LOCAL_TRANSCRIPTION_MODEL } from '../../../../platform/localTranscription/common/localTranscription.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; import { COPILOT_ALLOWED_MCP_SERVERS_KEY, COPILOT_DENIED_MCP_SERVERS_KEY, COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_MARKETPLACES_KEY, managedModelValue, managedSettingValue } from '../../../../platform/policy/common/copilotManagedSettings.js'; @@ -282,19 +283,19 @@ configurationRegistry.registerConfiguration({ 'dictation.model': { type: 'string', enum: [ - 'nemotron-speech-streaming-en-0.6b', + DEFAULT_LOCAL_TRANSCRIPTION_MODEL, 'mai', ], enumItemLabels: [ - nls.localize('dictation.model.nemotronStreaming.label', "Nemotron Streaming (English) — On-Device"), + nls.localize('dictation.model.nemotronStreaming.label', "Nemotron 3.5 ASR (Multilingual) — On-Device"), nls.localize('dictation.model.mai.label', "MAI — Cloud"), ], markdownEnumDescriptions: [ - nls.localize('dictation.model.nemotronStreaming', "NVIDIA Nemotron streaming RNN-T (English), run on-device through Microsoft Foundry Local. Works offline; no audio leaves the device. Downloaded on first use and cached on disk."), + nls.localize('dictation.model.nemotronStreaming', "NVIDIA Nemotron 3.5 multilingual streaming RNN-T, run on-device through Microsoft Foundry Local. Works offline; no audio leaves the device. Automatic language selection follows the Voice Mode language setting and system or browser locale, with model detection as a fallback. Downloaded on first use and cached on disk."), nls.localize('dictation.model.mai', "Cloud transcription through the same Microsoft AI voice service used by Voice Mode. Requires a network connection and GitHub sign-in; audio is streamed to the service."), ], markdownDescription: nls.localize('dictation.model', "The model used for dictation. On-device models download on first use and run locally through Microsoft Foundry Local; the cloud option streams audio to the Microsoft AI voice service."), - default: 'nemotron-speech-streaming-en-0.6b', + default: DEFAULT_LOCAL_TRANSCRIPTION_MODEL, tags: ['experimental'], experiment: { mode: 'auto' } }, @@ -2269,9 +2270,10 @@ Registry.as<IConfigurationMigrationRegistry>(Extensions.ConfigurationMigration). 'onnx-community/whisper-base', 'onnx-community/whisper-small', 'onnx-community/nemotron-3.5-asr-streaming-0.6b-onnx-int4', + 'nemotron-speech-streaming-en-0.6b', ]; const migrated = (typeof value === 'string' && legacyModelIds.includes(value)) - ? 'nemotron-speech-streaming-en-0.6b' + ? DEFAULT_LOCAL_TRANSCRIPTION_MODEL : value; const pairs: ConfigurationKeyValuePairs = [['chat.speechToText.model', { value: undefined }]]; // Never clobber an explicitly configured new key (e.g. after settings @@ -2282,6 +2284,16 @@ Registry.as<IConfigurationMigrationRegistry>(Extensions.ConfigurationMigration). return pairs; } }, + { + // Existing users may have the former English-only default stored + // explicitly. Move them to the multilingual replacement as well. + key: 'dictation.model', + migrateFn: value => ({ + value: value === 'nemotron-speech-streaming-en-0.6b' + ? DEFAULT_LOCAL_TRANSCRIPTION_MODEL + : value + }) + }, { // Dictation settings were regrouped under the top-level `dictation.*` // namespace (they govern dictation across chat, editor, and terminal). diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 82f0d2a7621..043ff60687c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -339,6 +339,7 @@ export type IChatWidgetViewContext = IChatViewViewContext | IChatResourceViewCon export interface IChatAcceptInputOptions { noCommandDetection?: boolean; isVoiceInput?: boolean; + isVoiceModeInput?: boolean; enableImplicitContext?: boolean; // defaults to true // Whether to store the input to history. This defaults to 'true' if the input // box's current content is being accepted, or 'false' if a specific input @@ -356,6 +357,13 @@ export interface IChatAcceptInputOptions { preserveFocus?: boolean; /** Keeps the input box contents and attachments after submitting a programmatic query, and omits them from it. The query itself is sent as-is: prompt slash commands in it are not resolved. */ preserveInput?: boolean; + /** + * Called once the request has been handed over to the chat service, i.e. it was either sent + * right away or queued because another request is in progress. Callers that must not wait for + * a queued request to actually run should use this instead of awaiting `acceptInput`, which + * only resolves once the request has been sent. + */ + onRequestAccepted?: () => void; } export interface IChatWidgetViewModelChangeEvent { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostChatDebugProvider.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostChatDebugProvider.ts index 0ac3c5a15fd..ba8d8f5cbd7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostChatDebugProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostChatDebugProvider.ts @@ -24,13 +24,13 @@ import { IPathService } from '../../../../services/path/common/pathService.js'; import { ChatDebugHookResult, ChatDebugLogLevel, IChatDebugCustomizationLogEntry, IChatDebugEvent, IChatDebugFileEntry, IChatDebugLogProvider, IChatDebugMessageSection, IChatDebugModelTurnEvent, IChatDebugResolvedEventContent, IChatDebugService } from '../../common/chatDebugService.js'; import { IAgentHostCustomizationService } from '../agentSessions/agentHost/agentHostCustomizationService.js'; import { AgentHostAgentDebugLogEnabledSettingId, AgentHostAgentDebugLogMaxEventsSettingId } from '../../common/promptSyntax/promptTypes.js'; -import { COPILOT_CLI_EH_SCHEME, COPILOT_CLI_LOCAL_AH_SCHEME, getCopilotCliSessionRawId, resolveEventsUri } from '../copilotCliEventsUri.js'; +import { buildLocalSessionStateUri, COPILOT_CLI_EH_SCHEME, COPILOT_CLI_LOCAL_AH_SCHEME, getCopilotCliSessionRawId, resolveEventsUri } from '../copilotCliEventsUri.js'; import { AgentHostCustomizationRecorder, AgentHostUsageRecorder, buildAgentHostCustomizationsUri, buildAgentHostUsageUri, readAgentHostCustomizationsSnapshot, readAgentHostUsageRecords, type IAgentHostUsageRecord } from './agentHostUsageSidecar.js'; /** * One record in an Agent Host Copilot CLI `events.jsonl` stream. The CLI * writes a line-delimited JSON log of the session under - * `~/.copilot/session-state/<id>/events.jsonl`. Every record shares the same + * `<COPILOT_HOME>/session-state/<id>/events.jsonl`. Every record shares the same * envelope. Note that `parentId` is **not** a logical parent: the SDK defines * it as the chronologically preceding event in the session (a flat linked chain * over every event), not the user → model-turn → tool-call hierarchy. The @@ -549,7 +549,7 @@ export class AgentHostChatDebugContribution extends Disposable implements IWorkb private async _discoverLocalSessions(token: CancellationToken): Promise<{ uri: URI; title?: string }[]> { const userHome = this._pathService.userHome({ preferLocal: true }); - const sessionStateDir = joinPath(userHome, '.copilot', 'session-state'); + const sessionStateDir = buildLocalSessionStateUri(userHome); let stat; try { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostLogSources.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostLogSources.ts index 32681c3674a..754311cc8a3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostLogSources.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/agentHostLogSources.ts @@ -49,7 +49,7 @@ export const enum AgentHostLogSourceKind { Events = 'events', /** The client-side AHP JSON-RPC wire log (`<logsHome>/ahp/*.jsonl`). */ WireLog = 'wire', - /** The Copilot SDK process logs under `~/.copilot/logs`. */ + /** The Copilot SDK process logs under `<COPILOT_HOME>/logs`. */ CliLog = 'cliLog', /** A VS Code output channel (agent host process, renderer, shared). */ ProcessChannel = 'processChannel', @@ -216,7 +216,7 @@ export async function enumerateAgentHostLogSources( }); } - // 5. Copilot SDK process logs (~/.copilot/logs), content-filtered lazily by session id. + // 5. Copilot SDK process logs (<COPILOT_HOME>/logs), content-filtered lazily by session id. const rawSessionId = getCopilotCliSessionRawId(sessionResource); if (rawSessionId) { const copilotLogsDir = isLocal diff --git a/src/vs/workbench/contrib/chat/browser/chatPetService.ts b/src/vs/workbench/contrib/chat/browser/chatPetService.ts index d659721eead..33dbef51721 100644 --- a/src/vs/workbench/contrib/chat/browser/chatPetService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatPetService.ts @@ -8,16 +8,45 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../base/common/observable.js'; import { localize } from '../../../../nls.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import product from '../../../../platform/product/common/product.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; const CHAT_PET_ENABLED_STORAGE_KEY = 'chat.vscodePet.enabled'; +const CHAT_PET_VARIANT_STORAGE_KEY = 'chat.vscodePet.variant'; +const CHAT_PET_ON_THE_RUN_STORAGE_KEY = 'chat.vscodePet.onTheRun'; + +export type ChatPetVariant = 'stable' | 'insiders'; + +type ChatPetEnablementEvent = { + enabled: boolean; + source: 'startup' | 'change'; +}; + +type ChatPetEnablementClassification = { + owner: 'justschen'; + comment: 'Tracks VS Code pet enablement so adoption can be measured.'; + enabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the VS Code pet is enabled.' }; + source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the state was observed at startup or changed while VS Code was running.' }; +}; + +export function getChatPetVariant(configuredVariant: string | undefined, productQuality: string | undefined): ChatPetVariant { + if (configuredVariant === 'stable' || configuredVariant === 'insiders') { + return configuredVariant; + } + return productQuality === 'stable' ? 'stable' : 'insiders'; +} export const IChatPetService = createDecorator<IChatPetService>('chatPetService'); export interface IChatPetService { readonly _serviceBrand: undefined; readonly enabled: IObservable<boolean>; + readonly variant: IObservable<ChatPetVariant>; + readonly onTheRun: IObservable<boolean>; toggle(): boolean; + setVariant(variant: ChatPetVariant): void; + setOnTheRun(onTheRun: boolean): void; } export class ChatPetService extends Disposable implements IChatPetService { @@ -26,27 +55,71 @@ export class ChatPetService extends Disposable implements IChatPetService { private readonly _enabled; readonly enabled: IObservable<boolean>; + private readonly _variant; + readonly variant: IObservable<ChatPetVariant>; + private readonly _onTheRun; + readonly onTheRun: IObservable<boolean>; constructor( @IStorageService private readonly storageService: IStorageService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); this._enabled = observableValue(this, this.storageService.getBoolean(CHAT_PET_ENABLED_STORAGE_KEY, StorageScope.APPLICATION, false)); this.enabled = this._enabled; + this._variant = observableValue(this, getChatPetVariant(this.storageService.get(CHAT_PET_VARIANT_STORAGE_KEY, StorageScope.APPLICATION), product.quality)); + this.variant = this._variant; + this._onTheRun = observableValue(this, this.storageService.getBoolean(CHAT_PET_ON_THE_RUN_STORAGE_KEY, StorageScope.APPLICATION, false)); + this.onTheRun = this._onTheRun; this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, CHAT_PET_ENABLED_STORAGE_KEY, this._store)(() => { - this._enabled.set(this.storageService.getBoolean(CHAT_PET_ENABLED_STORAGE_KEY, StorageScope.APPLICATION, false), undefined); + this._setEnabled(this.storageService.getBoolean(CHAT_PET_ENABLED_STORAGE_KEY, StorageScope.APPLICATION, false)); })); + this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, CHAT_PET_VARIANT_STORAGE_KEY, this._store)(() => { + this._variant.set(getChatPetVariant(this.storageService.get(CHAT_PET_VARIANT_STORAGE_KEY, StorageScope.APPLICATION), product.quality), undefined); + })); + this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, CHAT_PET_ON_THE_RUN_STORAGE_KEY, this._store)(() => { + this._onTheRun.set(this.storageService.getBoolean(CHAT_PET_ON_THE_RUN_STORAGE_KEY, StorageScope.APPLICATION, false), undefined); + })); + this._logEnablement(this._enabled.get(), 'startup'); } toggle(): boolean { const enabled = !this._enabled.get(); - this._enabled.set(enabled, undefined); + this._setEnabled(enabled); this.storageService.store(CHAT_PET_ENABLED_STORAGE_KEY, enabled, StorageScope.APPLICATION, StorageTarget.USER); status(enabled ? localize('chatPet.enabled', "VS Code pet enabled. Click the pet to interact with it, or use the Left and Right Arrow keys to move it.") : localize('chatPet.disabled', "VS Code pet disabled")); return enabled; } + + private _setEnabled(enabled: boolean): void { + if (enabled === this._enabled.get()) { + return; + } + this._enabled.set(enabled, undefined); + this._logEnablement(enabled, 'change'); + } + + private _logEnablement(enabled: boolean, source: ChatPetEnablementEvent['source']): void { + this.telemetryService.publicLog2<ChatPetEnablementEvent, ChatPetEnablementClassification>('chatPetEnablement', { enabled, source }); + } + + setVariant(variant: ChatPetVariant): void { + this._variant.set(variant, undefined); + this.storageService.store(CHAT_PET_VARIANT_STORAGE_KEY, variant, StorageScope.APPLICATION, StorageTarget.USER); + status(variant === 'stable' + ? localize('chatPet.variant.stable', "VS Code pet changed to the Stable colors") + : localize('chatPet.variant.insiders', "VS Code pet changed to the Insiders colors")); + } + + setOnTheRun(onTheRun: boolean): void { + this._onTheRun.set(onTheRun, undefined); + this.storageService.store(CHAT_PET_ON_THE_RUN_STORAGE_KEY, onTheRun, StorageScope.APPLICATION, StorageTarget.USER); + status(onTheRun + ? localize('chatPet.onTheRun', "The VS Code pet is on the run. Click the pet to bring it back.") + : localize('chatPet.restored', "The VS Code pet is back")); + } } diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 0d6e5fd41d8..67f1cd5bb58 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -13,7 +13,7 @@ import { getSelectedModelIdentifier } from '../common/chatSelectedModel.js'; import { ChatAgentLocation, ChatConfiguration } from '../common/constants.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { localize } from '../../../../nls.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -767,10 +767,26 @@ export class ChatTipService extends Disposable implements IChatTipService { this._logService.debug('#ChatTips: tip excluded because thinking phrases setting was previously modified', tip.id); return false; } + if (!this._areTipCommandsRegistered(tip)) { + return false; + } this._logService.debug('#ChatTips: tip is eligible', tip.id); return true; } + private _areTipCommandsRegistered(tip: ITipDefinition): boolean { + const ctx: ITipBuildContext = { keybindingService: this._keybindingService, experimentalTipMessages: this._experimentalTipMessages }; + const rawMessage = tip.buildMessage(ctx); + const commandIds = extractCommandIds(rawMessage.value); + for (const commandId of commandIds) { + if (!CommandsRegistry.getCommand(commandId)) { + this._logService.debug('#ChatTips: tip excluded because command is not registered', tip.id, commandId); + return false; + } + } + return true; + } + private _isSettingModified(key: string): boolean { const inspected = this._configurationService.inspect(key); return inspected.userValue !== undefined diff --git a/src/vs/workbench/contrib/chat/browser/copilotCliEventsUri.ts b/src/vs/workbench/contrib/chat/browser/copilotCliEventsUri.ts index 2ca03c2d615..edb5f7f89ba 100644 --- a/src/vs/workbench/contrib/chat/browser/copilotCliEventsUri.ts +++ b/src/vs/workbench/contrib/chat/browser/copilotCliEventsUri.ts @@ -4,8 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { Schemas } from '../../../../base/common/network.js'; +import { env } from '../../../../base/common/process.js'; +import type { IProcessEnvironment } from '../../../../base/common/platform.js'; import { joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; +import { getCopilotHomePath } from '../../../../platform/agentHost/common/copilotHome.js'; import { parseRemoteAgentHostSessionTypeAuthority } from '../../../../platform/agentHost/common/agentHostSessionType.js'; import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; import { IRemoteAgentHostConnectionInfo } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; @@ -21,21 +24,28 @@ export const COPILOT_CLI_LOCAL_AH_SCHEME = `agent-host-${COPILOT_CLI_PROVIDER}`; export const COPILOT_CLI_EH_SCHEME = COPILOT_CLI_PROVIDER; /** - * Builds the local `events.jsonl` URI under `~/.copilot/session-state/<rawId>/`. + * Builds the local `events.jsonl` URI under `<COPILOT_HOME>/session-state/<rawId>/`. * * Used for both the local Agent Host Copilot CLI provider and the * extension-host Copilot CLI provider, which share the same on-disk layout * and the same chat session URI shape (`copilotcli:/<rawId>`). */ -export function buildLocalEventsUri(userHome: URI, rawSessionId: string): URI { - return joinPath(userHome, '.copilot', 'session-state', rawSessionId, 'events.jsonl'); +export function buildLocalEventsUri(userHome: URI, rawSessionId: string, environment: IProcessEnvironment = env): URI { + return joinPath(buildLocalCopilotHomeUri(userHome, environment), 'session-state', rawSessionId, 'events.jsonl'); } /** - * Builds the local `~/.copilot/logs` directory URI. + * Builds the local `<COPILOT_HOME>/logs` directory URI. */ -export function buildLocalCopilotLogsUri(userHome: URI): URI { - return joinPath(userHome, '.copilot', 'logs'); +export function buildLocalCopilotLogsUri(userHome: URI, environment: IProcessEnvironment = env): URI { + return joinPath(buildLocalCopilotHomeUri(userHome, environment), 'logs'); +} + +/** + * Builds the local `<COPILOT_HOME>/session-state` directory URI. + */ +export function buildLocalSessionStateUri(userHome: URI, environment: IProcessEnvironment = env): URI { + return joinPath(buildLocalCopilotHomeUri(userHome, environment), 'session-state'); } /** @@ -120,6 +130,7 @@ export function resolveEventsUri( sessionResource: URI | undefined, userHome: URI, getConnectionByAuthority: (authority: string) => IRemoteAgentHostConnectionInfo | undefined, + environment: IProcessEnvironment = env, ): ResolveEventsUriResult { if (!sessionResource) { return { kind: 'no-session' }; @@ -130,7 +141,7 @@ export function resolveEventsUri( } if (sessionResource.scheme === COPILOT_CLI_LOCAL_AH_SCHEME || sessionResource.scheme === COPILOT_CLI_EH_SCHEME) { - return { kind: 'ok', resource: buildLocalEventsUri(userHome, rawId) }; + return { kind: 'ok', resource: buildLocalEventsUri(userHome, rawId, environment) }; } const remoteAuthority = parseRemoteAuthorityFromScheme(sessionResource.scheme); @@ -174,8 +185,9 @@ export function buildHostLocalEventsPath( sessionResource: URI | undefined, userHome: URI, getConnectionByAuthority: (authority: string) => IRemoteAgentHostConnectionInfo | undefined, + environment: IProcessEnvironment = env, ): string | undefined { - const result = resolveEventsUri(sessionResource, userHome, getConnectionByAuthority); + const result = resolveEventsUri(sessionResource, userHome, getConnectionByAuthority, environment); if (result.kind !== 'ok') { return undefined; } @@ -188,3 +200,7 @@ export function buildHostLocalEventsPath( // injected path is usable by host-side tooling; POSIX paths are left as-is. return fromAgentHostUri(result.resource).path.replace(/^\/([a-zA-Z]:)/, '$1'); } + +function buildLocalCopilotHomeUri(userHome: URI, environment: IProcessEnvironment): URI { + return URI.file(getCopilotHomePath(userHome.fsPath, environment)); +} diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index c1f037db0b8..717c1897260 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -10,6 +10,8 @@ import { generateUuid } from '../../../../../base/common/uuid.js'; import { computeLevenshteinDistance } from '../../../../../base/common/diff/diff.js'; import { joinPath } from '../../../../../base/common/resources.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IAction, toAction } from '../../../../../base/common/actions.js'; import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; @@ -21,7 +23,7 @@ import { localize } from '../../../../../nls.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; -import { ILocalTranscriptionModelStatus, ILocalTranscriptionService, LocalTranscriptionModelState } from '../../../../../platform/localTranscription/common/localTranscription.js'; +import { DEFAULT_LOCAL_TRANSCRIPTION_MODEL, ILocalTranscriptionModelStatus, ILocalTranscriptionService, LocalTranscriptionModelState } from '../../../../../platform/localTranscription/common/localTranscription.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; import { IVoiceClientService, IVoiceSessionContext, IVoiceTranscription, IVoiceTurnConfig } from '../../common/voiceClient/voiceClientService.js'; @@ -32,9 +34,18 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatMessageRole, ILanguageModelsService } from '../../common/languageModels.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { createPcmCaptureNode } from '../pcmCaptureWorklet.js'; +import { resolveDictationLanguage } from './dictationLanguage.js'; export const IChatSpeechToTextService = createDecorator<IChatSpeechToTextService>('chatSpeechToTextService'); +/** + * Command that imports a locally supplied Foundry Local dictation model package + * into the model cache. Registered in the desktop layer + * (`installDictationModelAction.ts`); referenced here so a failed download in a + * registry-blocked environment can offer the offline install as a next step. + */ +export const INSTALL_DICTATION_MODEL_COMMAND_ID = 'workbench.action.chat.installDictationModel'; + function joinIncrementalDictationText(prefix: string, suffix: string): string { if (!prefix || !suffix) { return `${prefix}${suffix}`; @@ -157,7 +168,7 @@ const PCM_CAPTURE_CHUNK_SIZE = 4096; const ENABLED_SETTING = 'dictation.enabled'; /** * Selects the dictation model. On-device model ids (e.g. - * `nemotron-speech-streaming-en-0.6b`) run through {@link ILocalTranscriptionService}; + * `nemotron-3.5-asr-streaming-0.6b`) run through {@link ILocalTranscriptionService}; * the sentinel {@link DICTATION_MAI_MODEL_ID} routes to the cloud voice service instead. */ export const DICTATION_MODEL_SETTING = 'dictation.model'; @@ -567,6 +578,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo @INotificationService private readonly _notificationService: INotificationService, @IProgressService private readonly _progressService: IProgressService, @ILogService private readonly _logService: ILogService, + @ICommandService private readonly _commandService: ICommandService, @IContextKeyService contextKeyService: IContextKeyService, @IStorageService private readonly _storageService: IStorageService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @@ -816,7 +828,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo if (this._activeBackend === 'mai') { return this._startMaiSession(window); } - return this._startLocalSession(); + return this._startLocalSession(window); } /** @@ -1122,7 +1134,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo * Begin an on-device transcription session in the utility process and pipe * its interim/final results onto the shared cumulative-transcript surface. */ - private async _startLocalSession(): Promise<void> { + private async _startLocalSession(window: Window & typeof globalThis): Promise<void> { const local = this._localTranscription; this._localSessionDisposables.add(local.onDidTranscribe(result => { // The local service returns the full cumulative transcript each time. @@ -1130,7 +1142,11 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo })); const cacheDir = joinPath(this._environmentService.cacheHome, 'chatDictationModels').fsPath; const model = this._getModelId(); - await local.start({ cacheDir, model }); + const language = resolveDictationLanguage( + this._configurationService.getValue('agents.voice.language'), + window.navigator.language, + ); + await local.start({ cacheDir, model, language }); // The model loads in the utility process in the background (start() // returns immediately). On first use it may download hundreds of MB, so @@ -1211,7 +1227,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo } else if (status.state === LocalTranscriptionModelState.Error) { this._logModelPrepareTelemetry(status); this._setPreparingModel(false); - this._failSession('model', localize('chatStt.modelError', "On-device speech-to-text model failed to load: {0}", status.error ?? '')); + this._failModelSession(status); } } @@ -1286,12 +1302,38 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._downloadNotification = undefined; } + /** + * Handle a terminal model-preparation error. A download failure caused by a + * blocked/unreachable model registry (common on locked-down corporate + * networks) is recoverable by importing the model from a locally supplied + * package, so in that case the error surfaces an action that launches the + * offline install flow. Other failures show a plain error. + */ + private _failModelSession(status: ILocalTranscriptionModelStatus): void { + const canImport = this._localTranscription.isSupported + && (status.errorCode === 'network' || status.errorCode === 'notFound'); + if (!canImport) { + this._failSession('model', localize('chatStt.modelError', "On-device speech-to-text model failed to load: {0}", status.error ?? '')); + return; + } + // Name the specific model so users know exactly which package to obtain + // on a machine that can reach the download, then sideload via the command. + const message = localize('chatStt.modelErrorOffline', "Could not download the {0} speech-to-text model, which can happen on networks that block the model registry. You can install it from a downloaded package instead.", DEFAULT_LOCAL_TRANSCRIPTION_MODEL); + const importAction = toAction({ + id: INSTALL_DICTATION_MODEL_COMMAND_ID, + label: localize('chatStt.installFromPackage', "Install from Local Package..."), + run: () => this._commandService.executeCommand(INSTALL_DICTATION_MODEL_COMMAND_ID), + }); + this._failSession('model', message, importAction); + } + /** * Abort the active recording because of an unrecoverable error (e.g. the * model failed to download/load), surfacing a notification instead of - * silently returning an empty transcript. + * silently returning an empty transcript. An optional recovery action is + * attached to the notification when the failure is actionable. */ - private _failSession(errorCode: string, message: string): void { + private _failSession(errorCode: string, message: string, action?: IAction): void { if (this._state === ChatSpeechToTextState.Idle) { return; } @@ -1300,7 +1342,11 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._cancelBackend(); this._teardown(); this._setState(ChatSpeechToTextState.Idle); - this._notificationService.error(message); + if (action) { + this._notificationService.notify({ severity: Severity.Error, message, actions: { primary: [action] } }); + } else { + this._notificationService.error(message); + } } /** diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationLanguage.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationLanguage.ts new file mode 100644 index 00000000000..3b1180256ff --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationLanguage.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const NEMOTRON_LOCALES = new Set([ + 'ar-AR', 'bg-BG', 'cs-CZ', 'da-DK', 'de-DE', 'en-GB', 'en-US', 'es-ES', + 'es-US', 'et-EE', 'fi-FI', 'fr-CA', 'fr-FR', 'el-GR', 'he-IL', 'hi-IN', + 'hr-HR', 'hu-HU', 'it-IT', 'ja-JP', 'ko-KR', 'lt-LT', 'lv-LV', 'mt-MT', + 'nb-NO', 'nl-NL', 'nn-NO', 'pl-PL', 'pt-BR', 'pt-PT', 'ro-RO', 'ru-RU', + 'sk-SK', 'sl-SI', 'sv-SE', 'th-TH', 'tr-TR', 'uk-UA', 'vi-VN', 'zh-CN', +]); + +const NEMOTRON_DEFAULT_LOCALE_BY_LANGUAGE: Readonly<Record<string, string>> = { + ar: 'ar-AR', + bg: 'bg-BG', + cs: 'cs-CZ', + da: 'da-DK', + de: 'de-DE', + en: 'en-US', + es: 'es-US', + et: 'et-EE', + el: 'el-GR', + fi: 'fi-FI', + fr: 'fr-FR', + he: 'he-IL', + hi: 'hi-IN', + hr: 'hr-HR', + hu: 'hu-HU', + it: 'it-IT', + ja: 'ja-JP', + ko: 'ko-KR', + lt: 'lt-LT', + lv: 'lv-LV', + mt: 'mt-MT', + nb: 'nb-NO', + nl: 'nl-NL', + nn: 'nn-NO', + pl: 'pl-PL', + pt: 'pt-PT', + ro: 'ro-RO', + ru: 'ru-RU', + sk: 'sk-SK', + sl: 'sl-SI', + sv: 'sv-SE', + th: 'th-TH', + tr: 'tr-TR', + uk: 'uk-UA', + vi: 'vi-VN', + zh: 'zh-CN', +}; + +/** + * Resolve the on-device dictation language using the same setting semantics as + * Voice Mode. Automatic follows the browser locale when Nemotron supports it, + * then falls back to the model's language detection. + */ +export function resolveDictationLanguage(configuredLanguage: unknown, browserLanguage: string | undefined): string { + const configured = typeof configuredLanguage === 'string' ? configuredLanguage.trim() : ''; + const candidate = configured && configured.toLowerCase() !== 'auto' ? configured : browserLanguage; + if (!candidate || typeof Intl.getCanonicalLocales !== 'function') { + return 'auto'; + } + + try { + const canonical = Intl.getCanonicalLocales(candidate)[0]; + if (NEMOTRON_LOCALES.has(canonical)) { + return canonical; + } + return NEMOTRON_DEFAULT_LOCALE_BY_LANGUAGE[canonical.split('-')[0]] ?? 'auto'; + } catch { + return 'auto'; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts index b1ee7c2a47a..60116a95917 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts @@ -5,7 +5,7 @@ import { addDisposableListener, getWindow } from '../../../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; -import { IAction, toAction } from '../../../../../base/common/actions.js'; +import { IAction, Separator, toAction } from '../../../../../base/common/actions.js'; import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../nls.js'; import { createConfigureKeybindingAction } from '../../../../../platform/actions/common/menuService.js'; @@ -24,11 +24,15 @@ const CANCEL_DICTATION_COMMAND = 'workbench.action.chat.cancelSpeechToText'; const VOICE_DISCONNECT_COMMAND = 'agentsVoice.disconnect'; /** Command that opens the Voice Mode settings; the affordance that used to live behind the toolbar gear. */ const VOICE_OPEN_SETTINGS_COMMAND = 'agentsVoice.openSettings'; +/** Command that opens the Settings editor. */ +const OPEN_SETTINGS_COMMAND = 'workbench.action.openSettings'; +/** Narrows the Settings editor to dictation settings. */ +const DICTATION_SETTINGS_QUERY = 'dictation'; /** Command that shows the Voice Mode onboarding card again. */ export const SHOW_VOICE_MODE_ONBOARDING_COMMAND = 'agentsVoice.showOnboarding'; -/** Setting that enables dictation; toggled off by "Disable Dictation". */ +/** Setting that enables dictation; toggled off by "Disable". */ const DICTATION_ENABLED_SETTING = 'dictation.enabled'; -/** Setting that enables Voice Mode; toggled off by "Disable Voice Mode". */ +/** Setting that enables Voice Mode; toggled off by "Disable". */ const VOICE_ENABLED_SETTING = 'agents.voice.enabled'; /** @@ -44,14 +48,14 @@ function createSelectMicrophoneAction(commandService: ICommandService): IAction } /** - * "Disable Dictation" entry. Cancels any active/preparing dictation first so + * "Disable" entry for dictation. Cancels any active/preparing dictation first so * disabling the setting doesn't leave the microphone capturing while the toolbar * affordance disappears, then turns off the feature setting. */ function createDisableDictationAction(commandService: ICommandService, configurationService: IConfigurationService): IAction { return toAction({ id: 'chat.dictation.disable', - label: localize('dictation.disable', "Disable Dictation"), + label: localize('dictation.disable', "Disable"), run: async () => { await commandService.executeCommand(CANCEL_DICTATION_COMMAND); await configurationService.updateValue(DICTATION_ENABLED_SETTING, false); @@ -68,14 +72,14 @@ function createShowDictationOnboardingAction(commandService: ICommandService): I } /** - * "Disable Voice Mode" entry. Tears down any active session first so disabling + * "Disable" entry for Voice Mode. Tears down any active session first so disabling * the setting doesn't leave the microphone capturing while the toolbar * affordance disappears, then turns off the feature setting. */ function createDisableVoiceModeAction(commandService: ICommandService, configurationService: IConfigurationService): IAction { return toAction({ id: 'chat.voiceMode.disable', - label: localize('voiceMode.disable', "Disable Voice Mode"), + label: localize('voiceMode.disable', "Disable"), run: async () => { await commandService.executeCommand(VOICE_DISCONNECT_COMMAND); await configurationService.updateValue(VOICE_ENABLED_SETTING, false); @@ -84,23 +88,26 @@ function createDisableVoiceModeAction(commandService: ICommandService, configura } /** - * Actions for the dictation mic button context menu: "Configure Keybinding" - * (always enabled so a removed binding can be restored), "Select Microphone" - * and "Disable Dictation". `keybindingCommandId` is the stable command the - * keybinding entry targets. + * Actions for the dictation mic button context menu. Keybinding and feature + * disabling are grouped separately from configuration and onboarding. */ export function getDictationContextMenuActions(commandService: ICommandService, configurationService: IConfigurationService, keybindingService: IKeybindingService, keybindingCommandId: string): IAction[] { - return [ - createConfigureKeybindingAction(commandService, keybindingService, keybindingCommandId), - createConfigureInstructionsAction(commandService, CONFIGURE_DICTATION_INSTRUCTIONS_ACTION_ID, localize('dictation.configureInstructions', "Configure Dictation Instructions")), - createShowDictationOnboardingAction(commandService), - createSelectMicrophoneAction(commandService), - createDisableDictationAction(commandService, configurationService), - ]; + return Separator.join( + [ + createConfigureKeybindingAction(commandService, keybindingService, keybindingCommandId), + createDisableDictationAction(commandService, configurationService), + ], + [ + createDictationSettingsAction(commandService), + createConfigureInstructionsAction(commandService, CONFIGURE_DICTATION_INSTRUCTIONS_ACTION_ID, localize('dictation.configureInstructions', "Configure Instructions")), + createShowDictationOnboardingAction(commandService), + createSelectMicrophoneAction(commandService), + ], + ); } /** - * "Voice Mode Settings" entry. Opens the Voice Mode settings — the affordance + * "Settings" entry. Opens the Voice Mode settings — the affordance * that used to live behind the toolbar gear button. */ function createVoiceModeSettingsAction(commandService: ICommandService): IAction { @@ -111,6 +118,14 @@ function createVoiceModeSettingsAction(commandService: ICommandService): IAction }); } +function createDictationSettingsAction(commandService: ICommandService): IAction { + return toAction({ + id: 'chat.dictation.openSettings', + label: localize('dictation.openSettings', "Open Settings"), + run: () => commandService.executeCommand(OPEN_SETTINGS_COMMAND, { query: DICTATION_SETTINGS_QUERY }), + }); +} + function createShowVoiceModeOnboardingAction(commandService: ICommandService): IAction { return toAction({ id: SHOW_VOICE_MODE_ONBOARDING_COMMAND, @@ -128,22 +143,22 @@ function createConfigureInstructionsAction(commandService: ICommandService, comm } /** - * Actions for the Voice Mode mic button context menu, mirroring - * {@link getDictationContextMenuActions} but with "Disable Voice Mode". The - * "Configure Keybinding" entry opens the keybindings editor scoped to the Voice - * Mode keybinding and "Voice Mode Settings" opens the Voice Mode settings — the - * affordances that used to live behind the toolbar gear button. - * `keybindingCommandId` is the stable command the keybinding entry targets. + * Actions for the Voice Mode mic button context menu. Keybinding and feature + * disabling are grouped separately from configuration and onboarding. */ export function getVoiceModeContextMenuActions(commandService: ICommandService, configurationService: IConfigurationService, keybindingService: IKeybindingService, keybindingCommandId: string): IAction[] { - return [ - createConfigureKeybindingAction(commandService, keybindingService, keybindingCommandId), - createVoiceModeSettingsAction(commandService), - createConfigureInstructionsAction(commandService, CONFIGURE_VOICE_INSTRUCTIONS_ACTION_ID, localize('voiceMode.configureInstructions', "Configure Voice Mode Instructions")), - createShowVoiceModeOnboardingAction(commandService), - createSelectMicrophoneAction(commandService), - createDisableVoiceModeAction(commandService, configurationService), - ]; + return Separator.join( + [ + createConfigureKeybindingAction(commandService, keybindingService, keybindingCommandId), + createDisableVoiceModeAction(commandService, configurationService), + ], + [ + createVoiceModeSettingsAction(commandService), + createConfigureInstructionsAction(commandService, CONFIGURE_VOICE_INSTRUCTIONS_ACTION_ID, localize('voiceMode.configureInstructions', "Configure Instructions")), + createShowVoiceModeOnboardingAction(commandService), + createSelectMicrophoneAction(commandService), + ], + ); } /** diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts index e0902844da4..3104dd3d635 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts @@ -17,12 +17,12 @@ import { createPcmCaptureNode } from '../pcmCaptureWorklet.js'; export const IMicCaptureService = createDecorator<IMicCaptureService>('micCaptureService'); -/** - * Number of samples buffered in the capture worklet before a chunk is posted to - * the main thread. Matches the buffer size previously used with - * `ScriptProcessorNode` so the per-chunk drain/diagnostic bookkeeping is unchanged. - */ -const MIC_CAPTURE_CHUNK_SIZE = 2048; +/** Number of samples buffered per 32 ms voice capture chunk at 16 kHz, matching one Silero VAD frame. */ +export const MIC_CAPTURE_CHUNK_SIZE = 512; + +export function isMicrophonePermissionDeniedError(error: unknown): boolean { + return (error instanceof DOMException || error instanceof Error) && error.name === 'NotAllowedError'; +} /** * Per-PTT-press diagnostic emitted after `pttUp` once the diagnostic @@ -170,6 +170,9 @@ export class MicCaptureService extends Disposable implements IMicCaptureService private _workletNode: AudioWorkletNode | undefined; private _analyserNode: AnalyserNode | undefined; private _isCapturing = false; + private _captureGeneration = 0; + private _capturePromise: Promise<void> | undefined; + private _pttGeneration = 0; private _pttHeld = false; private _pttStreaming = false; private _isMuted = false; @@ -244,6 +247,7 @@ export class MicCaptureService extends Disposable implements IMicCaptureService async pttDown(turnId: string, passive: boolean = false): Promise<void> { if (this._pttHeld) { return; } + const pttGeneration = ++this._pttGeneration; // If a previous press is still in its drain window, finish it // now: cancel the fallback timer, mark streaming closed, fire // `_onPttEnd`. Otherwise the backend would keep the prior turn @@ -278,13 +282,22 @@ export class MicCaptureService extends Disposable implements IMicCaptureService try { await this.startCapture(this._window); } catch (err) { + if (pttGeneration !== this._pttGeneration) { + return; + } this._pttHeld = false; this._pttStreaming = false; - this._pttAcquiring = false; this._pttReleasedDuringAcquire = false; throw err; + } finally { + if (pttGeneration === this._pttGeneration) { + this._pttAcquiring = false; + } + } + if (pttGeneration !== this._pttGeneration || !this._isCapturing || !this._pttHeld) { + this._pttReleasedDuringAcquire = false; + return; } - this._pttAcquiring = false; this._onPttStart.fire(passive); if (this._pttReleasedDuringAcquire) { @@ -348,6 +361,8 @@ export class MicCaptureService extends Disposable implements IMicCaptureService } this._pttDrainTargetSamples = 0; this._pttDrainSamplesSent = 0; + this._pttGeneration++; + this._pttAcquiring = false; this._pttHeld = false; this._pttStreaming = false; this._pttReleasedDuringAcquire = false; @@ -359,6 +374,22 @@ export class MicCaptureService extends Disposable implements IMicCaptureService async startCapture(window: Window & typeof globalThis): Promise<void> { this._window = window; if (this._isCapturing) { return; } + if (this._capturePromise) { + return this._capturePromise; + } + const capturePromise = this._startCapture(window); + this._capturePromise = capturePromise; + try { + await capturePromise; + } finally { + if (this._capturePromise === capturePromise) { + this._capturePromise = undefined; + } + } + } + + private async _startCapture(window: Window & typeof globalThis): Promise<void> { + const captureGeneration = this._captureGeneration; const deviceId = this.storageService.get(AgentsVoiceStorageKeys.MicrophoneDevice, StorageScope.APPLICATION); const audioConstraints: MediaTrackConstraints = { channelCount: 1, @@ -396,34 +427,53 @@ export class MicCaptureService extends Disposable implements IMicCaptureService throw err; } } + if (captureGeneration !== this._captureGeneration) { + micStream.getTracks().forEach(track => track.stop()); + return; + } this._micStream = micStream; - // Detect a hardware-muted microphone (e.g. a physical kill switch). - // `getUserMedia` succeeds in this case but the track produces silence, - // so without this check PTT would appear to work while capturing nothing. - this._micTrackListeners.clear(); - this._micMutedNotified = false; - const audioTrack = micStream.getAudioTracks()[0]; - if (audioTrack) { - if (audioTrack.muted) { - this._notifyMicrophoneMuted(); + const cleanupFailedCapture = () => { + if (this._micStream === micStream) { + this._stopCaptureResources(); + } else { + micStream.getTracks().forEach(track => track.stop()); } - this._micTrackListeners.add(addDisposableListener(audioTrack, 'mute', () => this._notifyMicrophoneMuted())); - this._micTrackListeners.add(addDisposableListener(audioTrack, 'unmute', () => { this._micMutedNotified = false; })); + }; + + let ctx: AudioContext; + let source: MediaStreamAudioSourceNode; + try { + // Detect a hardware-muted microphone (e.g. a physical kill switch). + // `getUserMedia` succeeds in this case but the track produces silence, + // so without this check PTT would appear to work while capturing nothing. + this._micTrackListeners.clear(); + this._micMutedNotified = false; + const audioTrack = micStream.getAudioTracks()[0]; + if (audioTrack) { + if (audioTrack.muted) { + this._notifyMicrophoneMuted(); + } + this._micTrackListeners.add(addDisposableListener(audioTrack, 'mute', () => this._notifyMicrophoneMuted())); + this._micTrackListeners.add(addDisposableListener(audioTrack, 'unmute', () => { this._micMutedNotified = false; })); + } + + if (!this._micCtx) { + this._micCtx = new window.AudioContext({ sampleRate: 16000 }); + } + ctx = this._micCtx; + source = ctx.createMediaStreamSource(micStream); + + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + source.connect(analyser); + this._analyserNode = analyser; + } catch (err) { + cleanupFailedCapture(); + throw err; } - if (!this._micCtx) { - this._micCtx = new window.AudioContext({ sampleRate: 16000 }); - } - const ctx = this._micCtx; - const source = ctx.createMediaStreamSource(micStream); - - const analyser = ctx.createAnalyser(); - analyser.fftSize = 256; - source.connect(analyser); - this._analyserNode = analyser; - - const { node } = await createPcmCaptureNode(window, ctx, MIC_CAPTURE_CHUNK_SIZE, samples => { + const captureNodePromise = createPcmCaptureNode(window, ctx, MIC_CAPTURE_CHUNK_SIZE, samples => { const nowTs = Date.now(); const ptUpTs = this._diagPttUpTs; // A callback is a "drain" callback while we're still in the @@ -480,20 +530,33 @@ export class MicCaptureService extends Disposable implements IMicCaptureService } }); + let node: AudioWorkletNode; + try { + node = (await captureNodePromise).node; + } catch (err) { + cleanupFailedCapture(); + throw err; + } + // stopCapture() may have run while the worklet module was loading. if (this._micCtx !== ctx) { try { node.disconnect(); } catch { /* ignore */ } return; } - this._workletNode = node; - source.connect(node); - node.connect(ctx.destination); - this._isCapturing = true; + try { + this._workletNode = node; + source.connect(node); + node.connect(ctx.destination); + this._isCapturing = true; + } catch (err) { + cleanupFailedCapture(); + throw err; + } } private _notifyMicPermissionDenied(err: unknown): void { - if (err instanceof DOMException && err.name === 'NotAllowedError') { + if (isMicrophonePermissionDeniedError(err)) { this.notificationService.notify({ severity: Severity.Error, message: localize('mic.permissionDenied', "Microphone access was denied. Grant microphone permission in your system settings to use Voice Mode."), @@ -513,17 +576,9 @@ export class MicCaptureService extends Disposable implements IMicCaptureService }); } - stopCapture(): void { - // Cancel any in-flight drain; do NOT fire `_onPttEnd` here - // because callers (reconnect / disconnect / dispose) have - // already torn down or are about to tear down the backend - // connection. - if (this._pttDrainFallbackTimer) { - clearTimeout(this._pttDrainFallbackTimer); - this._pttDrainFallbackTimer = undefined; - } - this._pttDrainTargetSamples = 0; - this._pttDrainSamplesSent = 0; + private _stopCaptureResources(): void { + this._captureGeneration++; + this._capturePromise = undefined; if (this._workletNode) { this._workletNode.port.onmessage = null; try { this._workletNode.disconnect(); } catch { /* ignore */ } @@ -539,6 +594,22 @@ export class MicCaptureService extends Disposable implements IMicCaptureService this._micTrackListeners.clear(); this._micMutedNotified = false; this._isCapturing = false; + } + + stopCapture(): void { + this._stopCaptureResources(); + this._pttGeneration++; + this._pttAcquiring = false; + // Cancel any in-flight drain; do NOT fire `_onPttEnd` here + // because callers (reconnect / disconnect / dispose) have + // already torn down or are about to tear down the backend + // connection. + if (this._pttDrainFallbackTimer) { + clearTimeout(this._pttDrainFallbackTimer); + this._pttDrainFallbackTimer = undefined; + } + this._pttDrainTargetSamples = 0; + this._pttDrainSamplesSent = 0; this._pttHeld = false; this._pttStreaming = false; this._pttReleasedDuringAcquire = false; diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts index 5b77c7608bf..f27c290d007 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts @@ -28,7 +28,10 @@ import { IVoiceNarrationAck, IVoiceNarrationSignal, IVoiceDispatchResult, + IVoiceCheckpointNarrationMetadata, + VoiceConfirmationType, VoiceNarrationKind, + isVoiceCheckpointId, } from '../../common/voiceClient/voiceClientService.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; @@ -92,7 +95,7 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic // state-change event needs to fire before the timer expires. private _pendingContext: IVoiceSessionContext | undefined; private _lastSentById = new Map<string, Record<string, unknown>>(); // session id → last-sent field values - private _lastSentActive = ''; + private readonly _invalidatedSessionIds = new Set<string>(); // --- Events --- private readonly _onTranscription = this._register(new Emitter<IVoiceTranscription>()); @@ -142,6 +145,10 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic return this._isResuming; } + get willReconnect(): boolean { + return this._reconnectTimer !== undefined; + } + get currentSessionId(): string | undefined { return this._lastSessionId; } @@ -352,8 +359,14 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic turn_id?: unknown; revision?: unknown; narration_id?: string; + request_id?: string; + checkpoint_id?: string; + sequence?: number; + narration_kind?: string; + playback_id?: string; interrupted_turn_id?: string; disposition?: string; + retryable?: boolean; }; try { msg = JSON.parse(evt.data as string); @@ -378,7 +391,7 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic this._onSessionInit.fire({ sessionId: msg.session_id ?? '' }); break; case 'speech_started': - this._onSpeechStarted.fire({}); + this._onSpeechStarted.fire({ turnId: asOptionalString(msg.turn_id) }); break; case 'barge_in': this._onBargeIn.fire({ @@ -386,14 +399,20 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic interruptedTurnId: msg.interrupted_turn_id ?? '', }); break; - case 'narration_ack': + case 'narration_ack': { + const disposition = msg.disposition === 'busy' + || msg.disposition === 'invalid' + || msg.disposition === 'suppressed' + ? msg.disposition + : 'accepted'; this._onNarrationAck.fire({ narrationId: msg.narration_id ?? '', codingSessionId: msg.coding_session_id ?? '', - disposition: (msg.disposition as 'accepted' | 'busy' | 'invalid') ?? 'accepted', + disposition, reason: msg.reason, }); break; + } case 'narration_unblocked': this._onNarrationUnblocked.fire({ narrationId: msg.narration_id ?? '', @@ -404,6 +423,8 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic this._onNarrationInterrupted.fire({ narrationId: msg.narration_id ?? '', codingSessionId: msg.coding_session_id ?? '', + ...(typeof msg.retryable === 'boolean' ? { retryable: msg.retryable } : {}), + ...(msg.reason ? { reason: msg.reason } : {}), }); break; case 'transcription': { @@ -422,11 +443,19 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic }); break; } - case 'audio_response': + case 'audio_response': { // Old pre-streaming server (pre PR #44076) doesn't send // `is_first_chunk` at all. Treat missing field as TRUE so // suppression-clearing in _enqueueAudio still works; new // streaming server always emits true/false explicitly. + const requestId = asOptionalString(msg.request_id); + const checkpointId = isVoiceCheckpointId(msg.checkpoint_id) ? msg.checkpoint_id : undefined; + const sequence = typeof msg.sequence === 'number' && Number.isSafeInteger(msg.sequence) && msg.sequence > 0 ? msg.sequence : undefined; + const narrationKind = msg.narration_kind === 'response' || msg.narration_kind === 'confirmation' || msg.narration_kind === 'checkpoint' ? msg.narration_kind as VoiceNarrationKind : undefined; + const playbackId = asOptionalString(msg.playback_id); + if (narrationKind === 'checkpoint') { + this._logService.info(`[voice] checkpoint audio request=${requestId ?? 'none'} stage=${checkpointId ?? 'none'} sequence=${sequence ?? 'none'} first=${msg.is_first_chunk === undefined ? true : Boolean(msg.is_first_chunk)} final=${Boolean(msg.is_final)}`); + } this._onAudioResponse.fire({ audio: msg.audio ?? '', isFirstChunk: msg.is_first_chunk === undefined ? true : Boolean(msg.is_first_chunk), @@ -435,8 +464,14 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic transcript: msg.transcript, turnId: asOptionalString(msg.turn_id), responseId: msg.narration_id ?? asOptionalString(msg.turn_id), + ...(requestId ? { requestId } : {}), + ...(checkpointId ? { checkpointId } : {}), + ...(sequence !== undefined ? { sequence } : {}), + ...(narrationKind ? { narrationKind } : {}), + ...(playbackId ? { playbackId } : {}), }); break; + } case 'tool_call': this._onToolCall.fire({ callId: msg.call_id ?? '', @@ -496,7 +531,6 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic } this._reconnectAttempts++; - this._setConnected(false); this._stopPing(); this._ws = undefined; @@ -504,7 +538,11 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic ? FAST_RETRY_DELAY_MS : SLOW_RETRY_DELAY_MS; this._logService.warn(`[voice] ws closed abnormally (code=${evt.code} reason=${evt.reason || 'none'} wasClean=${evt.wasClean}); reconnecting in ${delay}ms (attempt ${this._reconnectAttempts})`); - this._reconnectTimer = setTimeout(() => this._connectWebSocket(), delay); + this._reconnectTimer = setTimeout(() => { + this._reconnectTimer = undefined; + this._connectWebSocket(); + }, delay); + this._setConnected(false); } }; } @@ -534,7 +572,7 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic this._lastSessionId = undefined; this._isResuming = false; this._lastSentById.clear(); - this._lastSentActive = ''; + this._invalidatedSessionIds.clear(); this._setConnected(false); } @@ -630,14 +668,12 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic } invalidateSessionCache(sessionId: string): void { - this._lastSentById.delete(sessionId); + this._invalidatedSessionIds.add(sessionId); } private _sendDelta(context: IVoiceSessionContext): void { const currentIds = new Set(context.sessions.map(s => s.id)); const removes = [...this._lastSentById.keys()].filter(id => !currentIds.has(id)); - const activeKey = context.active_session ? stableStringify(context.active_session) : ''; - const activeChanged = activeKey !== this._lastSentActive; // Compute per-session field-level patches (JSON Merge Patch style) const upserts: Record<string, unknown>[] = []; @@ -650,20 +686,35 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic } else { const patch: Record<string, unknown> = { id: session.id }; let hasChanges = false; - // Fields that changed or were added - for (const key of Object.keys(current)) { - if (key === 'id') { continue; } - if (stableStringify(current[key]) !== stableStringify(prev[key])) { - patch[key] = current[key]; - hasChanges = true; + if (this._invalidatedSessionIds.has(session.id)) { + for (const key of Object.keys(current)) { + if (key !== 'id') { + patch[key] = current[key] ?? null; + hasChanges = true; + } } - } - // Fields that were removed (present in prev, absent in current) → null per RFC 7396 - for (const key of Object.keys(prev)) { - if (key === 'id') { continue; } - if (!Object.prototype.hasOwnProperty.call(current, key) || current[key] === undefined) { - patch[key] = null; - hasChanges = true; + for (const key of Object.keys(prev)) { + if (key !== 'id' && (!Object.prototype.hasOwnProperty.call(current, key) || current[key] === undefined)) { + patch[key] = null; + hasChanges = true; + } + } + } else { + // Fields that changed or were added + for (const key of Object.keys(current)) { + if (key === 'id') { continue; } + if (stableStringify(current[key]) !== stableStringify(prev[key])) { + patch[key] = current[key]; + hasChanges = true; + } + } + // Fields that were removed (present in prev, absent in current) → null per RFC 7396 + for (const key of Object.keys(prev)) { + if (key === 'id') { continue; } + if (!Object.prototype.hasOwnProperty.call(current, key) || current[key] === undefined) { + patch[key] = null; + hasChanges = true; + } } } // ``agent_state_detail`` (the confirmation prompt text) and @@ -691,7 +742,7 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic } } - if (upserts.length === 0 && removes.length === 0 && !activeChanged) { + if (upserts.length === 0 && removes.length === 0) { return; } @@ -702,22 +753,25 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic if (v !== undefined) { obj[k] = v; } } this._lastSentById.set(session.id, obj); + this._invalidatedSessionIds.delete(session.id); + } + for (const id of removes) { + this._lastSentById.delete(id); + this._invalidatedSessionIds.delete(id); } - for (const id of removes) { this._lastSentById.delete(id); } - this._lastSentActive = activeKey; this._ws!.send(JSON.stringify({ type: 'session_context', mode: 'delta', upserts, removes, - ...(activeChanged && context.active_session ? { active_session: context.active_session } : {}), })); - this._logService.trace(`[voice] _sendDelta upserts=[${upserts.map(u => `${String(u.id).slice(-8)}:${u.agent_state ?? '(no-state)'}${Object.prototype.hasOwnProperty.call(u, 'agent_state_detail') ? '+detail' : ''}${Object.prototype.hasOwnProperty.call(u, 'last_response_summary') && u.last_response_summary ? '+summary' : ''}`).join(', ')}] removes=${removes.length} activeChanged=${activeChanged}`); + this._logService.trace(`[voice] _sendDelta upserts=[${upserts.map(u => `${String(u.id).slice(-8)}:${u.agent_state ?? '(no-state)'}${Object.prototype.hasOwnProperty.call(u, 'agent_state_detail') ? '+detail' : ''}${Object.prototype.hasOwnProperty.call(u, 'last_response_summary') && u.last_response_summary ? '+summary' : ''}`).join(', ')}] removes=${removes.length}`); } private _seedTracking(context: IVoiceSessionContext): void { this._lastSentById.clear(); + this._invalidatedSessionIds.clear(); for (const session of context.sessions) { const obj: Record<string, unknown> = {}; for (const [k, v] of Object.entries(session as unknown as Record<string, unknown>)) { @@ -725,7 +779,6 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic } this._lastSentById.set(session.id, obj); } - this._lastSentActive = context.active_session ? stableStringify(context.active_session) : ''; } sendToolResult(callId: string, result: string | IVoiceDispatchResult): void { @@ -734,7 +787,18 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic } } - requestNarration(codingSessionId: string, kind: VoiceNarrationKind, text: string, narrationId?: string, pending?: { pendingId: string }): string | undefined { + sendNarrationPlaybackComplete(codingSessionId: string, narrationId: string, playbackId: string): void { + if (this._ws?.readyState === WebSocket.OPEN && this._sessionStartedOnSocket) { + this._ws.send(JSON.stringify({ + type: 'narration_playback_complete', + coding_session_id: codingSessionId, + narration_id: narrationId, + playback_id: playbackId, + })); + } + } + + requestNarration(codingSessionId: string, kind: VoiceNarrationKind, text: string, narrationId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata, confirmationType?: VoiceConfirmationType, pending?: { pendingId: string }): string | undefined { // Gate on session_context having been sent: the WS preserves send order, // so the backend processes start_session/resume_session before any // request_narration. Pre-session this returns undefined, so _narrate queues @@ -748,9 +812,18 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic kind, text, narration_id: id, + ...(checkpoint ? { + request_id: checkpoint.requestId, + checkpoint_id: checkpoint.checkpointId, + sequence: checkpoint.sequence, + } : {}), + ...(kind === 'confirmation' && confirmationType ? { confirmation_type: confirmationType } : {}), ...(pending ? { pending_id: pending.pendingId } : {}), })); this._logService.trace(`[voice] request_narration kind=${kind} id=${codingSessionId.slice(-32)} narration_id=${id.slice(0, 8)}${narrationId ? ' (retry)' : ''}`); + if (checkpoint) { + this._logService.info(`[voice] checkpoint sent request=${checkpoint.requestId} stage=${checkpoint.checkpointId} sequence=${checkpoint.sequence}`); + } return id; } return undefined; diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index fb9744a9ba0..daca646139b 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -3,36 +3,39 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { IObservable, observableValue, autorun, transaction, observableSignalFromEvent } from '../../../../../base/common/observable.js'; import { addDisposableListener, disposableWindowInterval } from '../../../../../base/browser/dom.js'; +import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js'; import { alert as ariaAlert } from '../../../../../base/browser/ui/aria/aria.js'; +import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../nls.js'; import { disposableTimeout } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { URI } from '../../../../../base/common/uri.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; +import { isObject } from '../../../../../base/common/types.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; import { IVoiceTranscriptEntryMetadata, IVoiceTranscriptStore, IVoiceTranscriptTurn, VoiceTranscriptKind } from '../../../agentsVoice/common/voiceTranscriptStore.js'; -import { IVoiceAudioResponse, IVoiceBargeIn, IVoiceClientService, IVoicePriorTimelineEntry, IVoiceSessionContext, IVoiceFeedbackPayload, IVoiceFeedbackTranscriptTurn, IVoiceTranscription, IVoiceTurnAutoEnded, IVoiceNarrationAck, IVoiceNarrationSignal, VoiceNarrationKind, IVoiceSessionPending, IVoicePendingQuestion, derivePendingId } from '../../common/voiceClient/voiceClientService.js'; -import { IMicCaptureService, IPttDiagnostic } from './micCaptureService.js'; +import { IVoiceAudioResponse, IVoiceBargeIn, IVoiceCheckpointNarrationMetadata, IVoiceClientService, IVoicePriorTimelineEntry, IVoiceSessionContext, IVoiceFeedbackPayload, IVoiceFeedbackTranscriptTurn, IVoiceTranscription, IVoiceTurnAutoEnded, IVoiceNarrationAck, IVoiceNarrationSignal, isVoiceCheckpointId, VoiceCheckpointId, VoiceConfirmationType, VoiceNarrationKind, IVoiceSessionPending, IVoicePendingQuestion, derivePendingId, VOICE_AGENT_PROGRESS_SETTING } from '../../common/voiceClient/voiceClientService.js'; +import { getVoiceConfirmationType, isPendingVoiceQuestionnaireInvocation, isVoiceQuestionnaireInvocation } from '../../common/voiceClient/voiceConfirmation.js'; +import { IMicCaptureService, IPttDiagnostic, isMicrophonePermissionDeniedError } from './micCaptureService.js'; import { ITtsPlaybackService } from './ttsPlaybackService.js'; import { IVoiceToolDispatchService, VoiceToolDispatchService } from './voiceToolDispatchService.js'; import { IVoicePlaybackService } from '../../common/voicePlaybackService.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; import { AgentSessionStatus } from '../agentSessions/agentSessionsModel.js'; import { toAgentHostBackendSessionUri } from '../agentSessions/agentHost/agentHostSessionUri.js'; -import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; -import { IChatService, IChatToolInvocation, ToolConfirmKind, IChatModelReference, IChatQuestionCarousel } from '../../common/chatService/chatService.js'; +import { ChatSendResult, IChatConfirmation, IChatElicitationRequest, IChatPlanReview, IChatQuestionCarousel, IChatService, IChatToolInvocation, ToolConfirmKind, IChatModelReference } from '../../common/chatService/chatService.js'; import { getDisplayedQuestionText, getOptionsWithDefaultsFirst } from '../../common/chatService/chatQuestionCarouselHelpers.js'; import { formatQuestionPrompt } from '../../common/voiceClient/voicePendingNarration.js'; import { IChatWidget, IChatWidgetService } from '../chat.js'; -import { IChatModel } from '../../common/model/chatModel.js'; +import { IChatModel, IChatProgressResponseContent, IChatResponseModel } from '../../common/model/chatModel.js'; import { ChatAgentLocation } from '../../common/constants.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; @@ -99,10 +102,54 @@ interface IPendingSolicitedNarration { readonly text: string; /** The form this narration speaks, when it has one. Identifies the occurrence for dedup; see `_narratableIdentity`. */ readonly pending?: { pendingId: string }; + readonly checkpoint?: IVoiceCheckpointNarrationMetadata; + readonly confirmationType?: VoiceConfirmationType; readonly audioStartTimer: ReturnType<typeof setTimeout>; hasReceivedAudio: boolean; } +interface IVoiceNarratable { + readonly kind: Exclude<VoiceNarrationKind, 'checkpoint'>; + readonly text: string; + readonly pending?: { pendingId: string }; + readonly confirmationType?: VoiceConfirmationType; +} + +interface IPlaybackNarration { + readonly kind: VoiceNarrationKind; + readonly checkpoint?: IVoiceCheckpointNarrationMetadata; + readonly playbackId?: string; +} + +interface IQueuedAudioResponse { + readonly sessionId: string | undefined; + readonly responseId?: string; + readonly narration?: IPlaybackNarration; + finalized: boolean; + readonly chunks: { audio: string; isFirstChunk: boolean; isFinal: boolean; transcript: string | undefined }[]; +} + +interface IVoiceAgentStateInfo { + readonly state: string; + readonly detail?: string; + readonly confirmation_type?: VoiceConfirmationType; + readonly last_response_summary?: string; +} + +interface IVisibleVoiceQuestionnaire { + readonly context?: string | IMarkdownString; + readonly questions: readonly { + readonly prompt?: string | IMarkdownString; + readonly details?: string | IMarkdownString; + readonly options: readonly string[]; + readonly allowFreeformInput: boolean; + }[]; +} + +function hasOwn<K extends string>(value: object, key: K): value is Record<K, unknown> { + return Object.prototype.hasOwnProperty.call(value, key); +} + export interface IPendingToolConfirmation { readonly type: 'approval' | 'input'; readonly sessionLabel: string; @@ -344,6 +391,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private readonly _connectWatchdog = this._register(new MutableDisposable()); private static readonly _CONNECT_TIMEOUT_MS = 10000; private _connectAttemptGeneration = 0; + private _sessionInitializationGeneration = 0; private readonly _autoApprovedSessions = new Set<string>(); private _transcriptFadeTimer: ReturnType<typeof setTimeout> | undefined; private _pttMaxDurationTimer: ReturnType<typeof setTimeout> | undefined; @@ -369,7 +417,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private _awaitingReplyWatchdog: ReturnType<typeof setTimeout> | undefined; // --- Audio FIFO queue --- - private readonly _audioQueue: { sessionId: string | undefined; responseId?: string; finalized: boolean; chunks: { audio: string; isFirstChunk: boolean; isFinal: boolean; transcript: string | undefined }[] }[] = []; + private readonly _audioQueue: IQueuedAudioResponse[] = []; private _currentPlaybackSessionId: string | undefined | null = null; // null = nothing playing // The narration id of the response currently occupying the playback slot, if // it was a solicited narration. Set when a chunk actually claims the slot and @@ -377,6 +425,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // has truly finished playing (never merely queued or received - see // {@link _markNarrationHeard}). private _currentPlaybackResponseId: string | undefined; + private _currentPlaybackNarration: IPlaybackNarration | undefined; // True once the currently-playing response has received its final audio // chunk. A same-session frame arriving after this marks a NEW response and // must be serialized (queued) rather than fast-pathed, or its audio would be @@ -521,7 +570,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private _replaySourceNode: AudioBufferSourceNode | undefined; // --- Session state tracking for explicit change notifications --- - private readonly _prevSessionStates = new Map<string, { state: string; detail: string; pendingId: string; lastResponseSummary: string }>(); + private readonly _prevSessionStates = new Map<string, { state: string; detail: string; pendingId: string; confirmationType?: VoiceConfirmationType; lastResponseSummary: string }>(); // Sessions the user explicitly cancelled from VS Code UI. We swallow the // NEXT state change for each (typically the chat model going `idle`) so the @@ -555,7 +604,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * also records the burst's baseline (``fromState``/``fromDetail``) so a wobble * that returns to its starting state is recognized as net-zero. */ - private readonly _pendingStateChanges = new Map<string, { sessionId: string; currentState: string; label: string; detail?: string; lastResponseSummary?: string; fromState: string; fromDetail: string; fromResponseSummary: string; pendingId: string; fromPendingId: string }>(); + private readonly _pendingStateChanges = new Map<string, { sessionId: string; currentState: string; label: string; detail?: string; confirmationType?: VoiceConfirmationType; lastResponseSummary?: string; fromState: string; fromDetail: string; fromConfirmationType?: VoiceConfirmationType; fromResponseSummary: string; pendingId: string; fromPendingId: string }>(); private _stateChangeEmitTimer: ReturnType<typeof setTimeout> | undefined; private static readonly _STATE_CHANGE_SETTLE_MS = 120; @@ -614,29 +663,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * {@link _narrate}). Replayed once on the next `session_init` so a reply or * confirmation that landed during a disconnect is still spoken on reconnect. */ - private readonly _pendingNarrationRetries = new Map<string, VoiceNarrationKind>(); - - /** - * Replay a narration that could not be sent while the socket was down. - * - * Re-derives the item from the session as it is *now*, because a form or - * confirmation can be answered, dismissed or replaced during a disconnect. - * Mirrors {@link _retryDeferredNarration} on the busy path. - */ - private _replayPendingNarrationRetry(sessionId: string, queuedKind: VoiceNarrationKind): boolean { - let resource: URI | undefined; - try { - resource = URI.parse(sessionId); - } catch { - resource = undefined; - } - const narratable = resource ? this._currentNarratable(resource) : undefined; - if (!narratable || narratable.kind !== queuedKind) { - this.logService.trace(`[voice] queued narration for ${sessionId.slice(-32)} no longer warranted after reconnect; dropping`); - return false; - } - return this._narrate(sessionId, narratable.kind, narratable.text, undefined, narratable.pending); - } + private readonly _pendingNarrationRetries = new Map<string, IVoiceNarratable>(); /** * Narrations we requested (got a `narration_id` back) but whose audio has not @@ -650,6 +677,16 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC */ private readonly _pendingSolicitedNarrations = new Map<string, IPendingSolicitedNarration>(); private static readonly _SOLICITED_NARRATION_AUDIO_START_TIMEOUT_MS = 30_000; + private static readonly _VOICE_PROGRESS_INITIAL_DELAY_MS = 5_000; + private static readonly _VOICE_PROGRESS_INTERVAL_MS = 10_000; + private static readonly _MAX_VOICE_PROGRESS_PER_REQUEST = 5; + private static readonly _MAX_CONFIRMATION_NARRATION_CHARS = 2_400; + private static readonly _MAX_QUESTIONNAIRE_QUESTIONS = 6; + private static readonly _MAX_QUESTIONNAIRE_OPTIONS = 5; + private static readonly _MAX_CONFIRMATION_FIELD_CHARS = 280; + private readonly _voiceProgressListeners = this._register(new DisposableMap<string, DisposableStore>()); + private readonly _voiceProgressSessionByResponse = new Map<string, string>(); + private readonly _lastSpokenAtBySession = new Map<string, number>(); /** * Narrations the backend bounced (`narration_ack` `busy`) or cancelled @@ -659,7 +696,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * since a dropped socket loses any in-flight nudge. See * `_retryDeferredNarration`. Cleared on a new turn (`thinking`) or teardown. */ - private readonly _deferredNarrations = new Map<string, { narrationId: string; kind: VoiceNarrationKind; text: string; reuseNarrationId: boolean; pending?: { pendingId: string } }>(); + private readonly _deferredNarrations = new Map<string, IVoiceNarratable & { narrationId: string; reuseNarrationId: boolean }>(); /** * The confirmation detail text last actually HEARD (final audio arrived) per @@ -776,16 +813,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._autoApprovedSessions.add(s.resource.toString()); const model = this.chatService.getSession(s.resource); if (model) { - for (const req of model.getRequests()) { - const pending = req.response?.isPendingConfirmation.get(); - if (pending && req.response) { - for (const part of req.response.response.value) { - if (part.kind === 'toolInvocation') { - IChatToolInvocation.confirmWith(part as IChatToolInvocation, { type: ToolConfirmKind.UserAction }); - } - } - } - } + this._autoApprovePendingTools(model); } } }, @@ -1084,7 +1112,14 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._currentPlaybackFinalized = false; const finishedResponseId = this._currentPlaybackResponseId; this._currentPlaybackResponseId = undefined; + const finishedNarration = this._currentPlaybackNarration; + this._currentPlaybackNarration = undefined; if (finishedResponseId && !wasInterrupted) { + const spokenSessionId = finishedSessionId ?? this._shownSessionId(); + if (spokenSessionId) { + this._lastSpokenAtBySession.set(this._sessionKey(spokenSessionId), Date.now()); + this._notifyCheckpointPlaybackComplete(spokenSessionId, finishedResponseId, finishedNarration); + } // The response actually played to the end: mark it heard (set the // exactly-once dedup and clear its pending indicator). This is the // only point that means the audio truly played through, not merely @@ -1135,6 +1170,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Connection state → start mic + send start session this._voiceEventDisposables.add(this.voiceClientService.onDidChangeConnectionState(async connected => { if (connected) { + const sessionInitializationGeneration = ++this._sessionInitializationGeneration; + // Every socket open, including reconnects, gets a full timeout window + // covering voice instructions, mic warm-up, and the session command. + this._armConnectWatchdog(); const pbCtx = this.ttsPlaybackService.ensureContext(window); pbCtx.resume(); @@ -1162,11 +1201,43 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const voiceInstructions = await this.promptsService.getVoiceInstructions(CancellationToken.None); if ( connectAttemptGeneration !== this._connectAttemptGeneration || + sessionInitializationGeneration !== this._sessionInitializationGeneration || !this.voiceClientService.isConnected || (!this._isConnecting.get() && !this._isReconnecting.get()) ) { return; } + if (isResuming) { + this.micCaptureService.stopCapture(); + } + this.micCaptureService.prepare(window); + if (this._isHandsFreeEnabled()) { + try { + await this.micCaptureService.startCapture(window); + } catch (err) { + if ( + connectAttemptGeneration !== this._connectAttemptGeneration || + sessionInitializationGeneration !== this._sessionInitializationGeneration || + !this.voiceClientService.isConnected || + (!this._isConnecting.get() && !this._isReconnecting.get()) + ) { + return; + } + this.logService.warn('[voice] failed to warm microphone capture for hands-free mode; resetting voice mode', err); + const permissionDenied = isMicrophonePermissionDeniedError(err); + this._resetFailedConnection(!permissionDenied); + return; + } + if ( + connectAttemptGeneration !== this._connectAttemptGeneration || + sessionInitializationGeneration !== this._sessionInitializationGeneration || + !this.voiceClientService.isConnected || + (!this._isConnecting.get() && !this._isReconnecting.get()) + ) { + return; + } + } + if (isResuming) { this.voiceClientService.sendResumeSession(this._buildSessionContext(), this._getMachineId(), voiceInstructions); } else { @@ -1175,17 +1246,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this.voiceClientService.sendStartSession(this._buildSessionContext(), this._getMachineId(), priorTimeline, undefined, voiceInstructions); } - // On a reconnect cycle, refresh the mic stream: the old MediaStream - // may have gone stale while the WS was down, so we stop+start to - // guarantee a clean capture before the user PTTs again. - if (isResuming) { - this.micCaptureService.stopCapture(); - } - this.micCaptureService.prepare(window); - // Mic is acquired lazily on the first pttDown, not eagerly on - // connect. This avoids switching bluetooth headsets into speech - // mode and prevents the backend from hearing ambient audio. - transaction(tx => { this._isConnecting.set(false, tx); this._isReconnecting.set(false, tx); @@ -1206,7 +1266,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC : s.status === AgentSessionStatus.Completed ? 'idle' : 'unknown'); if (currentState !== 'unknown') { - this._prevSessionStates.set(s.resource.toString(), { state: currentState, detail: info?.detail ?? '', pendingId: currentState === 'waiting_for_confirmation' ? this._pendingIdFor(s.resource.toString()) : '', lastResponseSummary: info?.last_response_summary ?? '' }); + this._prevSessionStates.set(s.resource.toString(), { state: currentState, detail: info?.detail ?? '', pendingId: currentState === 'waiting_for_confirmation' ? this._pendingIdFor(s.resource.toString()) : '', confirmationType: info?.confirmation_type, lastResponseSummary: info?.last_response_summary ?? '' }); } } // Also seed regular chat sessions so the autorun doesn't trigger false transitions @@ -1216,7 +1276,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (chatModel.getRequests().length === 0) { continue; } const info = this._getAgentStateInfo(chatModel); if (info.state !== 'unknown') { - this._prevSessionStates.set(key, { state: info.state, detail: info.detail ?? '', pendingId: info.state === 'waiting_for_confirmation' ? this._pendingIdFor(key) : '', lastResponseSummary: info.last_response_summary ?? '' }); + this._prevSessionStates.set(key, { state: info.state, detail: info.detail ?? '', pendingId: info.state === 'waiting_for_confirmation' ? this._pendingIdFor(key) : '', confirmationType: info.confirmation_type, lastResponseSummary: info.last_response_summary ?? '' }); } } @@ -1231,7 +1291,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const autorunDisposable = autorun(reader => { const agentSessions = this.agentSessionsService.model.sessions.filter(s => !s.isArchived()); let needsRecheck = false; - const stateChanges: { sessionId: string; currentState: string; label: string; detail?: string; lastResponseSummary?: string; fromState: string; fromDetail: string; fromResponseSummary: string; pendingId: string; fromPendingId: string }[] = []; + const stateChanges: { sessionId: string; currentState: string; label: string; detail?: string; confirmationType?: VoiceConfirmationType; lastResponseSummary?: string; fromState: string; fromDetail: string; fromConfirmationType?: VoiceConfirmationType; fromResponseSummary: string; pendingId: string; fromPendingId: string }[] = []; const waitingForConfirmationSessions: { sessionId: string; label: string; detail?: string; transition: boolean }[] = []; const processedResources = new Set<string>(); @@ -1243,7 +1303,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC lastReq.response.isIncomplete.read(reader); const pending = lastReq.response.isPendingConfirmation.read(reader); - if (pending && this._autoApprovedSessions.has(sessionId)) { + const confirmationType = getVoiceConfirmationType(lastReq.response.response.value); + if (pending && confirmationType === 'tool' && this._autoApprovedSessions.has(sessionId)) { for (const part of lastReq.response.response.value) { if (part.kind === 'toolInvocation') { if (IChatToolInvocation.confirmWith(part as IChatToolInvocation, { type: ToolConfirmKind.UserAction })) { @@ -1273,6 +1334,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._pendingIdleNarration.delete(sessionId); } const detail = info.detail; + const confirmationType = info.confirmation_type; const lastResponseSummary = info.last_response_summary; // Capture the summary while the model is resident so a later // completion reported after disposal can still narrate. @@ -1286,7 +1348,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // id names the occurrence, and is what makes replacing one form // with another a transition worth narrating. const pendingId = currentState === 'waiting_for_confirmation' ? this._pendingIdFor(sessionId) : ''; - const isDetailTransition = !isStateTransition && prev !== undefined && currentState === 'waiting_for_confirmation' && ((detail ?? '') !== prev.detail || pendingId !== prev.pendingId); + const isDetailTransition = !isStateTransition && prev !== undefined && currentState === 'waiting_for_confirmation' + && ((detail ?? '') !== prev.detail || pendingId !== prev.pendingId || confirmationType !== prev.confirmationType); // A completed reply's summary often lands AFTER the idle // transition (or updates while still idle); the model stays // idle so no state transition fires. Detect the summary @@ -1320,7 +1383,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC clearTimeout(cancelExpiry); this._userCancelledSessions.delete(sessionId); } else { - stateChanges.push({ sessionId, currentState, label, detail, lastResponseSummary, fromState: prev?.state ?? currentState, fromDetail: prev?.detail ?? '', fromResponseSummary: prev?.lastResponseSummary ?? '', pendingId, fromPendingId: prev?.pendingId ?? '' }); + stateChanges.push({ sessionId, currentState, label, detail, confirmationType, lastResponseSummary, fromState: prev?.state ?? currentState, fromDetail: prev?.detail ?? '', fromConfirmationType: prev?.confirmationType, fromResponseSummary: prev?.lastResponseSummary ?? '', pendingId, fromPendingId: prev?.pendingId ?? '' }); } } if (currentState !== 'unknown') { @@ -1328,7 +1391,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // so a model unload→reload can't manufacture an ''→old-summary // "transition" that looks like a fresh reply. const rememberedSummary = normalizedSummary || this._lastResponseSummaryById.get(sessionId) || prev?.lastResponseSummary || ''; - this._prevSessionStates.set(sessionId, { state: currentState, detail: detail ?? '', pendingId, lastResponseSummary: rememberedSummary }); + this._prevSessionStates.set(sessionId, { state: currentState, detail: detail ?? '', pendingId, confirmationType, lastResponseSummary: rememberedSummary }); // Leaving waiting_for_confirmation releases the per-occurrence // narration marker, so the next confirmation - even with // identical text - is narrated afresh on focus. @@ -1410,7 +1473,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } this._sessionsAwaitingResponseSummary.delete(sessionId); if (!this._userCancelledSessions.has(sessionId)) { - stateChanges.push({ sessionId, currentState, label: s.label || 'Untitled session', lastResponseSummary: cachedSummary, fromState: prev?.state ?? currentState, fromDetail: prev?.detail ?? '', fromResponseSummary: prev?.lastResponseSummary ?? '', pendingId: '', fromPendingId: prev?.pendingId ?? '' }); + stateChanges.push({ sessionId, currentState, label: s.label || 'Untitled session', lastResponseSummary: cachedSummary, fromState: prev?.state ?? currentState, fromDetail: prev?.detail ?? '', fromConfirmationType: prev?.confirmationType, fromResponseSummary: prev?.lastResponseSummary ?? '', pendingId: '', fromPendingId: prev?.pendingId ?? '' }); } this._prevSessionStates.set(sessionId, { state: currentState, detail: '', pendingId: '', lastResponseSummary: cachedSummary ?? '' }); continue; @@ -1422,7 +1485,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC clearTimeout(cancelExpiry); this._userCancelledSessions.delete(sessionId); } else { - stateChanges.push({ sessionId, currentState, label: s.label || 'Untitled session', fromState: prev?.state ?? currentState, fromDetail: prev?.detail ?? '', fromResponseSummary: prev?.lastResponseSummary ?? '', pendingId: '', fromPendingId: prev?.pendingId ?? '' }); + stateChanges.push({ sessionId, currentState, label: s.label || 'Untitled session', fromState: prev?.state ?? currentState, fromDetail: prev?.detail ?? '', fromConfirmationType: prev?.confirmationType, fromResponseSummary: prev?.lastResponseSummary ?? '', pendingId: '', fromPendingId: prev?.pendingId ?? '' }); } } if (currentState !== 'unknown') { @@ -1478,7 +1541,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC for (const change of stateChanges) { const existing = this._pendingStateChanges.get(change.sessionId); this._pendingStateChanges.set(change.sessionId, existing - ? { ...change, fromState: existing.fromState, fromDetail: existing.fromDetail, fromResponseSummary: existing.fromResponseSummary, fromPendingId: existing.fromPendingId } + ? { ...change, fromState: existing.fromState, fromDetail: existing.fromDetail, fromConfirmationType: existing.fromConfirmationType, fromResponseSummary: existing.fromResponseSummary, fromPendingId: existing.fromPendingId } : change); } this._scheduleStateChangeEmit(); @@ -1528,7 +1591,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._statusText.set('Hold to speak...', undefined); this._voiceState.set('idle', undefined); - // Wait for the backend session ack before opening the hands-free mic. + // Wait for the backend session ack before opening the hands-free PTT turn. this._enterListenOnSessionInit = this._shouldEnterListenOnSessionInit(isResuming); this.logService.trace(`[voice] connected: isResuming=${isResuming} handsFree=${this._isHandsFreeEnabled()} armListen=${this._enterListenOnSessionInit}`); if (this._enterListenOnSessionInit) { @@ -1540,24 +1603,27 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } }, 750)); } - } else if (this._fatalDisconnect) { - // Terminal close already handled by _handleFatalDisconnect: stay in - // the clean, restartable state and do NOT enter the reconnect path - // (which would strand the UI on "Reconnecting..." with no reconnect). - } else if (this._isConnected.get()) { - this._onConnectionLost(); - } else if (this._isReconnecting.get()) { - this._isReconnecting.set(false, undefined); - this._voiceState.set('idle', undefined); - this._statusText.set('Tap to start', undefined); - } else if (this._isConnecting.get()) { - // Connection failed during initial handshake (e.g. fatal WS close). - // Clear isConnecting so callers awaiting the state settle properly. - this._isConnecting.set(false, undefined); - this._voiceState.set('idle', undefined); - this._statusText.set('Tap to start', undefined); } else { - this._voiceState.set('idle', undefined); + this._sessionInitializationGeneration++; + if (this._fatalDisconnect) { + // Terminal close already handled by _handleFatalDisconnect: stay in + // the clean, restartable state and do NOT enter the reconnect path + // (which would strand the UI on "Reconnecting..." with no reconnect). + } else if (!this.voiceClientService.willReconnect) { + this.disconnect(); + } else if (this._isConnected.get()) { + this._onConnectionLost(); + } else { + // A transient socket drop invalidates the in-flight warm-up. Keep + // the controller armed for the service's already-scheduled retry. + this.micCaptureService.stopCapture(); + transaction(tx => { + this._isConnecting.set(false, tx); + this._isReconnecting.set(true, tx); + }); + this._voiceState.set('idle', undefined); + this._statusText.set('Reconnecting...', undefined); + } } })); @@ -1576,8 +1642,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (this._pendingNarrationRetries.size > 0) { const retries = [...this._pendingNarrationRetries.entries()]; this._pendingNarrationRetries.clear(); - for (const [sessionId, kind] of retries) { - narrated = this._replayPendingNarrationRetry(sessionId, kind) || narrated; + for (const [sessionId, item] of retries) { + narrated = this._retryPendingNarration(sessionId, item) || narrated; } } // The `narration_unblocked` nudge was lost with the dropped socket, so @@ -1612,9 +1678,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Speech started → stop TTS, suppress late chunks from the previous turn // (same flow as pttDown, but for server-VAD path). - this._voiceEventDisposables.add(this.voiceClientService.onSpeechStarted(() => { + this._voiceEventDisposables.add(this.voiceClientService.onSpeechStarted(event => { this._clearAutoListenTimer(); this._interruptAssistantPlayback(); + const turnId = event.turnId || this._pttCurrentTurnId; + if (turnId && this._transcriptionTurnState?.turnId !== turnId) { + this._beginTranscriptionTurn(turnId); + } this._startUserTurn(); })); @@ -1638,6 +1708,22 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (this._isInterruptedAudio(e)) { return; } + const solicitedNarration = e.responseId ? this._pendingSolicitedNarrations.get(e.responseId) : undefined; + const echoedCheckpoint: IVoiceCheckpointNarrationMetadata | undefined = e.requestId && e.checkpointId && e.sequence !== undefined + ? { requestId: e.requestId, checkpointId: e.checkpointId, sequence: e.sequence } + : undefined; + const narrationKind = e.narrationKind ?? solicitedNarration?.kind; + const playbackNarration: IPlaybackNarration | undefined = narrationKind + ? { + kind: narrationKind, + checkpoint: echoedCheckpoint ?? solicitedNarration?.checkpoint, + playbackId: e.playbackId, + } + : undefined; + const isCheckpointNarration = playbackNarration?.kind === 'checkpoint'; + if (isCheckpointNarration && e.isFinal) { + this.logService.trace(`[voice][checkpoint] received narration_id=${e.responseId} request_id=${playbackNarration.checkpoint?.requestId ?? '<unknown>'} phase=${playbackNarration.checkpoint?.checkpointId ?? '<unknown>'} sequence=${playbackNarration.checkpoint?.sequence ?? 0} playback_id=${playbackNarration.playbackId ?? '<none>'} spoken=${JSON.stringify(e.transcript ?? '')}`); + } // Latency telemetry: first audio chunk marks end of turn if (e.isFirstChunk && this._telemetryPttUpMs) { const ttft = this._telemetryFirstTranscriptionMs && this._telemetryPttDownMs @@ -1670,6 +1756,14 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (e.audio) { this._markSolicitedNarrationAudioStarted(e.responseId); } + if (isCheckpointNarration && solicitedNarration && e.isFinal && !e.audio && !solicitedNarration.hasReceivedAudio) { + if (e.responseId) { + this._clearPendingSolicitedNarration(e.responseId, solicitedNarration); + this._solicitedNarrationIds.delete(e.responseId); + this._responseRoutes.delete(e.responseId); + } + return; + } // If this response is for a session the user isn't currently looking // at, don't play it now: buffer it until that session is focused and // notify with a short audio cue instead. When the backend echoes a @@ -1689,9 +1783,18 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Backend re-narrated a reply we already read for this session // (matched by content). Drop it so the user never hears it twice. this.logService.trace(`[voice] dropping re-narration for session=${codingSessionId} responseId=${e.responseId?.slice(0, 8) ?? '<none>'} isFirstChunk=${e.isFirstChunk} isFinal=${e.isFinal}`); + } else if (defer && isCheckpointNarration) { + if (e.responseId && solicitedNarration) { + this._clearPendingSolicitedNarration(e.responseId, solicitedNarration); + this._solicitedNarrationIds.delete(e.responseId); + } + return; } else if (defer) { this._deferResponse(codingSessionId!, e.audio, e.isFirstChunk, e.isFinal, e.transcript, e.responseId, e.turnId); } else { + if (e.audio && !isCheckpointNarration) { + this._preemptCheckpointPlayback(); + } // A fresh reply is about to play live for this session. Anything // still buffered for it (earlier background updates the user never // returned to hear) must be played FIRST, in order, so nothing is @@ -1702,7 +1805,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC && !this._deferredBufferHasResponse(codingSessionId, e.responseId)) { this._flushDeferredResponse(codingSessionId); } - this._enqueueAudio(codingSessionId, e.audio, e.isFirstChunk, e.isFinal, e.transcript, e.responseId); + this._enqueueAudio(codingSessionId, e.audio, e.isFirstChunk, e.isFinal, e.transcript, e.responseId, playbackNarration); if (e.isFinal) { this._liveReplyKeys.delete(codingSessionId ?? ''); // Record this heard reply so an immediate backend re-narration @@ -1716,7 +1819,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // (dropping its next reply / misrouting this one). See // _reconcileConfirmationIndicators for the same caveat. const heardSessionId = codingSessionId ?? this._awaitingReplyForSession ?? this._shownSessionId(); - if (heardSessionId && e.transcript) { + if (!isCheckpointNarration && heardSessionId && e.transcript) { const heard = this._normalizeTranscript(e.transcript); if (heard) { const heardKey = this._sessionKey(heardSessionId); @@ -1727,7 +1830,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } // On the final chunk we have the complete assistant transcript to persist. - if (e.isFinal && e.transcript) { + if (!isCheckpointNarration && e.isFinal && e.transcript) { this._persistTurn('assistant', e.transcript); } // NOTE: a reply is marked "heard" (dedup set, pending indicator cleared) @@ -1892,16 +1995,22 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC */ private _armConnectWatchdog(): void { this._connectWatchdog.value = disposableTimeout(() => { - if (!this._isConnecting.get() || this._isConnected.get()) { + if ((!this._isConnecting.get() && !this._isReconnecting.get()) || this._isConnected.get()) { return; } this.logService.warn('[voice] connect handshake timed out; resetting voice mode'); - this.disconnect(); + this._resetFailedConnection(); + }, VoiceSessionController._CONNECT_TIMEOUT_MS); + } + + private _resetFailedConnection(notifyUser = true): void { + this.disconnect(); + if (notifyUser) { this.notificationService.notify({ severity: Severity.Warning, message: localize('voice.connectFailed', "Voice mode could not connect. Please try again."), }); - }, VoiceSessionController._CONNECT_TIMEOUT_MS); + } } /** @@ -1958,6 +2067,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._audioQueue.length = 0; this._currentPlaybackSessionId = null; this._currentPlaybackResponseId = undefined; + this._currentPlaybackNarration = undefined; this._isProcessingQueue = false; this._suppressIncomingAudio = false; this._interruptedAudioIds.clear(); @@ -1998,6 +2108,9 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._lastResponseSummaryById.clear(); this._lastNarratedText.clear(); this._pendingNarrationRetries.clear(); + this._voiceProgressListeners.clearAndDisposeAll(); + this._voiceProgressSessionByResponse.clear(); + this._lastSpokenAtBySession.clear(); for (const [narrationId, pending] of this._pendingSolicitedNarrations) { this._clearPendingSolicitedNarration(narrationId, pending); } @@ -2078,6 +2191,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._audioQueue.length = 0; this._currentPlaybackSessionId = null; this._currentPlaybackResponseId = undefined; + this._currentPlaybackNarration = undefined; this._isProcessingQueue = false; this.ttsPlaybackService.closeContext(); this.micCaptureService.stopCapture(); @@ -2095,6 +2209,9 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._solicitedNarrationIds.clear(); this._cancelledPendingNarrationIds.clear(); this._pendingNarrationRetries.clear(); + this._voiceProgressListeners.clearAndDisposeAll(); + this._voiceProgressSessionByResponse.clear(); + this._lastSpokenAtBySession.clear(); this._deferredNarrations.clear(); this._narratedPending.clear(); // Terminal disconnect (no reconnect): drop the routing target and @@ -2302,6 +2419,11 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // afterwards goes through the normal `pttUp()` path. if (this._bargeInListenActive) { this.logService.trace('[voice] pttDown: promoting passive barge-in listen to user interrupt'); + const shownSessionId = this._shownSessionId(); + if (shownSessionId) { + this._cancelVoiceProgress(shownSessionId); + } + this._preemptCheckpointPlayback(undefined, undefined, false); this._bargeInListenActive = false; // A promoted press is a deliberate interrupt, so it latches the backend // like a fresh press: clear the passive flag (kept consistent with the @@ -2324,6 +2446,9 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._startUserTurn(); this._audioQueue.length = 0; this._currentPlaybackSessionId = null; + this._currentPlaybackResponseId = undefined; + this._currentPlaybackNarration = undefined; + this._currentPlaybackFinalized = false; this._isProcessingQueue = false; this._suppressIncomingAudio = true; this.ttsPlaybackService.stopPlayback(); @@ -2344,6 +2469,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } if (this._pttHeld) { this.logService.trace('[voice] pttDown ignored: already held'); return; } + if (source === 'explicit') { + const shownSessionId = this._shownSessionId(); + if (shownSessionId) { + this._cancelVoiceProgress(shownSessionId); + } + this._preemptCheckpointPlayback(undefined, undefined, false); + } this._pttHeld = true; this._pttCurrentTurnPassive = passive; this._autoListenSuppressed = false; @@ -2377,6 +2509,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._audioQueue.length = 0; this._currentPlaybackSessionId = null; this._currentPlaybackResponseId = undefined; + this._currentPlaybackNarration = undefined; + this._currentPlaybackFinalized = false; this._isProcessingQueue = false; this._suppressIncomingAudio = true; @@ -2595,6 +2729,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } markUserCancelled(sessionId: string): void { + this._cancelVoiceProgress(sessionId); + this._preemptCheckpointPlayback(sessionId); const existing = this._userCancelledSessions.get(sessionId); if (existing) { clearTimeout(existing); } const expiry = setTimeout(() => { @@ -2766,9 +2902,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * stays open and becomes the next listening turn once playback ends * (`onPlaybackStopped` sees `_pttHeld` and stays in 'listening'). * - * Reuses the warm mic left by the previous turn's `abortPtt`, so no - * `getUserMedia` re-acquisition occurs. Idempotent: a no-op while a turn is - * already held. + * Hands-free session initialization keeps capture warm before the backend can + * send playback. Idempotent: a no-op while a turn is already held. */ private _startBargeInListen(): void { if (!this._isHandsFreeEnabled() || !this._isConnected.get() || this._pttHeld || this._autoListenHeld || this._autoListenSuppressed || !this._window) { @@ -2850,6 +2985,167 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } + private _acceptVoiceInput(text: string, sessionResource: URI): void { + this.commandService.executeCommand<IChatResponseModel | undefined>('_chat.voice.acceptInput', text).then(response => { + this.logService.info(`[voice] acceptInput completed session=${sessionResource.toString()} response=${response?.id ?? 'none'} connected=${this._isConnected.get()}`); + if (response && this._isConnected.get()) { + this._watchVoiceProgress(sessionResource, response); + } + }).catch(err => this.logService.warn('[voice] acceptInput failed:', err)); + } + + private async _sendVoiceRequest(sessionResource: URI, text: string): Promise<ChatSendResult | undefined> { + const result = await this.chatService.sendRequest(sessionResource, text, { isVoiceModeInput: this._isVoiceProgressEnabled() }).catch(err => { + this.logService.warn('[voice] Error sending transcription:', err); + return undefined; + }); + if (!result) { + return undefined; + } + + const sentResult = ChatSendResult.isQueued(result) ? result.deferred : Promise.resolve(result); + sentResult.then(async sent => { + if (ChatSendResult.isSent(sent)) { + const response = await sent.data.responseCreatedPromise; + if (this._isConnected.get()) { + this._watchVoiceProgress(sessionResource, response); + } + } + }).catch(err => this.logService.warn('[voice] Failed to watch voice response:', err)); + return result; + } + + private _watchVoiceProgress(sessionResource: URI, response: IChatResponseModel): void { + if (!this._isVoiceProgressEnabled()) { + return; + } + const disposables = new DisposableStore(); + const timer = disposables.add(new MutableDisposable()); + const seen = new Set<string>(); + const sessionId = sessionResource.toString(); + const sessionKey = this._sessionKey(sessionId); + const requestStartedAt = Date.now(); + let narratedCount = 0; + let lastCheckpointAt: number | undefined; + let nextSequence = 1; + let pending: { id: VoiceCheckpointId; value: string } | undefined; + this.logService.info(`[voice] watching progress session=${sessionId} response=${response.id} request=${response.requestId}`); + + const dispose = () => this._voiceProgressListeners.deleteAndDispose(response.id); + const nextEligibleAt = () => { + if (lastCheckpointAt !== undefined) { + return lastCheckpointAt + VoiceSessionController._VOICE_PROGRESS_INTERVAL_MS; + } + const lastSpokenAt = this._lastSpokenAtBySession.get(sessionKey); + return Math.max( + requestStartedAt + VoiceSessionController._VOICE_PROGRESS_INITIAL_DELAY_MS, + (lastSpokenAt ?? 0) + VoiceSessionController._VOICE_PROGRESS_INITIAL_DELAY_MS, + ); + }; + const flush = () => { + timer.clear(); + if (!this._isVoiceProgressEnabled()) { + dispose(); + return; + } + if (response.isComplete || response.isCanceled) { + dispose(); + return; + } + if (!pending || narratedCount >= VoiceSessionController._MAX_VOICE_PROGRESS_PER_REQUEST) { + return; + } + if (!this._isConnected.get()) { + return; + } + const canReplacePlayingCheckpoint = this._currentPlaybackNarration?.kind === 'checkpoint'; + if (this.ttsPlaybackService.isPlaying && !canReplacePlayingCheckpoint) { + return; + } + const delay = nextEligibleAt() - Date.now(); + if (delay > 0) { + timer.value = disposableTimeout(flush, delay); + return; + } + + const checkpoint = pending; + pending = undefined; + const metadata: IVoiceCheckpointNarrationMetadata = { + requestId: response.requestId, + checkpointId: checkpoint.id, + sequence: nextSequence++, + }; + const narrated = this._isConnected.get() + && this._isSameSession(sessionId, this._shownSessionId()) + && this._narrate(sessionId, 'checkpoint', checkpoint.value, undefined, metadata); + this.logService.info(`[voice] checkpoint dispatch session=${sessionId} response=${response.id} stage=${checkpoint.id} sequence=${metadata.sequence} narrated=${Boolean(narrated)}`); + if (narrated) { + narratedCount++; + lastCheckpointAt = Date.now(); + } + }; + const schedule = () => { + timer.clear(); + const delay = nextEligibleAt() - Date.now(); + if (delay <= 0) { + flush(); + } else { + timer.value = disposableTimeout(flush, delay); + } + }; + const update = () => { + if (!this._isVoiceProgressEnabled()) { + dispose(); + return; + } + if (response.isComplete || response.isCanceled) { + this._preemptCheckpointPlayback(sessionId); + dispose(); + return; + } + for (const part of response.response.value) { + if (part.kind !== 'voiceProgress' || !isVoiceCheckpointId(part.id) || seen.has(part.id)) { + continue; + } + seen.add(part.id); + pending = { id: part.id, value: part.value }; + this.logService.info(`[voice] checkpoint observed session=${sessionId} response=${response.id} stage=${part.id}`); + } + if (pending) { + schedule(); + } + }; + + disposables.add(response.onDidChange(update)); + disposables.add(autorun(reader => { + if (this._isConnected.read(reader) && pending) { + schedule(); + } + })); + disposables.add(this.ttsPlaybackService.onPlaybackStopped(() => { + if (pending) { + schedule(); + } + })); + disposables.add({ dispose: () => this._voiceProgressSessionByResponse.delete(response.id) }); + this._voiceProgressListeners.set(response.id, disposables); + this._voiceProgressSessionByResponse.set(response.id, sessionKey); + update(); + } + + private _isVoiceProgressEnabled(): boolean { + return this.configurationService.getValue<boolean>(VOICE_AGENT_PROGRESS_SETTING) === true; + } + + private _cancelVoiceProgress(sessionId?: string): void { + const sessionKey = sessionId ? this._sessionKey(sessionId) : undefined; + for (const responseId of [...this._voiceProgressListeners.keys()]) { + if (sessionKey === undefined || this._voiceProgressSessionByResponse.get(responseId) === sessionKey) { + this._voiceProgressListeners.deleteAndDispose(responseId); + } + } + } + /** * Send transcription text to the target session or active chat. */ @@ -2865,9 +3161,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (isTargetVisible) { // Target is visible — send via the chat pane directly - await this.commandService.executeCommand('_chat.voice.acceptInput', text).catch(err => { - this.logService.warn('[voice] acceptInput failed for visible target:', err); - }); + this._acceptVoiceInput(text, target); } else { // Target is NOT visible — ensure session is loaded, then send const cts = new CancellationTokenSource(); @@ -2882,14 +3176,11 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const switched = await this.commandService.executeCommand<boolean>('_chat.voice.switchToSession', target.toString()).catch(() => false); if (switched) { await new Promise(resolve => setTimeout(resolve, 200)); - await this.commandService.executeCommand('_chat.voice.acceptInput', text).catch(() => { }); + this._acceptVoiceInput(text, target); } return; } - const result = await this.chatService.sendRequest(target, text).catch(err => { - this.logService.warn('[voice] Error sending transcription to target session:', err); - return undefined; - }); + const result = await this._sendVoiceRequest(target, text); if (result && result.kind !== 'rejected') { // Surface response in floating window this._watchResponseForFloatingWindow(target); @@ -2922,9 +3213,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const currentSession = await this.commandService.executeCommand<string | undefined>('_chat.voice.getCurrentSession').catch(() => undefined); if (currentSession) { // There's an active chat widget — send to it - this.commandService.executeCommand('_chat.voice.acceptInput', text).catch(err => { - this.logService.warn('[voice] acceptInput failed for current session:', err); - }); + this._acceptVoiceInput(text, URI.parse(currentSession)); } else { // No focused chat session — find the most recent existing session // instead of creating a new one, so voice continues the conversation. @@ -2937,14 +3226,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const switched = await this.commandService.executeCommand<boolean>('_chat.voice.switchToSession', sessionResource.toString()).catch(() => false); if (switched) { await new Promise(resolve => setTimeout(resolve, 200)); - await this.commandService.executeCommand('_chat.voice.acceptInput', text).catch(err => { - this.logService.warn('[voice] acceptInput failed after switch to existing:', err); - }); + this._acceptVoiceInput(text, sessionResource); } else { // Direct send as fallback - this.chatService.sendRequest(sessionResource, text).catch(err => { - this.logService.warn('[voice] Error sending transcription to existing session:', err); - }); + await this._sendVoiceRequest(sessionResource, text); } } else { // Truly no sessions exist — create one @@ -2953,9 +3238,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC ref.dispose(); // Switch to the new session so the user sees the response this.commandService.executeCommand('_chat.voice.switchToSession', resource.toString()).catch(() => { /* pane may not exist */ }); - this.chatService.sendRequest(resource, text).catch(err => { - this.logService.warn('[voice] Error sending transcription to new session:', err); - }); + await this._sendVoiceRequest(resource, text); } } @@ -3536,7 +3819,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } else if (bufferRetainedUnderPress) { this.logService.trace(`[voice] activate skip: buffered reply retained under held press for ${key.slice(-32)}`); } else { - this._narrate(key, narratable.kind, narratable.text, undefined, narratable.pending); + this._narrate(key, narratable.kind, narratable.text, undefined, undefined, narratable.confirmationType, narratable.pending); } if (narratable.kind === 'response') { // A request being SENT is not the reply being heard: keep the @@ -3561,7 +3844,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } /** Ask the backend to narrate a session's pending item, de-duped by the exact text last spoken for it ({@link _lastNarratedText}) and by any in-flight request for the same text ({@link _pendingSolicitedNarrations}); the single narration trigger for both live and on-focus paths. Returns `true` when a request was actually SENT - NOT that the reply was heard (the audio may still be dropped/deferred/never arrive). The reply is marked narrated and its pending indicator cleared only once its audio finalizes (see {@link _markNarrationHeard}). */ - private _narrate(sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, pending?: { pendingId: string }): boolean { + private _narrate(sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata, confirmationType?: VoiceConfirmationType, pending?: { pendingId: string }): boolean { if (!text) { return false; } @@ -3579,22 +3862,36 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // and on the pending id so a *different* form that happens to render the // same prompt is not mistaken for the one already in flight. const sessionKey = this._sessionKey(sessionId); - const identity = this._narratableIdentity({ text, pending }); + const identity = this._narratableIdentity({ text, pending, confirmationType }); for (const s of this._pendingSolicitedNarrations.values()) { if (s.kind === kind && this._narratableIdentity(s) === identity && this._sessionKey(s.sessionId) === sessionKey) { return false; } } + // A response only supersedes checkpoint playback once non-empty response audio arrives. + if (kind !== 'response') { + this._preemptCheckpointPlayback(); + } + if (kind === 'confirmation') { + this._sendContext(); + this.voiceClientService.flushSessionContext(); + } this.logService.trace(`[voice] narrate kind=${kind} id=${sessionId.slice(-32)}`); - const narrationId = this.voiceClientService.requestNarration(sessionId, kind, text, reuseId, pending); + const narrationId = this.voiceClientService.requestNarration(sessionId, kind, text, reuseId, checkpoint, confirmationType, pending); if (!narrationId) { + if (kind === 'checkpoint') { + return false; + } // Socket was closed, so nothing was sent: don't touch playback/listening // state (that would tear down a freshly-entered listen on connect). // Remember the item so the next session_init replays it after resume; // leaving the dedup unset lets a later focus/state event retry too. - this._pendingNarrationRetries.set(sessionId, kind); + this._pendingNarrationRetries.set(sessionId, { kind, text, confirmationType, pending }); return false; } + if (kind === 'checkpoint') { + this.logService.trace(`[voice][checkpoint] requested narration_id=${narrationId} request_id=${checkpoint?.requestId ?? '<unknown>'} phase=${checkpoint?.checkpointId ?? '<unknown>'} sequence=${checkpoint?.sequence ?? 0} seed=${JSON.stringify(text)}`); + } // The narration audio is now inbound. Get out of listening/auto-listen so // the echoed audio isn't suppressed (or captured as the user's own turn) // while PTT/mic capture is active. Done here so every narration path @@ -3636,6 +3933,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC kind, text, pending, + checkpoint, + confirmationType, audioStartTimer, hasReceivedAudio: false, }); @@ -3693,6 +3992,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._pendingSolicitedNarrations.delete(narrationId); } + private _notifyCheckpointPlaybackComplete(sessionId: string, narrationId: string, narration: IPlaybackNarration | undefined): void { + if (narration?.kind === 'checkpoint' && narration.playbackId) { + this.voiceClientService.sendNarrationPlaybackComplete(sessionId, narrationId, narration.playbackId); + } + } + private _restoreVoiceStateAfterNarrationTimeout(): void { if (this.ttsPlaybackService.isPlaying || this._audioQueue.length > 0 || this._currentPlaybackSessionId !== null || this._pttHeld) { return; @@ -3722,7 +4027,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (solicited.kind === 'response') { this._lastNarratedText.set(sessionKey, solicited.text); this._clearPendingResponse(sessionKey); - } else { + } else if (solicited.kind === 'confirmation') { // Confirmation heard: mark THIS occurrence spoken so a mere refocus // while it is still pending doesn't re-narrate it (see // _activateShownSession). Cleared when the session leaves @@ -3742,7 +4047,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * `busy` means the backend could not play right now (user speaking / reply in * flight); it will nudge us with `narration_unblocked` when the guard clears, * so we stop tracking the id as in-flight and remember it for a revalidated - * retry. `invalid` is terminal, so we drop it entirely. + * retry. `invalid` and legacy `suppressed` are terminal, so we drop them entirely. */ private _handleNarrationAck(e: IVoiceNarrationAck): void { if (e.disposition === 'accepted') { @@ -3754,11 +4059,11 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._clearPendingSolicitedNarration(e.narrationId, solicited); } this._solicitedNarrationIds.delete(e.narrationId); - if (e.disposition === 'invalid') { - this.logService.trace(`[voice] narration_ack invalid id=${e.narrationId.slice(0, 8)} reason=${e.reason ?? '<none>'}; dropping`); + if (e.disposition === 'invalid' || e.disposition === 'suppressed') { + this.logService.trace(`[voice] narration_ack ${e.disposition} id=${e.narrationId.slice(0, 8)} reason=${e.reason ?? '<none>'}; dropping`); this._clearDeferred(key); if (solicited) { - this.telemetryService.publicLog2<VoiceNarrationDroppedEvent, VoiceNarrationDroppedClassification>('voiceNarrationDropped', { kind: solicited.kind, reason: 'invalid' }); + this.telemetryService.publicLog2<VoiceNarrationDroppedEvent, VoiceNarrationDroppedClassification>('voiceNarrationDropped', { kind: solicited.kind, reason: e.disposition }); } return; } @@ -3766,8 +4071,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const kind = solicited?.kind; const text = solicited?.text; if (kind && text) { + if (kind === 'checkpoint') { + this.logService.trace(`[voice] narration_ack busy id=${e.narrationId.slice(0, 8)}; dropping checkpoint`); + return; + } this.logService.trace(`[voice] narration_ack busy id=${e.narrationId.slice(0, 8)} reason=${e.reason ?? '<none>'}; deferring`); - this._deferredNarrations.set(key, { narrationId: e.narrationId, kind, text, reuseNarrationId: true, pending: solicited?.pending }); + this._deferredNarrations.set(key, { narrationId: e.narrationId, kind, text, reuseNarrationId: true, confirmationType: solicited.confirmationType, pending: solicited.pending }); this.telemetryService.publicLog2<VoiceNarrationDeferredEvent, VoiceNarrationDeferredClassification>('voiceNarrationDeferred', { kind, reason: 'busy' }); } } @@ -3781,6 +4090,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private _handleNarrationInterrupted(e: IVoiceNarrationSignal): void { const solicited = this._pendingSolicitedNarrations.get(e.narrationId); if (solicited) { + if (solicited.kind === 'checkpoint') { + this._preemptCheckpointPlayback(e.codingSessionId, e.narrationId); + return; + } this._deferInterruptedNarration(e.narrationId, solicited); this.logService.trace(`[voice] narration_interrupted id=${e.narrationId.slice(0, 8)}; deferring for revalidation`); this.telemetryService.publicLog2<VoiceNarrationDeferredEvent, VoiceNarrationDeferredClassification>('voiceNarrationDeferred', { kind: solicited.kind, reason: 'interrupted' }); @@ -3792,12 +4105,16 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private _deferInterruptedNarration(narrationId: string, solicited: IPendingSolicitedNarration): void { this._clearPendingSolicitedNarration(narrationId, solicited); this._solicitedNarrationIds.delete(narrationId); + if (solicited.kind === 'checkpoint') { + return; + } this._deferredNarrations.set(this._sessionKey(solicited.sessionId), { narrationId, kind: solicited.kind, text: solicited.text, reuseNarrationId: false, pending: solicited.pending, + confirmationType: solicited.confirmationType, }); } @@ -3826,7 +4143,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC resource = undefined; } const narratable = resource ? this._currentNarratable(resource) : undefined; - if (!narratable || narratable.kind !== deferred.kind) { + if (!narratable + || narratable.kind !== deferred.kind + || narratable.text !== deferred.text + || (deferred.kind === 'confirmation' && narratable.confirmationType !== deferred.confirmationType)) { this.logService.trace(`[voice] deferred narration for ${sessionKey.slice(-32)} no longer warranted; dropping`); this._clearDeferred(sessionKey); this.telemetryService.publicLog2<VoiceNarrationDroppedEvent, VoiceNarrationDroppedClassification>('voiceNarrationDropped', { kind: deferred.kind, reason: 'stale' }); @@ -3849,7 +4169,29 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const reuseId = deferred.reuseNarrationId && this._narratableIdentity(narratable) === this._narratableIdentity(deferred) ? deferred.narrationId : undefined; this.logService.trace(`[voice] retrying deferred narration for ${sessionKey.slice(-32)} reuse=${!!reuseId}`); this._clearDeferred(sessionKey); - return this._narrate(sessionKey, narratable.kind, narratable.text, reuseId, narratable.pending); + return this._narrate(sessionKey, narratable.kind, narratable.text, reuseId, undefined, narratable.confirmationType, narratable.pending); + } + + private _retryPendingNarration(sessionId: string, pending: IVoiceNarratable): boolean { + let resource: URI; + try { + resource = URI.parse(sessionId); + } catch { + this.logService.trace(`[voice] queued confirmation for invalid session id; dropping`); + return false; + } + const current = this._currentNarratable(resource); + if (!current + || current.kind !== pending.kind + || this._narratableIdentity(current) !== this._narratableIdentity(pending)) { + this.logService.trace(`[voice] queued narration for ${sessionId.slice(-32)} no longer matches current state; dropping`); + return false; + } + if (current.kind !== 'response' && this._shouldDeferForSession(this._sessionKey(sessionId))) { + this.logService.trace(`[voice] queued narration for ${sessionId.slice(-32)} is no longer shown; dropping`); + return false; + } + return this._narrate(sessionId, current.kind, current.text, undefined, undefined, current.confirmationType, current.pending); } /** Drop a deferred narration. */ @@ -3858,7 +4200,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } /** The pending item a session would narrate now (waiting confirmation prompt or completed reply summary), from the resident model or cached summary/status; returns undefined (kicking off a load) if a confirmation's detail isn't ready. */ - private _currentNarratable(resource: URI): { kind: VoiceNarrationKind; text: string; pending?: { pendingId: string } } | undefined { + private _currentNarratable(resource: URI): IVoiceNarratable | undefined { const model = this.chatService.getSession(resource); if (model) { // A question form is narrated from the structured payload, not from @@ -3871,7 +4213,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } const info = this._getAgentStateInfo(model); if (info.state === 'waiting_for_confirmation' && info.detail) { - return { kind: 'confirmation', text: info.detail }; + return { kind: 'confirmation', text: info.detail, confirmationType: info.confirmation_type }; } if (info.state === 'idle' && info.last_response_summary) { return { kind: 'response', text: info.last_response_summary }; @@ -3923,8 +4265,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * prompt, so keying "already heard" on text alone swallows the second one. * Text is only a fallback for narratables with no structured pending. */ - private _narratableIdentity(narratable: { text: string; pending?: { pendingId: string } }): string { - return narratable.pending ? `#${narratable.pending.pendingId}` : narratable.text; + private _narratableIdentity(narratable: { text: string; pending?: { pendingId: string }; confirmationType?: VoiceConfirmationType }): string { + return narratable.pending ? `#${narratable.pending.pendingId}` : `${narratable.confirmationType ?? ''}:${narratable.text}`; } /** @@ -3937,7 +4279,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * debounce window before the backend's mirror catches up, which is by * definition first sighting. */ - private _questionNarratable(model: IChatModel | undefined | null): { kind: VoiceNarrationKind; text: string; pending: { pendingId: string } } | undefined { + private _questionNarratable(model: IChatModel | undefined | null): { kind: 'question'; text: string; pending: { pendingId: string } } | undefined { const pending = model ? this._buildPendingPayload(model) : undefined; const question = pending?.type === 'questions' ? pending.questions?.[0] : undefined; if (!pending || !question) { @@ -4539,20 +4881,95 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // --- Audio FIFO queue --- + private _preemptCheckpointPlayback(sessionId?: string, targetNarrationId?: string, stopActivePlayback = true): void { + const sessionKey = sessionId ? this._sessionKey(sessionId) : undefined; + const shouldPreempt = (candidateSessionId: string | undefined, candidateNarrationId: string | undefined, narration: IPlaybackNarration | undefined) => { + return narration?.kind === 'checkpoint' + && (targetNarrationId === undefined || candidateNarrationId === targetNarrationId) + && (sessionKey === undefined || (candidateSessionId !== undefined && this._sessionKey(candidateSessionId) === sessionKey)); + }; + + const interruptedIds = new Set<string>(); + for (let i = this._audioQueue.length - 1; i >= 0; i--) { + const queued = this._audioQueue[i]; + if (!shouldPreempt(queued.sessionId, queued.responseId, queued.narration)) { + continue; + } + if (queued.responseId) { + interruptedIds.add(queued.responseId); + } + this._audioQueue.splice(i, 1); + } + for (const [candidateNarrationId, pending] of this._pendingSolicitedNarrations) { + if (pending.kind !== 'checkpoint' + || (targetNarrationId !== undefined && candidateNarrationId !== targetNarrationId) + || (sessionKey !== undefined && this._sessionKey(pending.sessionId) !== sessionKey)) { + continue; + } + interruptedIds.add(candidateNarrationId); + this._clearPendingSolicitedNarration(candidateNarrationId, pending); + this._solicitedNarrationIds.delete(candidateNarrationId); + } + for (const narrationId of interruptedIds) { + this._rememberInterruptedAudioId(narrationId); + } + + const activeCheckpointMatches = shouldPreempt(this._currentPlaybackSessionId ?? undefined, this._currentPlaybackResponseId, this._currentPlaybackNarration); + if (activeCheckpointMatches && this._currentPlaybackResponseId) { + this._rememberInterruptedAudioId(this._currentPlaybackResponseId); + } + if (activeCheckpointMatches && stopActivePlayback) { + this._stopCurrentPlaybackAsInterrupted(); + } + } + private _interruptAssistantPlayback(): void { + const interruptedSessionId = this._currentPlaybackSessionId ?? this._shownSessionId(); + if (interruptedSessionId) { + this._cancelVoiceProgress(interruptedSessionId); + } + this._preemptCheckpointPlayback(undefined, undefined, false); this._rememberInterruptedPlaybackIds(); this._telemetryTtsInterrupted = this._telemetryTtsInterrupted || this.ttsPlaybackService.isPlaying; this._audioQueue.length = 0; this._currentPlaybackSessionId = null; + this._currentPlaybackFinalized = false; this._isProcessingQueue = false; this._suppressIncomingAudio = true; this.ttsPlaybackService.stopPlayback(); // Clear any narration id left over if stopPlayback didn't fire onPlaybackStopped // (e.g. nothing was playing), so a later stray stop can't consume a stale id. this._currentPlaybackResponseId = undefined; + this._currentPlaybackNarration = undefined; this.voicePlaybackService.notifyPlaybackEnd(undefined); } + private _stopCurrentPlaybackAsInterrupted(): void { + if (this.ttsPlaybackService.isPlaying) { + this._telemetryTtsInterrupted = true; + this.ttsPlaybackService.stopPlayback(); + return; + } + + // The controller claims the playback slot before WebAudio finishes decoding. + // Stopping during that window emits no playback-stopped event, so close the + // lifecycle here instead of leaking interruption state into the next reply. + this.ttsPlaybackService.stopPlayback(); + this._telemetryTtsInterrupted = false; + this._currentPlaybackSessionId = null; + this._currentPlaybackResponseId = undefined; + this._currentPlaybackNarration = undefined; + this._currentPlaybackFinalized = false; + this.voicePlaybackService.notifyPlaybackEnd(undefined); + if (this._audioQueue.length > 0) { + if (!this._isProcessingQueue) { + this._processQueue(); + } + } else { + this._restoreVoiceStateAfterNarrationTimeout(); + } + } + /** * Stop reading an actionable pending request aloud once it has been resolved * (e.g. the user pressed Allow, or answered the form with the mouse, before @@ -4629,12 +5046,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // "heard"; that handler then resets the slot, drains the queue and // restores idle / hands-free listening. if (this._currentPlaybackResponseId !== undefined && cancelledIds.has(this._currentPlaybackResponseId)) { - this._telemetryTtsInterrupted = true; - this.ttsPlaybackService.stopPlayback(); + this._stopCurrentPlaybackAsInterrupted(); } } - private _enqueueAudio(sessionId: string | undefined, audio: string, isFirstChunk: boolean, isFinal: boolean, transcript: string | undefined, responseId?: string): void { + private _enqueueAudio(sessionId: string | undefined, audio: string, isFirstChunk: boolean, isFinal: boolean, transcript: string | undefined, responseId?: string, narration?: IPlaybackNarration): void { + const isCheckpointNarration = narration?.kind === 'checkpoint'; // An incoming response frame means the assistant is actively replying, so // cancel any pending auto-listen. Otherwise a debounced listen scheduled // when the previous session's playback stopped can fire mid-response and @@ -4659,7 +5076,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } - if (isFirstChunk) { + if (isFirstChunk && !isCheckpointNarration) { this._clearAwaitingReply(); } @@ -4678,7 +5095,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // too - forcing a fresh turn once the current one finishes. const continuationOfCurrent = sameSession && !isFirstChunk && !this._currentPlaybackFinalized; if ((nothingPlaying && this._audioQueue.length === 0) || continuationOfCurrent) { - this._playChunk(sessionId, audio, isFirstChunk, isFinal, transcript, responseId); + this._playChunk(sessionId, audio, isFirstChunk, isFinal, transcript, responseId, narration); return; } @@ -4694,7 +5111,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC !e.finalized && (e.sessionId === sessionId || (e.sessionId === undefined && sessionId === undefined)) ); if (!entry) { - entry = { sessionId, responseId, finalized: false, chunks: [] }; + entry = { sessionId, responseId, narration, finalized: false, chunks: [] }; this._audioQueue.push(entry); } entry.chunks.push({ audio, isFirstChunk, isFinal, transcript }); @@ -4708,7 +5125,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } - private _playChunk(sessionId: string | undefined, audio: string, isFirstChunk: boolean, isFinal: boolean, transcript: string | undefined, responseId?: string): void { + private _playChunk(sessionId: string | undefined, audio: string, isFirstChunk: boolean, isFinal: boolean, transcript: string | undefined, responseId?: string, narration?: IPlaybackNarration): void { + const isCheckpointNarration = narration?.kind === 'checkpoint'; // Streaming pipeline sends a monotonically-growing transcript on every // chunk. On the FIRST chunk of a response we push a fresh assistant // turn into the rolling buffer; on subsequent chunks we REPLACE that @@ -4736,11 +5154,14 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Track the response now occupying the slot so onPlaybackStopped can // mark it heard once its audio truly finishes (not merely queued). this._currentPlaybackResponseId = responseId; + this._currentPlaybackNarration = narration; // A same-session frame arriving after the final chunk is a NEW // response and must be serialized (see `_enqueueAudio`). this._currentPlaybackFinalized = isFinal; this._clearAutoListenTimer(); - this._replyPlayedSinceSend = true; + if (!isCheckpointNarration) { + this._replyPlayedSinceSend = true; + } this._voiceState.set('speaking', undefined); this._statusText.set('Speaking...', undefined); this.ttsPlaybackService.playAudioChunk(audio, isFinal, this._window!); @@ -4756,14 +5177,20 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this.micCaptureService.suppressUntil(Date.now() + 800); } } else if (!speakResponsesEnabled) { - this._replyPlayedSinceSend = true; + if (!isCheckpointNarration) { + this._replyPlayedSinceSend = true; + } if (isFinal) { this._currentPlaybackSessionId = null; this._currentPlaybackResponseId = undefined; + this._currentPlaybackNarration = undefined; // Speech is disabled so no audio plays and onPlaybackStopped won't // fire: the reply is nonetheless consumed, so mark the solicited // narration heard here to clear its pending indicator. if (responseId) { + if (sessionId) { + this._notifyCheckpointPlaybackComplete(sessionId, responseId, narration); + } this._markNarrationHeard(responseId); } // Avoid re-entering _processQueue if we're already inside its @@ -4797,7 +5224,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC while (this._currentPlaybackSessionId === null && this._audioQueue.length > 0) { const next = this._audioQueue.shift()!; for (const chunk of next.chunks) { - this._playChunk(next.sessionId, chunk.audio, chunk.isFirstChunk, chunk.isFinal, chunk.transcript, next.responseId); + this._playChunk(next.sessionId, chunk.audio, chunk.isFirstChunk, chunk.isFinal, chunk.transcript, next.responseId, next.narration); } } this._isProcessingQueue = false; @@ -4867,8 +5294,11 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } /** React to a session reaching a narratable state. If it's the shown session, speak it now; a completed reply on a background session instead shows the sessions-list pending indicator and is read when focused. A new turn (`thinking`) clears both the dedup and any stale pending indicator. */ - private _handleNarratableStateChange(sessionId: string, currentState: string, detail: string | undefined, lastResponseSummary: string | undefined, shownNow: string | undefined): void { + private _handleNarratableStateChange(sessionId: string, currentState: string, detail: string | undefined, lastResponseSummary: string | undefined, shownNow: string | undefined, confirmationType?: VoiceConfirmationType): void { const sessionKey = this._sessionKey(sessionId); + if (currentState === 'idle' || currentState === 'waiting_for_confirmation') { + this._cancelVoiceProgress(sessionId); + } if (currentState === 'thinking') { this._clearLastNarratedText(sessionKey); // A new turn supersedes any completed reply that was waiting to be @@ -4920,9 +5350,9 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // prose, which is what it got before. const question = this._questionNarratable(this._modelForSession(sessionId)); if (question) { - this._narrate(sessionId, question.kind, question.text, undefined, question.pending); + this._narrate(sessionId, question.kind, question.text, undefined, undefined, undefined, question.pending); } else { - this._narrate(sessionId, 'confirmation', detail); + this._narrate(sessionId, 'confirmation', detail, undefined, undefined, confirmationType); } } } @@ -4966,7 +5396,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Same pendingId test as the per-session path: two forms asking the same // things have identical detail, so only the id distinguishes them. const detailOnly = !stateChanged && change.currentState === 'waiting_for_confirmation' - && (change.fromDetail !== detail || change.fromPendingId !== change.pendingId); + && (change.fromDetail !== detail || change.fromPendingId !== change.pendingId || change.fromConfirmationType !== change.confirmationType); // A summary that appeared/changed while the session stayed idle is a // real narratable change even though the coarse state didn't move. const responseSummaryOnly = !stateChanged && change.currentState === 'idle' && !!summary && change.fromResponseSummary !== summary; @@ -4995,7 +5425,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } this._sendContext(); - this.logService.trace(`[voice] emitting ${netChanges.length} settled stateChange(s): ${netChanges.map(({ change, detailOnly }) => `${change.label}:${change.currentState}${detailOnly ? ' (detail-only)' : ''}`).join(', ')}`); this.voiceClientService.flushSessionContext(); // Speak the settled item for the shown session; a background session's item // waits until the user focuses it. Both this coalesced path and the direct @@ -5003,8 +5432,9 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // surfaced only by the latter are covered too. const shownNow = this._shownSessionId(); for (const { change } of netChanges) { - this._handleNarratableStateChange(change.sessionId, change.currentState, change.detail, change.lastResponseSummary, shownNow); + this._handleNarratableStateChange(change.sessionId, change.currentState, change.detail, change.lastResponseSummary, shownNow, change.confirmationType); } + this.logService.trace(`[voice] emitting ${netChanges.length} settled stateChange(s): ${netChanges.map(({ change, detailOnly }) => `${change.label}:${change.currentState}${detailOnly ? ' (detail-only)' : ''}`).join(', ')}`); for (const { change } of netChanges) { // Persist as a coding_event in the local timeline so // "session X went from thinking → waiting_for_confirmation" @@ -5079,7 +5509,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } const sessions = this.agentSessionsService.model.sessions.filter(s => !s.isArchived()); - const stateChanges: { sessionId: string; currentState: string; label: string; detail?: string; lastResponseSummary?: string }[] = []; + const stateChanges: { sessionId: string; currentState: string; label: string; detail?: string; confirmationType?: VoiceConfirmationType; lastResponseSummary?: string }[] = []; const processedResources = new Set<string>(); const waitingSessionIds = new Set<string>(); @@ -5089,6 +5519,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const model = this.chatService.getSession(s.resource); let currentState: string; let detail: string | undefined; + let confirmationType: VoiceConfirmationType | undefined; let lastResponseSummary: string | undefined; if (model) { const info = this._getAgentStateInfo(model); @@ -5097,6 +5528,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // resident with a proper summary, so drop the pending idle deferral. currentState = this._effectiveResidentState(sessionId, info); detail = info.detail; + confirmationType = info.confirmation_type; lastResponseSummary = currentState === info.state ? info.last_response_summary : undefined; // Capture the summary while resident so a later completion after // disposal can still narrate. @@ -5120,7 +5552,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const prev = this._prevSessionStates.get(sessionId); const isStateChange = prev !== undefined && prev.state !== currentState && currentState !== 'unknown'; const pendingId = currentState === 'waiting_for_confirmation' ? this._pendingIdFor(sessionId) : ''; - const isDetailChange = !isStateChange && prev !== undefined && currentState === 'waiting_for_confirmation' && ((detail ?? '') !== prev.detail || pendingId !== prev.pendingId); + const isDetailChange = !isStateChange && prev !== undefined && currentState === 'waiting_for_confirmation' + && ((detail ?? '') !== prev.detail || pendingId !== prev.pendingId || confirmationType !== prev.confirmationType); // Arm the awaiting-summary marker on a genuine new turn so this run's // completion is later recognized as new (see autorun for rationale). @@ -5163,14 +5596,14 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (isDetailChange) { this.voiceClientService.invalidateSessionCache(sessionId); } - stateChanges.push({ sessionId, currentState, label: s.label || 'Untitled session', detail, lastResponseSummary }); + stateChanges.push({ sessionId, currentState, label: s.label || 'Untitled session', detail, confirmationType, lastResponseSummary }); } } if (currentState !== 'unknown') { // Preserve a known summary rather than clobbering with '' so a // model unload→reload can't manufacture a fresh-reply transition. const rememberedSummary = normalizedSummary || this._lastResponseSummaryById.get(sessionId) || prev?.lastResponseSummary || ''; - this._prevSessionStates.set(sessionId, { state: currentState, detail: detail ?? '', pendingId, lastResponseSummary: rememberedSummary }); + this._prevSessionStates.set(sessionId, { state: currentState, detail: detail ?? '', pendingId, confirmationType, lastResponseSummary: rememberedSummary }); } if (currentState === 'waiting_for_confirmation') { waitingSessionIds.add(sessionId); @@ -5186,12 +5619,14 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const info = this._getAgentStateInfo(chatModel); const currentState = info.state; const detail = info.detail; + const confirmationType = info.confirmation_type; const lastResponseSummary = info.last_response_summary; const prev = this._prevSessionStates.get(key); const isStateChange = prev !== undefined && prev.state !== currentState && currentState !== 'unknown'; const pendingId = currentState === 'waiting_for_confirmation' ? this._pendingIdFor(key) : ''; - const isDetailChange = !isStateChange && prev !== undefined && currentState === 'waiting_for_confirmation' && ((detail ?? '') !== prev.detail || pendingId !== prev.pendingId); + const isDetailChange = !isStateChange && prev !== undefined && currentState === 'waiting_for_confirmation' + && ((detail ?? '') !== prev.detail || pendingId !== prev.pendingId || confirmationType !== prev.confirmationType); // Arm the awaiting-summary marker on a genuine new turn. if (isStateChange && currentState === 'thinking' && !this._eagerModelLoading.has(key)) { @@ -5210,11 +5645,11 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (isDetailChange) { this.voiceClientService.invalidateSessionCache(key); } - stateChanges.push({ sessionId: key, currentState, label: chatModel.title || 'Chat', detail, lastResponseSummary }); + stateChanges.push({ sessionId: key, currentState, label: chatModel.title || 'Chat', detail, confirmationType, lastResponseSummary }); } if (currentState !== 'unknown') { const rememberedSummary = normalizedSummary || this._lastResponseSummaryById.get(key) || prev?.lastResponseSummary || ''; - this._prevSessionStates.set(key, { state: currentState, detail: detail ?? '', pendingId, lastResponseSummary: rememberedSummary }); + this._prevSessionStates.set(key, { state: currentState, detail: detail ?? '', pendingId, confirmationType, lastResponseSummary: rememberedSummary }); } if (currentState === 'waiting_for_confirmation') { waitingSessionIds.add(key); @@ -5238,7 +5673,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // are spoken on focus. const shownNow = this._shownSessionId(); for (const change of stateChanges) { - this._handleNarratableStateChange(change.sessionId, change.currentState, change.detail, change.lastResponseSummary, shownNow); + this._handleNarratableStateChange(change.sessionId, change.currentState, change.detail, change.lastResponseSummary, shownNow, change.confirmationType); } if (stateChanges.length > 0) { @@ -5335,6 +5770,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const cachedSummary = fallbackState === 'idle' ? this._lastResponseSummaryById.get(sessionIdStr) : undefined; return { id: sessionIdStr, + ...(s.label ? { label: s.label } : {}), is_active: isActive, agent_state: scoped.state, ...(cachedSummary ? { last_response_summary: cachedSummary } : {}), @@ -5360,9 +5796,11 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const pending = this._buildPendingPayload(model); return { id: s.resource.toString(), + ...(s.label ? { label: s.label } : {}), is_active: isActive, agent_state: scoped.state, ...(!scoped.hideConfirmationDetail && stateInfo.detail ? { agent_state_detail: stateInfo.detail } : {}), + ...(!scoped.hideConfirmationDetail && stateInfo.confirmation_type ? { confirmation_type: stateInfo.confirmation_type } : {}), ...(shipSummary ? { last_response_summary: shipSummary } : {}), ...(pending ? { pending } : {}), }; @@ -5386,32 +5824,22 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const pending = this._buildPendingPayload(chatModel); sessionList.push({ id: key, + ...(chatModel.title ? { label: chatModel.title } : {}), is_active: isActive, agent_state: scoped.state, ...(!scoped.hideConfirmationDetail && stateInfo.detail ? { agent_state_detail: stateInfo.detail } : {}), + ...(!scoped.hideConfirmationDetail && stateInfo.confirmation_type ? { confirmation_type: stateInfo.confirmation_type } : {}), ...(stateInfo.last_response_summary ? { last_response_summary: stateInfo.last_response_summary } : {}), ...(pending ? { pending } : {}), }); } - // Try to get active session from chatViewPane via command - let activeSession: { id: string; last_message: string | null } | undefined; - try { - // This is fire-and-forget; the sync command bridge populates active_session - // For now, we omit active_session when called from controller - // (the chatViewPane's context already had this, the floating window didn't) - } catch { - // ignore - } - - const context: IVoiceSessionContext = { + // `active_session` is not sent: the per-session `is_active` flag already + // names the focused session, and the backend keys the marker off it. + return { sessions: sessionList, display_locale: this._window?.navigator.language || 'en-US', }; - if (activeSession) { - context.active_session = activeSession; - } - return context; } /** @@ -5550,7 +5978,352 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC return stateInfo.state; } - private _getAgentStateInfo(model: IChatModel | undefined | null): { state: string; detail?: string; last_response_summary?: string } { + private _visibleConfirmationText(value: string | IMarkdownString | undefined, maxLength = VoiceSessionController._MAX_CONFIRMATION_FIELD_CHARS): string { + if (!value) { + return ''; + } + const plainText = renderAsPlaintext(typeof value === 'string' ? { value } : value, { useLinkFormatter: true }).replace(/\s+/g, ' ').trim(); + if (plainText.length <= maxLength) { + return plainText; + } + const prefix = plainText.slice(0, maxLength - 3); + const wordBoundary = prefix.lastIndexOf(' '); + const truncated = wordBoundary > Math.floor(maxLength * 0.6) ? prefix.slice(0, wordBoundary) : prefix; + return localize('voice.confirmation.truncated', "{0}...", truncated); + } + + private _boundedConfirmationLines(lines: readonly string[], fallback: string): string { + const result: string[] = []; + for (const line of lines.filter(Boolean)) { + const candidate = [...result, line].join('\n'); + if (candidate.length > VoiceSessionController._MAX_CONFIRMATION_NARRATION_CHARS) { + break; + } + result.push(line); + } + return result.join('\n') || fallback; + } + + private _visibleQuestionnaireFromCarousel(carousel: IChatQuestionCarousel, includeDetails: boolean): IVisibleVoiceQuestionnaire { + return { + context: carousel.message, + questions: carousel.questions.map(question => ({ + prompt: question.message ?? (question.title !== question.id ? question.title : undefined), + details: includeDetails ? question.description ?? question.detailedMessage : undefined, + options: (question.options ?? []).map(option => option.label), + allowFreeformInput: question.allowFreeformInput !== false, + })), + }; + } + + private _visibleQuestionnaireFromToolInvocation(toolInvocation: IChatToolInvocation): IVisibleVoiceQuestionnaire | undefined { + if (!isPendingVoiceQuestionnaireInvocation(toolInvocation)) { + return undefined; + } + const state = toolInvocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation && state.type !== IChatToolInvocation.StateKind.WaitingForPostApproval) { + return undefined; + } + const parameters = state.parameters; + if (!isObject(parameters) || !hasOwn(parameters, 'questions') || !Array.isArray(parameters.questions) || parameters.questions.length === 0) { + return undefined; + } + + return { + questions: parameters.questions.map(rawQuestion => { + if (!isObject(rawQuestion)) { + return { options: [], allowFreeformInput: true }; + } + const prompt = hasOwn(rawQuestion, 'question') && typeof rawQuestion.question === 'string' + ? rawQuestion.question + : undefined; + const options: string[] = []; + if (hasOwn(rawQuestion, 'options') && Array.isArray(rawQuestion.options)) { + for (const rawOption of rawQuestion.options) { + if (!isObject(rawOption) || !hasOwn(rawOption, 'label') || typeof rawOption.label !== 'string') { + continue; + } + const description = hasOwn(rawOption, 'description') && typeof rawOption.description === 'string' + ? rawOption.description + : undefined; + options.push(description ? `${rawOption.label} - ${description}` : rawOption.label); + } + } + const allowFreeformInput = !(hasOwn(rawQuestion, 'allowFreeformInput') && rawQuestion.allowFreeformInput === false); + return { prompt, options, allowFreeformInput }; + }), + }; + } + + private _formatQuestionnaireNarration(questionnaire: IVisibleVoiceQuestionnaire): string | undefined { + const fallback = localize('voice.questionnaire.fallback', "I need your input in the open questionnaire."); + if (questionnaire.questions.length === 0) { + return undefined; + } + + const lines = [ + questionnaire.questions.length === 1 + ? localize('voice.questionnaire.single', "questionnaire: 1 question") + : localize('voice.questionnaire.multiple', "questionnaire: {0} questions", questionnaire.questions.length), + ]; + const context = this._visibleConfirmationText(questionnaire.context, 220); + if (context) { + lines.push(localize('voice.questionnaire.context', "context: {0}", context)); + } + + let includedQuestions = 0; + const questionLimit = Math.min(questionnaire.questions.length, VoiceSessionController._MAX_QUESTIONNAIRE_QUESTIONS); + for (let index = 0; index < questionLimit; index++) { + const question = questionnaire.questions[index]; + const prompt = this._visibleConfirmationText(question.prompt); + const questionLines = [ + localize('voice.questionnaire.question', "{0}. {1}", index + 1, prompt || fallback), + ]; + const description = this._visibleConfirmationText(question.details, 180); + if (description && description !== prompt) { + questionLines.push(localize('voice.questionnaire.description', "details: {0}", description)); + } + + const visibleOptions = question.options + .map(option => this._visibleConfirmationText(option, 160)) + .filter(Boolean); + if (visibleOptions.length > 0) { + const includedOptions = visibleOptions.slice(0, VoiceSessionController._MAX_QUESTIONNAIRE_OPTIONS); + const omittedOptions = visibleOptions.length - includedOptions.length; + let optionsText = includedOptions.join('; '); + if (omittedOptions > 0) { + optionsText = localize('voice.questionnaire.moreOptions', "{0}; {1} more options", optionsText, omittedOptions); + } + if (question.allowFreeformInput) { + optionsText = localize('voice.questionnaire.customOption', "{0}; a custom response is also available", optionsText); + } + questionLines.push(localize('voice.questionnaire.options', "options: {0}", optionsText)); + } else { + questionLines.push(localize('voice.questionnaire.freeform', "response: enter a free-form answer in GitHub Copilot")); + } + + const remainingAfterCandidate = questionnaire.questions.length - (includedQuestions + 1); + const reservedSuffix = remainingAfterCandidate > 0 + ? remainingAfterCandidate === 1 + ? localize('voice.questionnaire.oneOmitted', "1 more question is open in GitHub Copilot.") + : localize('voice.questionnaire.manyOmitted', "{0} more questions are open in GitHub Copilot.", remainingAfterCandidate) + : localize('voice.questionnaire.open', "The questionnaire is open in GitHub Copilot."); + const candidate = [...lines, ...questionLines, reservedSuffix].join('\n'); + if (candidate.length > VoiceSessionController._MAX_CONFIRMATION_NARRATION_CHARS) { + break; + } + lines.push(...questionLines); + includedQuestions++; + } + + const omittedQuestions = questionnaire.questions.length - includedQuestions; + if (omittedQuestions > 0) { + lines.push(omittedQuestions === 1 + ? localize('voice.questionnaire.oneOmitted', "1 more question is open in GitHub Copilot.") + : localize('voice.questionnaire.manyOmitted', "{0} more questions are open in GitHub Copilot.", omittedQuestions)); + } else { + lines.push(localize('voice.questionnaire.open', "The questionnaire is open in GitHub Copilot.")); + } + return lines.join('\n') || fallback; + } + + private _formatChoiceLabels(choices: readonly { label: string; description?: string }[]): string | undefined { + const visibleChoices = choices.map(choice => { + const label = this._visibleConfirmationText(choice.label, 160); + const description = this._visibleConfirmationText(choice.description, 160); + return description ? localize('voice.confirmation.choiceDescription', "{0} - {1}", label, description) : label; + }).filter(Boolean); + if (visibleChoices.length === 0) { + return undefined; + } + const includedChoices = visibleChoices.slice(0, VoiceSessionController._MAX_QUESTIONNAIRE_OPTIONS); + const omittedChoices = visibleChoices.length - includedChoices.length; + const text = includedChoices.join('; '); + return omittedChoices > 0 + ? localize('voice.confirmation.moreChoices', "{0}; {1} more choices", text, omittedChoices) + : text; + } + + private _formatPlanNarration(plan: IChatPlanReview): string { + const fallback = localize('voice.plan.fallback', "A plan is open in GitHub Copilot and needs your approval."); + const title = this._visibleConfirmationText(plan.title) || fallback; + const lines = [localize('voice.plan.title', "plan approval: {0}", title)]; + const choices = this._formatChoiceLabels(plan.actions); + if (choices) { + lines.push(localize('voice.plan.choices', "choices: {0}", choices)); + } + lines.push(localize('voice.plan.open', "The plan is open in GitHub Copilot.")); + return this._boundedConfirmationLines(lines, fallback); + } + + private _formatElicitationNarration(elicitation: IChatElicitationRequest): string { + const fallback = localize('voice.elicitation.fallback', "GitHub Copilot needs your input in the open request."); + const title = this._visibleConfirmationText(elicitation.title); + const message = this._visibleConfirmationText(elicitation.message); + const subtitle = this._visibleConfirmationText(elicitation.subtitle); + const lines = [localize('voice.elicitation.title', "input request: {0}", title || message || fallback)]; + if (subtitle && subtitle !== title) { + lines.push(subtitle); + } + if (message && message !== title) { + lines.push(message); + } + const choices = this._formatChoiceLabels([ + { label: elicitation.acceptButtonLabel }, + ...(elicitation.rejectButtonLabel ? [{ label: elicitation.rejectButtonLabel }] : []), + ...(elicitation.moreActions ?? []).map(action => ({ label: action.label })), + ]); + if (choices) { + lines.push(localize('voice.elicitation.choices', "choices: {0}", choices)); + } + return this._boundedConfirmationLines(lines, fallback); + } + + private _formatConfirmationNarration(confirmation: IChatConfirmation): string { + const fallback = localize('voice.confirmation.fallback', "GitHub Copilot needs your approval to continue."); + const title = this._visibleConfirmationText(confirmation.title); + const message = this._visibleConfirmationText(confirmation.message); + const lines = [localize('voice.confirmation.title', "confirmation: {0}", title || message || fallback)]; + if (message && message !== title) { + lines.push(message); + } + const choices = this._formatChoiceLabels((confirmation.buttons ?? []).map(label => ({ label }))); + if (choices) { + lines.push(localize('voice.confirmation.choices', "choices: {0}", choices)); + } + return this._boundedConfirmationLines(lines, fallback); + } + + private _formatToolNarration(toolInvocation: IChatToolInvocation): string { + const fallback = localize('voice.toolConfirmation.fallback', "GitHub Copilot needs your approval to continue."); + const state = toolInvocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation && state.type !== IChatToolInvocation.StateKind.WaitingForPostApproval) { + return fallback; + } + const messages = state.confirmationMessages; + const title = this._visibleConfirmationText(messages?.title) || this._visibleConfirmationText(toolInvocation.invocationMessage); + const message = this._visibleConfirmationText(messages?.message); + const lines = [localize('voice.toolConfirmation.title', "tool approval: {0}", title || message || fallback)]; + if (message && message !== title) { + lines.push(message); + } + return this._boundedConfirmationLines(lines, fallback); + } + + private _formatToolNarrationFallback(): string { + const fallback = localize('voice.toolConfirmation.fallback', "GitHub Copilot needs your approval to continue."); + return localize('voice.toolConfirmation.title', "tool approval: {0}", fallback); + } + + private _formatToolAuthenticationNarration(toolInvocation: IChatToolInvocation): string | undefined { + const state = toolInvocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForAuthentication) { + return undefined; + } + const serverName = this._visibleConfirmationText(state.server.name); + const fallback = localize('voice.authentication.fallback', "GitHub Copilot needs authentication to continue."); + return this._boundedConfirmationLines([ + localize('voice.authentication.title', "authentication request: MCP authentication required"), + serverName + ? localize('voice.authentication.message', "The MCP server {0} requires authentication to continue this tool call.", serverName) + : fallback, + localize('voice.authentication.choices', "choices: Authenticate; Cancel"), + ], fallback); + } + + private _selectPendingPart(model: IChatModel | undefined | null): { requestId: string; type: VoiceConfirmationType; part: IChatProgressResponseContent } | undefined { + const lastRequest = model?.getRequests().at(-1); + const parts = lastRequest?.response?.response.value; + if (!lastRequest || !parts) { + return undefined; + } + + for (let index = 0; index < parts.length; index++) { + const part = parts[index]; + const type = getVoiceConfirmationType([part]); + if (type && this._isOpenPendingPart(part)) { + if (type === 'questionnaire' && isVoiceQuestionnaireInvocation(part)) { + const carousel = parts.slice(index + 1).find(candidate => + candidate.kind === 'questionCarousel' + && candidate.resolveId === part.toolCallId + && this._isOpenPendingPart(candidate)); + if (carousel) { + return { requestId: lastRequest.id, type, part: carousel }; + } + } + return { requestId: lastRequest.id, type, part }; + } + } + return undefined; + } + + private _isOpenPendingPart(part: IChatProgressResponseContent): boolean { + if (part.kind === 'questionCarousel') { + return !part.isUsed && !part.answeredExternally; + } + if (part.kind === 'elicitation2') { + return part.state.get() === 'pending'; + } + if (part.kind === 'planReview' || part.kind === 'confirmation') { + return !part.isUsed; + } + if (part.kind === 'toolInvocation') { + const state = part.state.get(); + return state.type === IChatToolInvocation.StateKind.WaitingForConfirmation + || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval + || state.type === IChatToolInvocation.StateKind.WaitingForAuthentication; + } + return false; + } + + private _getPendingConfirmationInfo(model: IChatModel): { type: VoiceConfirmationType; detail?: string } | undefined { + const lastResponse = model.getRequests().at(-1)?.response; + if (!lastResponse) { + return undefined; + } + + const parts = lastResponse.response.value; + const selected = this._selectPendingPart(model); + if (!selected) { + return undefined; + } + const { type, part } = selected; + + const askQuestionsCallIds = new Set(parts + .filter(isVoiceQuestionnaireInvocation) + .map(part => part.toolCallId)); + if (type === 'questionnaire' && part?.kind === 'questionCarousel') { + const includeDetails = !part.resolveId || !askQuestionsCallIds.has(part.resolveId); + return { type, detail: this._formatQuestionnaireNarration(this._visibleQuestionnaireFromCarousel(part, includeDetails)) }; + } + if (type === 'questionnaire' && part?.kind === 'toolInvocation') { + const questionnaire = this._visibleQuestionnaireFromToolInvocation(part); + if (questionnaire) { + return { type, detail: this._formatQuestionnaireNarration(questionnaire) }; + } + } + if (type === 'elicitation' && part?.kind === 'elicitation2') { + return { type, detail: this._formatElicitationNarration(part) }; + } + if (type === 'plan' && part?.kind === 'planReview') { + return { type, detail: this._formatPlanNarration(part) }; + } + if (type === 'tool' && part?.kind === 'toolInvocation') { + return { type, detail: this._formatToolNarration(part) }; + } + if (type === 'generic' && part?.kind === 'confirmation') { + return { type, detail: this._formatConfirmationNarration(part) }; + } + if (type === 'generic' && part?.kind === 'toolInvocation') { + return { type, detail: this._formatToolAuthenticationNarration(part) }; + } + if (type === 'questionnaire') { + return { type }; + } + return { type, detail: this._formatToolNarrationFallback() }; + } + + private _getAgentStateInfo(model: IChatModel | undefined | null): IVoiceAgentStateInfo { if (!model) { return { state: 'unknown' }; } @@ -5563,93 +6336,15 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } const pendingConfirmation = lastRequest?.response?.isPendingConfirmation.get(); - if (pendingConfirmation) { - // Scan ALL response parts to find the most recent pending item. - // We iterate the full list and keep overwriting `confirmDetail` so - // the LAST match wins — response parts are ordered chronologically, - // so earlier tools (already confirmed) will have left - // WaitingForConfirmation while the newest pending item is last. - let confirmDetail = ''; - for (const part of lastRequest?.response?.response.value ?? []) { - if (part.kind === 'questionCarousel' && !(part as { isUsed?: boolean }).isUsed) { - const carousel = part as { questions?: { title?: string }[]; message?: string | { value: string } }; - const titles = (carousel.questions ?? []).map(q => q.title).filter(Boolean); - if (titles.length > 0) { - confirmDetail = `questions: ${titles.join(', ')}`; - } else { - const msg = carousel.message; - confirmDetail = msg ? (typeof msg === 'string' ? msg : msg.value) : 'asking clarifying questions'; - } - } else if (part.kind === 'planReview' && !(part as { isUsed?: boolean }).isUsed) { - confirmDetail = 'review the plan to continue'; - } else if (part.kind === 'elicitation2') { - const elicitation = part as { state: IObservable<string>; title?: string | { value: string } }; - if (elicitation.state.get() === 'pending') { - const title = elicitation.title; - confirmDetail = title ? (typeof title === 'string' ? title : title.value) : 'needs input'; - } - } else if (part.kind === 'confirmation' && !(part as { isUsed?: boolean }).isUsed) { - const conf = part as { title?: string }; - confirmDetail = conf.title ?? 'needs approval'; - } else if (part.kind === 'toolInvocation') { - const state = part.state.get(); - if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { - const params = state.parameters as Record<string, unknown> | undefined; - const command = params?.['command'] ?? params?.['input']; - const explanation = params?.['explanation'] ?? params?.['goal']; - if (typeof command === 'string' && command) { - confirmDetail = `command: ${command}`; - if (typeof explanation === 'string' && explanation) { - confirmDetail += `\nreason: ${explanation}`; - } - } else { - confirmDetail = pendingConfirmation.detail ?? ''; - } - } - } - } - + const confirmation = this._getPendingConfirmationInfo(model); + if (pendingConfirmation || confirmation) { return { state: 'waiting_for_confirmation', - detail: confirmDetail || pendingConfirmation.detail || '', + ...(confirmation?.detail ? { detail: confirmation.detail } : !confirmation ? { detail: this._formatToolNarrationFallback() } : {}), + confirmation_type: confirmation?.type ?? 'generic', }; } - // Fallback: some tools (e.g. askQuestions) enter WaitingForConfirmation - // without setting confirmationMessages, so isPendingConfirmation is - // undefined. Scan response parts directly to catch these. - if (lastRequest?.response) { - let fallbackDetail: string | undefined; - for (const part of lastRequest.response.response.value) { - if (part.kind === 'toolInvocation') { - const state = part.state.get(); - if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { - const params = state.parameters as Record<string, unknown> | undefined; - const questions = params?.['questions']; - let detail = ''; - if (Array.isArray(questions) && questions.length > 0) { - const headers = questions - .map((q: Record<string, unknown>) => q['header'] || q['question']) - .filter(Boolean) - .join(', '); - detail = headers ? `questions: ${headers}` : 'asking clarifying questions'; - } - if (!detail) { - const invMsg = (part as { invocationMessage?: string | { value: string } }).invocationMessage; - detail = invMsg ? (typeof invMsg === 'string' ? invMsg : invMsg.value) : 'needs input'; - } - fallbackDetail = detail; - } - } - } - if (fallbackDetail !== undefined) { - return { - state: 'waiting_for_confirmation', - detail: fallbackDetail, - }; - } - } - const incomplete = lastRequest?.response?.isIncomplete.get() ?? false; if (incomplete) { return { state: 'thinking' }; @@ -5670,64 +6365,43 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * `questions: <titles>`, losing the options, their values and the ids. This * returns what the backend needs to route an answer back to the exact part. * - * Scans newest-first and returns the first still-open part, so an - * already-answered earlier form can't shadow the live one. Plan review is - * deliberately not typed here; it stays on the legacy string path. + * Uses the same typed pending selection as narration, so the backend never + * receives an id for a different action than the one the user heard. */ private _buildPendingPayload(model: IChatModel | undefined | null): IVoiceSessionPending | undefined { - const lastRequest = model?.getRequests().at(-1); - const parts = lastRequest?.response?.response.value; - if (!lastRequest || !parts) { + const selected = this._selectPendingPart(model); + if (!selected || (selected.type !== 'questionnaire' && selected.type !== 'plan' && selected.type !== 'tool')) { return undefined; } - - for (let index = parts.length - 1; index >= 0; index--) { - const part = parts[index]; - // Minted lazily: an id is issued only once the part is confirmed to be - // a live pending request, so a part the backend can never answer never - // gets an identity that a stale id could collide with. - const routing = () => ({ pending_id: derivePendingId(lastRequest.id, part), request_id: lastRequest.id }); - - if (part.kind === 'questionCarousel') { - const carousel = part as IChatQuestionCarousel; - if (carousel.isUsed || carousel.answeredExternally || carousel.questions.length === 0) { - continue; - } - return { - type: 'questions', - ...routing(), - allow_skip: carousel.allowSkip === true, - ...(carousel.message ? { message: this._plainText(carousel.message) } : {}), - questions: carousel.questions.map((question): IVoicePendingQuestion => ({ - id: question.id, - type: question.type, - // The same text the widget shows, so voice reads the question - // rather than its header. - title: this._plainText(getDisplayedQuestionText(question)), - allow_freeform: question.allowFreeformInput !== false, - // The ordinal the user hears has to be the one they see, so the - // list is in the same order the widget renders, and both sides - // number it by position. - options: getOptionsWithDefaultsFirst(question).map(({ option }) => ({ - label: option.label, - value: option.value, - })), + const { requestId, type, part } = selected; + const routing = () => ({ pending_id: derivePendingId(requestId, part), request_id: requestId }); + if (type === 'questionnaire' && part.kind === 'questionCarousel') { + const carousel = part as IChatQuestionCarousel; + if (carousel.answeredExternally || carousel.questions.length === 0) { + return undefined; + } + return { + type: 'questions', + ...routing(), + allow_skip: carousel.allowSkip === true, + ...(carousel.message ? { message: this._plainText(carousel.message) } : {}), + questions: carousel.questions.map((question): IVoicePendingQuestion => ({ + id: question.id, + type: question.type, + title: this._plainText(getDisplayedQuestionText(question)), + allow_freeform: question.allowFreeformInput !== false, + options: getOptionsWithDefaultsFirst(question).map(({ option }) => ({ + label: option.label, + value: option.value, })), - }; - } - - if (part.kind === 'toolInvocation') { - const state = (part as IChatToolInvocation).state.get(); - if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation) { - continue; - } - const message = this._plainText((part as { invocationMessage?: string | IMarkdownString }).invocationMessage); - return { - type: 'approval', - ...routing(), - ...(message ? { message } : {}), - }; - } + })), + }; + } + if (type === 'plan' && part.kind === 'planReview') { + return { type: 'approval', ...routing(), message: this._formatPlanNarration(part) }; + } + if (type === 'tool' && part.kind === 'toolInvocation') { + return { type: 'approval', ...routing(), message: this._formatToolNarration(part) }; } return undefined; @@ -5814,14 +6488,19 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (!this._autoApprovedSessions.has(s.resource.toString())) { continue; } const model = this.chatService.getSession(s.resource); if (!model) { continue; } - for (const req of model.getRequests()) { - const pending = req.response?.isPendingConfirmation.get(); - if (pending && req.response) { - for (const part of req.response.response.value) { - if (part.kind === 'toolInvocation') { - IChatToolInvocation.confirmWith(part as IChatToolInvocation, { type: ToolConfirmKind.UserAction }); - } - } + this._autoApprovePendingTools(model); + } + } + + private _autoApprovePendingTools(model: IChatModel): void { + for (const request of model.getRequests()) { + const response = request.response; + if (!response?.isPendingConfirmation.get() || getVoiceConfirmationType(response.response.value) !== 'tool') { + continue; + } + for (const part of response.response.value) { + if (part.kind === 'toolInvocation') { + IChatToolInvocation.confirmWith(part, { type: ToolConfirmKind.UserAction }); } } } diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceTelemetry.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceTelemetry.ts index 96c7cc99076..b8438ff1556 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceTelemetry.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceTelemetry.ts @@ -124,7 +124,7 @@ export type VoiceNarrationDeferredEvent = { export type VoiceNarrationDeferredClassification = { owner: 'meganrogge'; comment: 'Fired client-side when a requested narration cannot play now and is queued for a later retry (no narration text is logged).'; - kind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the deferred narration was a response or a confirmation prompt.' }; + kind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the deferred narration was a response, confirmation prompt, or checkpoint.' }; reason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Why it was deferred: busy (narration_ack busy) or interrupted (narration_interrupted).' }; }; @@ -135,7 +135,6 @@ export type VoiceNarrationDroppedEvent = { export type VoiceNarrationDroppedClassification = { owner: 'meganrogge'; comment: 'Fired client-side when a requested narration is dropped without being played (no narration text is logged).'; - kind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the dropped narration was a response or a confirmation prompt.' }; + kind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the dropped narration was a response, confirmation prompt, or checkpoint.' }; reason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Why it was dropped: invalid (narration_ack invalid), stale (no longer the current narratable item), or session_changed (user switched away from the session).' }; }; - diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts index 9cb92b7b246..70262a1f0d6 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts @@ -10,13 +10,15 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; import { AgentSessionStatus, getAgentChangesSummary } from '../agentSessions/agentSessionsModel.js'; -import { IChatQuestionAnswers, IChatQuestionCarousel, IChatSendRequestOptions, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js'; +import { IChatPlanReviewResult, IChatQuestionAnswers, IChatQuestionCarousel, IChatSendRequestOptions, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js'; import { IBackendQuestionAnswer, resolveQuestionAnswers } from '../../common/voiceClient/voiceQuestionAnswers.js'; import { ChatQuestionCarouselData } from '../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; +import { ChatPlanReviewData } from '../../common/model/chatProgressTypes/chatPlanReviewData.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { IVoiceDispatchResult, IVoiceToolCall, peekPendingId } from '../../common/voiceClient/voiceClientService.js'; +import { getVoiceConfirmationType } from '../../common/voiceClient/voiceConfirmation.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; /** @@ -304,7 +306,14 @@ export class VoiceToolDispatchService implements IVoiceToolDispatchService { } const approve = responseType === 'approve'; + if (part.kind === 'planReview' && part instanceof ChatPlanReviewData) { + return this._resolvePlanReview(part, approve) ? { ok: true } : { ok: false, reason: 'stale_pending' }; + } + if (part.kind === 'toolInvocation') { + if (getVoiceConfirmationType([part]) !== 'tool') { + return { ok: false, reason: 'unsupported' }; + } const confirmed = IChatToolInvocation.confirmWith( part as IChatToolInvocation, approve ? { type: ToolConfirmKind.UserAction } : { type: ToolConfirmKind.Denied }, @@ -315,6 +324,30 @@ export class VoiceToolDispatchService implements IVoiceToolDispatchService { return { ok: false, reason: 'unsupported' }; } + private _resolvePlanReview(plan: ChatPlanReviewData, approve: boolean): boolean { + if (plan.isUsed) { + return false; + } + let result: IChatPlanReviewResult; + if (approve) { + const action = plan.actions.find(candidate => candidate.default) ?? plan.actions[0]; + if (!action) { + return false; + } + result = { + action: action.label, + actionId: action.id, + rejected: false, + }; + } else { + result = { rejected: true }; + } + plan.data = result; + plan.isUsed = true; + void plan.completion.complete(result); + return true; + } + /** Resolve a coding session id to its chat model, never falling back to the focused session. */ private async _resolveModelForResponse(codingSessionId: string): Promise<{ model: IChatModel; dispose(): void } | undefined> { if (!codingSessionId) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts index ce96986e5f4..6ba4e9feeb6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts @@ -27,7 +27,7 @@ import { ChatConfiguration } from '../../common/constants.js'; import { ChatMemoryFileResource } from '../../common/chatArtifactExtraction.js'; import { IChatArtifact, IChatArtifactsService, IArtifactSourceGroup, ArtifactSource } from '../../common/tools/chatArtifactsService.js'; import { IChatImageCarouselService } from '../chatImageCarouselService.js'; -import { getEditorOverrideForChatResource } from './chatContentParts/chatInlineAnchorWidget.js'; +import { getEditorOverrideForChatResource } from './chatEditorAssociations.js'; const ARTIFACT_TYPE_ICONS: Record<string, ThemeIcon> = { devServer: Codicon.globe, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts index 4674e1070ea..218bfa9aba9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts @@ -36,6 +36,7 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I protected readonly hasFollowingContent: boolean; protected _isExpanded = observableValue<boolean>(this, false); protected _collapseButton: ButtonWithIcon | undefined; + protected _hoverChevron: HTMLElement | undefined; private readonly _overrideIcon = observableValue<ThemeIcon | undefined>(this, undefined); protected readonly _showCheckmarks: IObservable<boolean>; @@ -106,6 +107,7 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I // Add hover chevron indicator on the right (decorative, hide from screen readers) const hoverChevron = $('span.chat-collapsible-hover-chevron.codicon.codicon-chevron-right', { 'aria-hidden': 'true' }); + this._hoverChevron = hoverChevron; collapseButton.element.appendChild(hoverChevron); if (this.hoverMessage) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts index dc95821bad2..73fdd530396 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts @@ -38,7 +38,6 @@ import { FolderThemeIcon, IThemeService } from '../../../../../../platform/theme import { fillEditorsDragData } from '../../../../../browser/dnd.js'; import { StaticResourceContextKey } from '../../../../../common/contextkeys.js'; import { IEditorService, SIDE_GROUP } from '../../../../../services/editor/common/editorService.js'; -import { globMatchesResource } from '../../../../../services/editor/common/editorResolverService.js'; import { INotebookDocumentService } from '../../../../../services/notebook/common/notebookDocumentService.js'; import { ExplorerFolderContext } from '../../../../files/common/files.js'; import { IWorkspaceSymbol } from '../../../../search/common/search.js'; @@ -54,22 +53,7 @@ import { Schemas } from '../../../../../../base/common/network.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { BrowserEditorInput } from '../../../../browserView/common/browserEditorInput.js'; - -/** - * Returns the editor ID to use when opening a resource from chat pills (inline anchors), based on the - * `chat.editorAssociations` setting. Returns undefined if no association matches. - */ -export function getEditorOverrideForChatResource(resource: URI, configurationService: IConfigurationService): string | undefined { - const associations = configurationService.getValue<Record<string, string>>(ChatConfiguration.EditorAssociations) ?? {}; - // Sort patterns by length (longer patterns are more specific) - const sortedPatterns = Object.keys(associations).sort((a, b) => b.length - a.length); - for (const pattern of sortedPatterns) { - if (globMatchesResource(pattern, resource)) { - return associations[pattern]; - } - } - return undefined; -} +import { getEditorOverrideForChatResource } from '../chatEditorAssociations.js'; type ContentRefData = | { readonly kind: 'symbol'; readonly symbol: IWorkspaceSymbol } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 8010f0a04cd..39b90561c15 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -11,6 +11,7 @@ import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../. import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { isMacintosh } from '../../../../../../base/common/platform.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { hasKey } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; @@ -38,6 +39,8 @@ import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; +import { AgentHostAutoReplyAnswer } from '../../../../../../platform/agentHost/common/agentHostSchema.js'; +import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import './media/chatQuestionCarousel.css'; const PREVIOUS_QUESTION_ACTION_ID = 'workbench.action.chat.previousQuestion'; @@ -47,6 +50,63 @@ export interface IChatQuestionCarouselOptions { shouldAutoFocus?: boolean; } +class ChatQuestionAnswerCollapsiblePart extends ChatCollapsibleContentPart { + constructor( + title: string, + private readonly prefix: string | undefined, + private readonly value: string, + private readonly answerIcon: ThemeIcon, + context: IChatContentPartRenderContext, + private readonly contentFactory: (() => HTMLElement) | undefined, + private readonly onDidChangeHeight: () => void, + hoverService: IHoverService, + configurationService: IConfigurationService, + ) { + super(title, context, undefined, hoverService, configurationService); + } + + protected override init(): HTMLElement { + const element = super.init(); + element.classList.toggle('chat-question-answer-expandable', !!this.contentFactory); + if (this._collapseButton) { + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + const icon = dom.$('span.chat-question-summary-answer-icon'); + icon.classList.add(...ThemeIcon.asClassNameArray(this.answerIcon)); + icon.setAttribute('aria-hidden', 'true'); + const value = dom.$('span.chat-question-summary-answer-value'); + value.textContent = this.value; + this._register(this.hoverService.setupDelayedHover(value, { content: this.value })); + labelElement.appendChild(icon); + if (this.prefix) { + const prefix = dom.$('span.chat-question-summary-prefix'); + prefix.textContent = this.prefix; + labelElement.append(prefix, labelElement.ownerDocument.createTextNode(' ')); + } + labelElement.appendChild(value); + if (!this.contentFactory) { + this._collapseButton.element.tabIndex = -1; + this._collapseButton.element.setAttribute('aria-disabled', 'true'); + this._collapseButton.element.removeAttribute('aria-expanded'); + this._hoverChevron?.remove(); + } + } + return element; + } + + protected override initContent(): HTMLElement { + return this.contentFactory?.() ?? dom.$('.chat-question-summary-empty-content'); + } + + protected override expansionDidChange(): void { + this.onDidChangeHeight(); + } + + hasSameContent(_other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + return false; + } +} + export class ChatQuestionCarouselPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; @@ -92,7 +152,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent constructor( public readonly carousel: IChatQuestionCarousel, - context: IChatContentPartRenderContext, + private readonly _context: IChatContentPartRenderContext, private readonly _options: IChatQuestionCarouselOptions, @IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService, @IHoverService private readonly _hoverService: IHoverService, @@ -106,6 +166,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent super(); this.domNode = dom.$('.chat-question-carousel-container'); + this.domNode.classList.toggle('chat-question-carousel-conversation', carousel.answerPresentation === 'conversation'); this.domNode.id = generateUuid(); this._inChatQuestionCarouselContextKey = ChatContextKeys.inChatQuestionCarousel.bindTo(this._contextKeyService); this._chatQuestionCarouselHasTerminalContextKey = ChatContextKeys.chatQuestionCarouselHasTerminal.bindTo(this._contextKeyService); @@ -152,7 +213,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // If carousel was already used OR the response is complete, show summary of answers // When response is complete, the carousel can no longer be interacted with - const responseIsComplete = isResponseVM(context.element) && context.element.isComplete; + const responseIsComplete = isResponseVM(this._context.element) && this._context.element.isComplete; if (carousel.isUsed || responseIsComplete) { this._isSkipped = true; this.domNode.classList.add('chat-question-carousel-used'); @@ -401,6 +462,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent * Hides the carousel UI and shows a summary of answers. */ private hideAndShowSummary(): void { + if (this._store.isDisposed) { + return; + } + this._isSkipped = true; this.domNode.classList.add('chat-question-carousel-used'); @@ -1558,7 +1623,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const skippedMessage = dom.$('.chat-question-summary-skipped'); skippedMessage.textContent = isDismissedByTerminal ? localize('chat.questionCarousel.deferredToTerminal', "Deferring to user's input in the terminal") - : localize('chat.questionCarousel.skipped', 'Skipped'); + : localize('chat.questionCarousel.skipped', 'Skipped question'); summaryContainer.appendChild(skippedMessage); } this.domNode.appendChild(summaryContainer); @@ -1570,12 +1635,34 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private renderSummary(): void { // If no answers, show the terminal-state (Skipped/Answered) message if (this._answers.size === 0) { + if (this.carousel.answerPresentation === 'conversation') { + if (this.carousel.autoReply) { + this.renderConversationSummary({ + answerFallback: localize('chat.questionCarousel.answeredAutomatically', "Answered automatically"), + answerIcon: Codicon.copilotCompact, + }); + } else if (this.carousel.answeredExternally) { + this.renderTerminalStateMessage(); + } else if (this.carousel.isUsed) { + this.renderConversationSummary({ + answerFallback: localize('chat.questionCarousel.skippedConversation', "Skipped question"), + answerIcon: Codicon.closeCompact, + hideAnswerPrefix: true, + }); + } + return; + } if (this.carousel.isUsed) { this.renderTerminalStateMessage(); } return; } + if (this.carousel.answerPresentation === 'conversation') { + this.renderConversationSummary(); + return; + } + const summaryContainer = dom.$('.chat-question-carousel-summary'); for (const question of this.carousel.questions) { @@ -1607,10 +1694,129 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this.domNode.appendChild(summaryContainer); } + private renderConversationSummary(options?: { answerFallback?: string; answerIcon?: ThemeIcon; hideAnswerPrefix?: boolean }): void { + const summaryStore = new DisposableStore(); + this._interactiveUIStore.value = summaryStore; + const summaryContainer = dom.$('.chat-question-carousel-summary.chat-question-carousel-conversation-summary'); + this.domNode.setAttribute('aria-label', localize('chat.questionCarousel.answeredQuestions', "Answered chat questions")); + + for (const question of this.carousel.questions) { + const answer = this._answers.get(question.id); + const summaryItem = dom.$('.chat-question-summary-item'); + const questionValue = dom.$('.chat-question-summary-question'); + const questionText = getDisplayedQuestionText(question); + const displayedQuestion = (typeof questionText === 'string' ? questionText : questionText.value).replace(/[:\s]+$/, ''); + const questionPrefix = dom.$('span.chat-question-summary-prefix'); + questionPrefix.textContent = localize('chat.questionCarousel.questionPrefix', "Question:"); + const questionTextValue = dom.$('span.chat-question-summary-question-value'); + questionTextValue.textContent = displayedQuestion; + summaryStore.add(this._hoverService.setupDelayedHover(questionTextValue, { content: displayedQuestion })); + questionValue.append(questionPrefix, questionValue.ownerDocument.createTextNode(' '), questionTextValue); + summaryItem.appendChild(questionValue); + + const decision = dom.$('.chat-question-summary-decision'); + const answerValue = answer === undefined + ? options?.answerFallback ?? localize('chat.questionCarousel.conversationNotAnswered', "Not answered yet") + : this.formatAnswerForSummary(question, answer); + const answerPrefix = options?.hideAnswerPrefix ? undefined : localize('chat.questionCarousel.answerPrefix', "Answered:"); + const answerTitle = answerPrefix + ? localize('chat.questionCarousel.conversationAnswer', "{0} {1}", answerPrefix, answerValue) + : answerValue; + const collapsibleContext = { + ...this._context, + content: this._context.content ?? [], + contentIndex: this._context.contentIndex ?? 0, + }; + const answerPart = summaryStore.add(new ChatQuestionAnswerCollapsiblePart( + answerTitle, + answerPrefix, + answerValue, + options?.answerIcon ?? (this.carousel.autoReply ? Codicon.copilotCompact : Codicon.comment), + collapsibleContext, + question.options?.length ? () => this.renderConversationOptions(question, answer) : undefined, + () => this._onDidChangeHeight.fire(), + this._hoverService, + this._configurationService, + )); + answerPart.domNode.classList.add('chat-question-answer-collapsible'); + decision.appendChild(answerPart.domNode); + summaryItem.appendChild(decision); + summaryContainer.appendChild(summaryItem); + } + + this.domNode.appendChild(summaryContainer); + } + + private renderConversationOptions(question: IChatQuestion, answer: IChatQuestionAnswerValue | undefined): HTMLElement { + const selectedValues = new Set<string>(); + let freeformValue: string | undefined; + if (typeof answer === 'string') { + selectedValues.add(answer); + } else if (answer) { + if (hasKey(answer, { selectedValues: true })) { + for (const selectedValue of answer.selectedValues) { + selectedValues.add(selectedValue); + } + freeformValue = answer.freeformValue; + } else { + const singleAnswer = answer as IChatSingleSelectAnswer; + if (singleAnswer.selectedValue !== undefined) { + selectedValues.add(singleAnswer.selectedValue); + } + freeformValue = singleAnswer.freeformValue; + } + } + + const container = dom.$('.chat-question-summary-option-details.chat-used-context-list'); + const optionsTitle = dom.$('.chat-question-summary-options-title'); + optionsTitle.textContent = localize('chat.questionCarousel.optionsTitle', "Options"); + container.appendChild(optionsTitle); + + const optionList = dom.$('ul.chat-question-summary-option-list'); + for (const option of question.options ?? []) { + const selected = selectedValues.has(option.value); + const optionItem = dom.$('li.chat-question-summary-option'); + optionItem.classList.toggle('selected', selected); + optionItem.setAttribute('aria-label', selected + ? localize('chat.questionCarousel.selectedOptionAriaLabel', "{0}, selected", option.label) + : option.label); + const optionLabel = dom.$('span.chat-question-summary-option-label'); + optionLabel.textContent = option.label; + optionItem.appendChild(optionLabel); + if (selected) { + optionItem.appendChild(this.renderSelectedOptionState()); + } + optionList.appendChild(optionItem); + } + if (freeformValue) { + const customItem = dom.$('li.chat-question-summary-option.selected'); + customItem.setAttribute('aria-label', localize('chat.questionCarousel.selectedCustomAnswerAriaLabel', "Custom answer: {0}, selected", freeformValue)); + const customLabel = dom.$('span.chat-question-summary-option-label'); + customLabel.textContent = localize('chat.questionCarousel.customAnswer', "Custom answer: {0}", freeformValue); + customItem.append(customLabel, this.renderSelectedOptionState()); + optionList.appendChild(customItem); + } + container.appendChild(optionList); + return container; + } + + private renderSelectedOptionState(): HTMLElement { + const selectedState = dom.$('span.chat-question-summary-option-selected'); + const selectedIcon = dom.$('span'); + selectedIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.checkCompact)); + selectedIcon.setAttribute('aria-hidden', 'true'); + selectedState.appendChild(selectedIcon); + return selectedState; + } + /** * Formats an answer for display in the summary. */ private formatAnswerForSummary(question: IChatQuestion, answer: IChatQuestionAnswerValue): string { + if (this.carousel.autoReply && answer === AgentHostAutoReplyAnswer) { + return localize('chat.questionCarousel.autoReplyAnswer', "The user is not available to answer your question. Choose a pragmatic option best aligned with the context of the request."); + } + switch (question.type) { case 'text': return String(answer); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index 1f33bf28e54..1d744b86899 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -17,13 +17,15 @@ import { rcut } from '../../../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; import { IActionViewItemService } from '../../../../../../platform/actions/browser/actionViewItemService.js'; -import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; -import { MenuId } from '../../../../../../platform/actions/common/actions.js'; +import { HiddenItemStrategy, WorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; +import { IMenuService, MenuId, MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID } from '../../../common/constants.js'; import { formatCopilotCredits, IChatHookPart, IChatMarkdownContent, IChatToolInvocation, IChatToolInvocationSerialized, isLegacyChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; import { IChatRendererContent, isResponseVM } from '../../../common/model/chatViewModel.js'; import { IRunSubagentToolInputParams } from '../../../common/tools/builtinTools/runSubagentTool.js'; @@ -128,9 +130,10 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen * header. The Agents window contributes an "Open Subagent" action (rendered * as a pill) into this menu; elsewhere the menu is empty and nothing shows. */ - private _openChatToolbar: MenuWorkbenchToolBar | undefined; + private _openChatToolbar: WorkbenchToolBar | undefined; private _openChatToolbarContainer: HTMLElement | undefined; private readonly _openChatActionListeners = this._register(new MutableDisposable<DisposableStore>()); + private readonly _openChatActionViewRegistration = this._register(new MutableDisposable()); // Confirmation auto-expand tracking private toolsWaitingForConfirmation: number = 0; @@ -242,27 +245,63 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this._openChatToolbarContainer?.classList.add('hidden'); return; } - if (!this._openChatToolbar) { - const container = $('.chat-subagent-open-chat-toolbar'); - this._collapseButton.element.parentElement?.insertBefore(container, this._collapseButton.element); - this._openChatToolbarContainer = container; - this._openChatToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, container, MenuId.ChatSubagentContent, { - hiddenItemStrategy: HiddenItemStrategy.Ignore, - menuOptions: { shouldForwardArgs: true }, - toolbarOptions: { primaryGroup: () => true }, - })); - this._register(this._openChatToolbar.onDidChangeMenuItems(() => this._trackOpenChatActions())); - this._register(this.actionViewItemService.onDidChange(menuId => { - if (menuId === MenuId.ChatSubagentContent) { - this._trackOpenChatActions(); - } - })); - this._trackOpenChatActions(); + if (!this._ensureOpenChatToolbar()) { + return; } this._updateOpenChatToolbarContext(); this._openChatToolbarContainer!.classList.remove('hidden'); } + private _ensureOpenChatToolbar(): boolean { + if (this._openChatToolbar) { + return true; + } + const menuAction = this._getOpenChatMenuAction(); + if (!menuAction) { + return false; + } + const actionViewItemProvider = this.actionViewItemService.lookUp(MenuId.ChatSubagentContent, CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID); + if (!actionViewItemProvider) { + if (!this._openChatActionViewRegistration.value) { + this._openChatActionViewRegistration.value = Event.once(Event.filter( + this.actionViewItemService.onDidChange, + menuId => menuId === MenuId.ChatSubagentContent + ))(() => { + this._openChatActionViewRegistration.clear(); + this._updateOpenChatLink(); + }); + } + return false; + } + + this._openChatActionViewRegistration.clear(); + const container = $('.chat-subagent-open-chat-toolbar'); + this._collapseButton?.element.parentElement?.insertBefore(container, this._collapseButton.element); + this._openChatToolbarContainer = container; + this._openChatToolbar = this._register(this.instantiationService.createInstance(WorkbenchToolBar, container, { + hiddenItemStrategy: HiddenItemStrategy.Ignore, + actionViewItemProvider: (action, options) => actionViewItemProvider( + action, + options, + this.instantiationService, + dom.getWindow(container).vscodeWindowId + ), + })); + this._openChatToolbar.setActions([menuAction]); + this._trackOpenChatActions(); + return true; + } + + private _getOpenChatMenuAction(): MenuItemAction | undefined { + for (const [, actions] of this.menuService.getMenuActions(MenuId.ChatSubagentContent, this.contextKeyService, { shouldForwardArgs: true })) { + const action = actions.find(action => action.id === CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID); + if (action instanceof MenuItemAction) { + return action; + } + } + return undefined; + } + private _trackOpenChatActions(): void { const store = new DisposableStore(); const itemCount = this._openChatToolbar?.getItemsLength() ?? 0; @@ -335,6 +374,8 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen @IConfigurationService private readonly configurationService: IConfigurationService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, @IActionViewItemService private readonly actionViewItemService: IActionViewItemService, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { // Extract description, agentName, and prompt from toolInvocation const { description, isDefaultDescription, agentName, prompt, modelName, credits } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts index 38b99ae83bb..9b1b57ac62f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts @@ -13,12 +13,11 @@ import { basename, getComparisonKey, isEqual } from '../../../../../../base/comm import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../../../nls.js'; -import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { FileKind } from '../../../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../../browser/labels.js'; @@ -30,7 +29,7 @@ import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingServic import { IChatRendererContent, IChatTurnPillsPart } from '../../../common/model/chatViewModel.js'; import { ChatTreeItem } from '../../chat.js'; import { IChatResponseFileChangesService } from '../../chatResponseFileChangesService.js'; -import { diffStatsEqual, EMPTY_DIFF_STATS, IDiffStats, IPreviewFile, observeTurnStatusPillsEnabled, openChatPreviewFile, previewFilesEqual, previewKind } from '../chatTurnPills.js'; +import { diffStatsEqual, EMPTY_DIFF_STATS, IDiffStats, IPreviewFile, observeTurnStatusPillsEnabled, openChatTurnFile, previewFilesEqual, previewKind } from '../chatTurnPills.js'; import { renderChangesSummaryFileList } from './chatChangesSummaryPart.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; @@ -53,14 +52,13 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent private readonly _content: IChatTurnPillsPart, _context: IChatContentPartRenderContext, @IChatResponseFileChangesService chatResponseFileChangesService: IChatResponseFileChangesService, - @ICommandService private readonly _commandService: ICommandService, @IOpenerService private readonly _openerService: IOpenerService, - @ILogService private readonly _logService: ILogService, @IHoverService private readonly _hoverService: IHoverService, @IEditorService private readonly _editorService: IEditorService, - @IConfigurationService configurationService: IConfigurationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, @IThemeService themeService: IThemeService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ILabelService private readonly _labelService: ILabelService, ) { super(); @@ -110,7 +108,7 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent return [...created, ...edited]; }); - const turnStatusPillsEnabled = observeTurnStatusPillsEnabled(configurationService); + const turnStatusPillsEnabled = observeTurnStatusPillsEnabled(this._configurationService); const changesEnabled = derived(this, reader => turnStatusPillsEnabled.read(reader)); const previewEnabled = derived(this, reader => turnStatusPillsEnabled.read(reader)); const showChanges = derived(this, reader => changesEnabled.read(reader) && stats.read(reader).files > 0); @@ -137,10 +135,9 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent })); // Only feed diffs into the list when the changes summary is shown, so the - // disclosure stays empty when just the preview action is enabled. Each - // previewable row gets a "Preview" action that opens the file's preview. + // disclosure stays empty when just the preview action is enabled. const listDiffs = derived(this, reader => showChanges.read(reader) ? this._diffs.read(reader) : []); - this._register(renderChangesSummaryFileList(details, listDiffs, this._instantiationService, this._editorService, configurationService, { + this._register(renderChangesSummaryFileList(details, listDiffs, this._instantiationService, this._editorService, this._configurationService, { getRowActions: diff => this._getRowActions(diff), })); @@ -200,7 +197,7 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent const button = container.appendChild(document.createElement('button')); button.classList.add('chat-turn-preview-action'); button.type = 'button'; - const label = this._register(resourceLabels.create(button)); + const label = this._register(resourceLabels.create(button, { hoverTargetOverride: button })); const clickDisposable = dom.addDisposableListener(button, 'click', (e) => { this._openPrimaryPreview(previewFiles.get()); @@ -211,13 +208,15 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent const files = previewFiles.read(reader); const primaryFile = files.at(0); if (primaryFile) { + const name = basename(primaryFile.uri); label.setResource( - { resource: primaryFile.uri, name: basename(primaryFile.uri) }, - { fileKind: FileKind.FILE }, + { resource: primaryFile.uri, name }, + { + fileKind: FileKind.FILE, + title: localize('chat.turnPreview.tooltip', "{0} • Open File", this._labelService.getUriLabel(primaryFile.uri)), + }, ); - const tooltip = localize('chat.turnPreview.tooltip', 'Open Preview: {0}', basename(primaryFile.uri)); - button.setAttribute('aria-label', tooltip); - button.title = tooltip; + button.setAttribute('aria-label', localize('chat.turnPreview.ariaLabel', "Open File: {0}", name)); } container.classList.toggle('hidden', !showPreview.read(reader)); })); @@ -260,13 +259,13 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent private _openPrimaryPreview(files: readonly IPreviewFile[]): void { const primaryFile = files.at(0); if (primaryFile) { - openChatPreviewFile(primaryFile, this._commandService, this._openerService, this._logService); + openChatTurnFile(primaryFile, this._openerService, this._configurationService); } } /** - * Row actions for the changed-files list: markdown files get a labelless- - * icon-free "Preview" action that opens the file as a markdown preview. + * Row actions for the changed-files list: markdown files get a labelless, + * icon-free action that opens the file. */ private _getRowActions(diff: IEditSessionEntryDiff): IAction[] { const kind = previewKind(diff.modifiedURI); @@ -277,7 +276,7 @@ export class ChatTurnPillsContentPart extends Disposable implements IChatContent return [toAction({ id: 'chat.turnChanges.previewFile', label: localize('chat.turnChanges.preview', "Preview"), - run: () => openChatPreviewFile(file, this._commandService, this._openerService, this._logService), + run: () => openChatTurnFile(file, this._openerService, this._configurationService), })]; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 0ac6a6aca49..6c49afefb5a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -95,6 +95,8 @@ font-weight: var(--vscode-agents-fontWeight-semiBold); font-size: var(--vscode-agents-fontSize-heading3); margin: 0; + user-select: text; + -webkit-user-select: text; .rendered-markdown { a { @@ -560,3 +562,168 @@ font-size: var(--vscode-chat-font-size-body-s); } } + +.interactive-session .chat-question-carousel-container.chat-question-carousel-conversation.chat-question-carousel-used { + max-height: none; + overflow: visible; + border: none; + background: transparent; +} + +.interactive-session .chat-question-carousel-container.chat-question-carousel-conversation.chat-question-carousel-used:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.interactive-session .chat-question-carousel-container.chat-question-carousel-conversation.chat-question-carousel-used:focus-within:not(:focus-visible) { + border-color: transparent; + outline: none; +} + +.interactive-session .chat-question-carousel-conversation-summary { + gap: var(--vscode-spacing-size120); + padding: var(--vscode-spacing-size80) 0; + + .chat-question-summary-item { + gap: var(--vscode-spacing-size60); + padding: var(--vscode-spacing-size40) 0 0; + font-size: var(--vscode-agents-fontSize-body1); + } + + .chat-question-summary-question { + display: flex; + gap: var(--vscode-spacing-size40); + color: var(--vscode-descriptionForeground); + } + + .chat-question-summary-prefix { + flex-shrink: 0; + font-weight: var(--vscode-agents-fontWeight-semiBold); + } + + .chat-question-summary-question-value, + .chat-question-summary-answer-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .chat-question-summary-decision { + position: relative; + display: flex; + flex-direction: column; + padding-left: var(--vscode-spacing-size160); + } + + .chat-question-summary-decision::before { + position: absolute; + top: calc(-1 * var(--vscode-spacing-size40)); + left: var(--vscode-spacing-size40); + width: var(--vscode-spacing-size60); + height: var(--vscode-spacing-size160); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); + border-left: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); + border-bottom-left-radius: var(--vscode-cornerRadius-medium); + content: ''; + pointer-events: none; + } + + .chat-question-summary-answer-icon { + flex: 0 0 var(--vscode-codiconFontSize-compact); + font-size: var(--vscode-codiconFontSize-compact); + line-height: 1; + } + + .chat-question-answer-collapsible { + min-width: 0; + margin-bottom: 0; + } + + .chat-question-answer-collapsible > .chat-used-context-label .monaco-button { + align-items: center; + max-width: 100%; + } + + .chat-question-answer-collapsible:not(.chat-question-answer-expandable) > .chat-used-context-label .monaco-button { + cursor: default; + pointer-events: none; + user-select: none; + } + + .chat-question-answer-collapsible > .chat-used-context-label .monaco-button-mdlabel { + display: flex; + flex: 1; + align-items: center; + gap: var(--vscode-spacing-size40); + white-space: normal; + width: 100%; + } + + .chat-question-answer-collapsible .chat-question-summary-answer-icon, + .chat-question-answer-collapsible .chat-collapsible-hover-chevron { + align-self: center; + margin-top: 2px; + } + + .chat-question-answer-expandable .chat-collapsible-hover-chevron { + flex-shrink: 0; + margin-left: var(--vscode-spacing-size20); + line-height: 1; + opacity: 1; + } + + .chat-question-summary-option-details { + margin-bottom: 0; + padding: var(--vscode-spacing-size100); + border: var(--vscode-strokeThickness) solid var(--vscode-widget-border); + border-radius: var(--vscode-cornerRadius-medium); + background: var(--vscode-editorWidget-background); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-chat-font-size-body-s) + } + + .chat-question-summary-options-title { + margin-bottom: var(--vscode-spacing-size40); + padding: 0 var(--vscode-spacing-size80) var(--vscode-spacing-size40); + border-bottom: var(--vscode-strokeThickness) solid color-mix(in srgb, var(--vscode-widget-border) 70%, transparent); + color: var(--vscode-foreground); + font-size: var(--vscode-agents-fontSize-label1); + } + + .chat-question-summary-option-list { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; + padding: 0; + list-style: none; + } + + .chat-question-summary-option { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); + border-radius: var(--vscode-cornerRadius-small); + } + + .chat-question-summary-option.selected { + background: var(--vscode-list-inactiveSelectionBackground); + color: var(--vscode-foreground); + } + + .chat-question-summary-option-label { + flex: 1; + } + + .chat-question-summary-option-selected { + display: flex; + flex-shrink: 0; + align-items: center; + color: inherit; + } + + .chat-question-summary-option-selected > .codicon { + font-size: var(--vscode-codiconFontSize-compact); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 6e7c94829e5..2b51cbadcc2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -1401,6 +1401,7 @@ class ChatTerminalToolOutputSection extends Disposable { // Only now show the expanded state (after content is ready) this._setExpanded(true); + await this._layoutMirrorWidth(); this._layoutOutput(); this._scrollOutputToBottom(); this._scheduleOutputRelayout(); @@ -1585,6 +1586,7 @@ class ChatTerminalToolOutputSection extends Disposable { } })); await mirror.attach(this._terminalContainer); + await this._layoutMirrorWidth(mirror); let result = await mirror.renderCommand(); // Only show "No output" message if: // 1. Command has finished (has endMarker), AND @@ -1628,6 +1630,7 @@ class ChatTerminalToolOutputSection extends Disposable { private async _renderSnapshotOutput(snapshot: NonNullable<IChatTerminalToolInvocationData['terminalCommandOutput']>): Promise<void> { if (this._snapshotMirror) { this._snapshotMirror.setOutput(snapshot); + await this._layoutMirrorWidth(this._snapshotMirror); const result = await this._snapshotMirror.render(); this._layoutOutput(result?.lineCount ?? snapshot.lineCount ?? this._lastRenderedLineCount ?? 0); return; @@ -1639,6 +1642,7 @@ class ChatTerminalToolOutputSection extends Disposable { this._snapshotMirror = this._register(this._instantiationService.createInstance(DetachedTerminalSnapshotMirror, snapshot, this._getStoredTheme)); await this._snapshotMirror.attach(this._terminalContainer); this._snapshotMirror.setOutput(snapshot); + await this._layoutMirrorWidth(this._snapshotMirror); const result = await this._snapshotMirror.render(); const hasText = !!snapshot.text && snapshot.text.length > 0; if (hasText) { @@ -1696,6 +1700,7 @@ class ChatTerminalToolOutputSection extends Disposable { return; } if (this.isExpanded) { + void this._layoutMirrorWidth(); this._layoutOutput(); this._scrollOutputToBottom(); } else { @@ -1703,6 +1708,26 @@ class ChatTerminalToolOutputSection extends Disposable { } } + /** + * Resizes the mirror's column count to fill the currently available width. No-op while the + * width is unmeasurable (e.g. collapsed); the mirror keeps its current cols until the next + * layout opportunity. + */ + private async _layoutMirrorWidth(mirror: DetachedTerminalCommandMirror | DetachedTerminalSnapshotMirror | undefined = this._snapshotMirror ?? this._mirror): Promise<void> { + if (!mirror) { + return; + } + const width = this._terminalContainer.clientWidth || this._outputBody.clientWidth || this.domNode.clientWidth || (this.domNode.parentElement?.clientWidth ?? 0); + if (width <= 0) { + return; + } + const result = await mirror.layout(width); + if (!this._store.isDisposed && result?.lineCount !== undefined) { + // Re-wrapping can change the number of rendered rows, so refresh the box height + this._layoutOutput(result.lineCount); + } + } + private _layoutOutput(lineCount?: number): void { if (!this._scrollableContainer) { return; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts index 8840232b780..8c891c24255 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts @@ -12,7 +12,11 @@ export function isMcpToolInvocation(toolInvocation: IChatToolInvocation | IChatT } export function isAskQuestionsToolInvocation(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): boolean { - return toolInvocation.toolId === 'copilot_askQuestions' || toolInvocation.toolId === 'vscode_askQuestions'; + return toolInvocation.toolId === 'copilot_askQuestions' + || toolInvocation.toolId === 'vscode_askQuestions' + || toolInvocation.toolId === 'ask_user' + || toolInvocation.toolId === 'AskUserQuestion' + || toolInvocation.toolId === 'request_user_input'; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatEditorAssociations.ts b/src/vs/workbench/contrib/chat/browser/widget/chatEditorAssociations.ts new file mode 100644 index 00000000000..ff515b53439 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatEditorAssociations.ts @@ -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 { URI } from '../../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { globMatchesResource } from '../../../../services/editor/common/editorResolverService.js'; +import { ChatConfiguration } from '../../common/constants.js'; + +/** + * Returns the editor configured for a resource opened from chat, if one matches. + */ +export function getEditorOverrideForChatResource(resource: URI, configurationService: IConfigurationService): string | undefined { + const associations = configurationService.getValue<Record<string, string>>(ChatConfiguration.EditorAssociations) ?? {}; + const sortedPatterns = Object.keys(associations).sort((a, b) => b.length - a.length); + for (const pattern of sortedPatterns) { + if (globMatchesResource(pattern, resource)) { + return associations[pattern]; + } + } + return undefined; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 57e16fdfe70..5def5488eb3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -122,7 +122,7 @@ import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; import { HookType } from '../../common/promptSyntax/hookTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; import { AccessibilityWorkbenchSettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; -import { isMcpToolInvocation } from './chatContentParts/toolInvocationParts/chatToolPartUtilities.js'; +import { isAskQuestionsToolInvocation, isMcpToolInvocation } from './chatContentParts/toolInvocationParts/chatToolPartUtilities.js'; import { AgentSessionProviders, isAgentHostTarget } from '../agentSessions/agentSessions.js'; const $ = dom.$; @@ -1556,6 +1556,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch const workingParts = getWorkingProgressRelevantParts(partsToRender); const lastPart = findLastMeaningfulPart(workingParts); + const endsWithCompletedQuestion = endsWithCompletedQuestionInteraction(workingParts); // Don't show working if a streaming tool invocation is already present if (workingParts.some(part => part.kind === 'toolInvocation' && IChatToolInvocation.isStreaming(part))) { @@ -1569,7 +1570,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch // never show working progress when there is an active thinking piece const lastThinking = this.getLastThinkingPart(templateData.renderedParts); - if (lastThinking) { + if (lastThinking && !endsWithCompletedQuestion) { return undefined; } @@ -1601,6 +1602,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch ((lastPart.kind === 'textEditGroup' || lastPart.kind === 'notebookEditGroup') && lastPart.done && !workingParts.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || (lastPart.kind === 'externalEdit' && !workingParts.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || (lastPart.kind === 'progressTask' && lastPart.deferred.isSettled) || + endsWithCompletedQuestion || lastPart.kind === 'mcpServersStarting' || lastPart.kind === 'mcpAuthenticationRequired' || lastPart.kind === 'mcpServersStartingSlow' || @@ -2591,7 +2593,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch } // don't pin ask questions tool invocations - const isAskQuestionsTool = (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') && (part.toolId === 'copilot_askQuestions' || part.toolId === 'vscode_askQuestions'); + const isAskQuestionsTool = (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') && isAskQuestionsToolInvocation(part); if (isAskQuestionsTool) { return false; } @@ -4200,6 +4202,19 @@ export function endsWithSubagentContent(parts: readonly IChatRendererContent[]): return false; } +export function endsWithCompletedQuestionInteraction(parts: readonly IChatRendererContent[]): boolean { + const lastPart = findLastMeaningfulPart(parts); + if (!lastPart) { + return false; + } + if (lastPart.kind === 'questionCarousel') { + return !!lastPart.isUsed; + } + return (lastPart.kind === 'toolInvocation' || lastPart.kind === 'toolInvocationSerialized') + && isAskQuestionsToolInvocation(lastPart) + && IChatToolInvocation.isComplete(lastPart); +} + export function isWaitingForMcpServers(parts: readonly IChatRendererContent[]): boolean { return parts.some(part => part.kind === 'mcpServersStartingSlow' && part.servers.get().length > 0); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts index 117128b4ae1..2d9ec26039e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatPetWidget.ts @@ -7,27 +7,31 @@ import './media/chatPet.css'; import * as dom from '../../../../../base/browser/dom.js'; import { GlobalPointerMoveMonitor } from '../../../../../base/browser/globalPointerMoveMonitor.js'; import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; +import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { status } from '../../../../../base/browser/ui/aria/aria.js'; +import { Action, IAction, Separator } from '../../../../../base/common/actions.js'; import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; -import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { FileAccess } from '../../../../../base/common/network.js'; import { autorun, IObservable, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { localize } from '../../../../../nls.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; -import product from '../../../../../platform/product/common/product.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IChatModel } from '../../common/model/chatModel.js'; -import { IChatPetService } from '../chatPetService.js'; +import { ChatPetVariant, IChatPetService } from '../chatPetService.js'; -export type ChatPetState = 'idle' | 'sleep' | 'waking' | 'typing' | 'rendering' | 'complete' | 'love' | 'clapping' | 'jump' | 'yapping' | 'yappingMouthOpen'; -export type ChatPetClickInteraction = Extract<ChatPetState, 'love' | 'jump' | 'yapping'>; +export type ChatPetState = 'idle' | 'sleep' | 'waking' | 'typing' | 'rendering' | 'complete' | 'love' | 'clapping' | 'jump' | 'cool' | 'yapping' | 'yappingMouthOpen' | 'onTheRun' | 'searching' | 'searchingDown'; +export type ChatPetClickInteraction = Extract<ChatPetState, 'love' | 'jump' | 'cool' | 'yapping'>; export const CHAT_PET_IDLE_SLEEP_DELAY = 20_000; const TRANSIENT_STATE_DURATION = 2_000; const COMPLETE_STATE_DURATION = 2_140; const LOVE_STATE_DURATION = 2_940; +const COOL_STATE_DURATION = 3_000; const WAKE_STATE_DURATION = 880; +const SEARCH_INTERVAL = 10_000; const DRAG_THRESHOLD = 2; const KEYBOARD_MOVE_DISTANCE = 8; const CHAT_PET_SOURCE_SIZE = 96; @@ -39,6 +43,8 @@ const TYPING_FRAME_DURATIONS = Array.from({ length: 8 }, () => 120); const SPEECH_FRAME_DURATIONS = [220, 220, 220, 100, 160, 180]; const CLAPPING_FRAME_DURATIONS = [80, 40, 40, 40, 80, 40, 40, 40, 40, 80, 40, 40, 80]; const LOVE_FRAME_DURATIONS = [200, 200, 380, 100, 80, 1_980]; +const COOL_FRAME_DURATIONS = [600, 120, 120, 120, 160, 80, 80, 80, 1_640]; +const SEARCH_FRAME_DURATIONS = [500, 500, 500, 500]; const YAPPING_FRAME_DURATIONS = [300, 240, 1_500, 240, 360]; interface ChatPetSpriteSource { @@ -62,11 +68,11 @@ export function getChatPetBuddyName(quality: string | undefined): 'buddy-idle-st return quality === 'stable' ? 'buddy-idle-stable' : 'buddy-idle-insiders'; } -let spriteSources: Record<ChatPetState, ChatPetSpriteSources> | undefined; -let speechSpriteSources: ChatPetSpriteSources | undefined; +const spriteSources = new Map<ChatPetVariant, Record<ChatPetState, ChatPetSpriteSources>>(); +const speechSpriteSources = new Map<ChatPetVariant, ChatPetSpriteSources>(); export function doesChatPetStateTrackCursor(state: ChatPetState | undefined): boolean { - return state !== undefined && state !== 'sleep' && state !== 'waking' && state !== 'typing' && state !== 'complete' && state !== 'love' && state !== 'yappingMouthOpen'; + return state !== undefined && state !== 'sleep' && state !== 'waking' && state !== 'typing' && state !== 'complete' && state !== 'love' && state !== 'cool' && state !== 'yappingMouthOpen' && state !== 'onTheRun' && state !== 'searching' && state !== 'searchingDown'; } export function getChatPetSpriteName(state: ChatPetState, quality: string | undefined): string { @@ -76,6 +82,12 @@ export function getChatPetSpriteName(state: ChatPetState, quality: string | unde return `buddy-love-${variant}`; case 'clapping': return `buddy-clapping-${variant}`; + case 'cool': + return `buddy-cool-${variant}`; + case 'onTheRun': + case 'searching': + case 'searchingDown': + return `buddy-search-${variant}`; case 'sleep': return `buddy-sleep-${variant}`; case 'waking': @@ -105,6 +117,13 @@ export function getChatPetFrameDurations(state: ChatPetState): readonly number[] return CLAPPING_FRAME_DURATIONS; case 'love': return LOVE_FRAME_DURATIONS; + case 'cool': + return COOL_FRAME_DURATIONS; + case 'searching': + return SEARCH_FRAME_DURATIONS; + case 'onTheRun': + case 'searchingDown': + return []; case 'yappingMouthOpen': return YAPPING_FRAME_DURATIONS; case 'yapping': @@ -127,7 +146,7 @@ function createSpriteSources(name: string, state: ChatPetState, tracksCursor = t animated: frameDurations.length === 0 ? staticSource : { url: FileAccess.asBrowserUri(`${root}/${name}${suffix}.spritesheet.png`).toString(true), frameDurations, - iterations: state === 'waking' ? 1 : Infinity, + iterations: state === 'waking' || state === 'cool' || state === 'searching' ? 1 : Infinity, }, reducedMotion: staticSource, }; @@ -137,10 +156,11 @@ export function getChatPetSpeechFrameDurations(): readonly number[] { return SPEECH_FRAME_DURATIONS; } -function getSpriteSources(): Record<ChatPetState, ChatPetSpriteSources> { - if (!spriteSources) { - const createStateSpriteSources = (state: ChatPetState) => createSpriteSources(getChatPetSpriteName(state, product.quality), state, doesChatPetStateTrackCursor(state)); - spriteSources = { +function getSpriteSources(variant: ChatPetVariant): Record<ChatPetState, ChatPetSpriteSources> { + let sources = spriteSources.get(variant); + if (!sources) { + const createStateSpriteSources = (state: ChatPetState) => createSpriteSources(getChatPetSpriteName(state, variant), state, doesChatPetStateTrackCursor(state)); + sources = { idle: createStateSpriteSources('idle'), sleep: createStateSpriteSources('sleep'), waking: createStateSpriteSources('waking'), @@ -150,20 +170,25 @@ function getSpriteSources(): Record<ChatPetState, ChatPetSpriteSources> { love: createStateSpriteSources('love'), clapping: createStateSpriteSources('clapping'), jump: createStateSpriteSources('jump'), + cool: createStateSpriteSources('cool'), yapping: createStateSpriteSources('yapping'), yappingMouthOpen: createStateSpriteSources('yappingMouthOpen'), + onTheRun: createStateSpriteSources('onTheRun'), + searching: createStateSpriteSources('searching'), + searchingDown: createStateSpriteSources('searchingDown'), }; + spriteSources.set(variant, sources); } - return spriteSources; + return sources; } -function getSpeechSpriteSources(): ChatPetSpriteSources { - if (!speechSpriteSources) { +function getSpeechSpriteSources(variant: ChatPetVariant): ChatPetSpriteSources { + let sources = speechSpriteSources.get(variant); + if (!sources) { const root = 'vs/workbench/contrib/chat/browser/widget/media/chatPet'; - const variant = product.quality === 'stable' ? 'stable' : 'insiders'; const name = `buddy-speech-${variant}-96`; - speechSpriteSources = { + sources = { animated: { url: FileAccess.asBrowserUri(`${root}/${name}.spritesheet.png`).toString(true), frameDurations: SPEECH_FRAME_DURATIONS, @@ -175,8 +200,9 @@ function getSpeechSpriteSources(): ChatPetSpriteSources { iterations: 1, }, }; + speechSpriteSources.set(variant, sources); } - return speechSpriteSources; + return sources; } function doesChatPetStateSpeak(state: ChatPetState | undefined): boolean { @@ -203,6 +229,10 @@ export function getChatPetBaseState(hasActiveRequest: boolean, needsInput: boole return 'idle'; } +export function isChatPetVisible(enabled: boolean, isLatestFocusedWidget: boolean): boolean { + return enabled && isLatestFocusedWidget; +} + export function getChatPetRenderedState(baseState: ChatPetState, transientState: ChatPetState | undefined, isDragging: boolean): ChatPetState { return isDragging ? 'idle' : transientState ?? baseState; } @@ -235,6 +265,8 @@ function getTransientStateDuration(state: ChatPetState): number { return COMPLETE_STATE_DURATION; case 'love': return LOVE_STATE_DURATION; + case 'cool': + return COOL_STATE_DURATION; case 'waking': return WAKE_STATE_DURATION; default: @@ -243,7 +275,7 @@ function getTransientStateDuration(state: ChatPetState): number { } export function getChatPetClickInteraction(random: number, previousInteraction?: ChatPetClickInteraction): ChatPetClickInteraction { - const interactions: readonly ChatPetClickInteraction[] = ['love', 'jump', 'yapping']; + const interactions: readonly ChatPetClickInteraction[] = ['love', 'jump', 'cool', 'yapping']; const availableInteractions = interactions.filter(interaction => interaction !== previousInteraction); return availableInteractions[Math.min(Math.floor(random * availableInteractions.length), availableInteractions.length - 1)]; } @@ -280,9 +312,11 @@ export class ChatPetWidget extends Disposable { private readonly _isDragging = observableValue(this, false); private readonly _idleScheduler = this._register(new RunOnceScheduler(() => this._idleExpired.set(true, undefined), CHAT_PET_IDLE_SLEEP_DELAY)); private readonly _transientScheduler = this._register(new RunOnceScheduler(() => this._transientState.set(undefined, undefined), TRANSIENT_STATE_DURATION)); + private readonly _searchScheduler: RunOnceScheduler; private readonly _clickSuppressionScheduler = this._register(new RunOnceScheduler(() => this._suppressNextPointerClick = false, 0)); private readonly _spriteAnimation = this._register(new MutableDisposable()); private readonly _speechAnimation = this._register(new MutableDisposable()); + private readonly _contextMenuActions = this._register(new MutableDisposable<DisposableStore>()); private _cursorPosition: readonly [number, number] | undefined; private _activeSprite: ChatPetSpriteElement | undefined; private _pendingSprite: ChatPetSpriteElement | undefined; @@ -296,21 +330,27 @@ export class ChatPetWidget extends Disposable { private _hasCustomPosition = false; private _suppressNextPointerClick = false; private _lastClickInteraction: ChatPetClickInteraction | undefined; + private _variant: ChatPetVariant; constructor( private readonly parent: HTMLElement, private readonly dragBounds: HTMLElement, model: IObservable<IChatModel | undefined>, hasInput: IObservable<boolean>, + isLatestFocusedWidget: IObservable<boolean>, inputChanged: (listener: () => void) => IDisposable, @IChatPetService private readonly chatPetService: IChatPetService, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { super(); + this._variant = this.chatPetService.variant.get(); + this._searchScheduler = this._register(new RunOnceScheduler(() => this._trySearch(), SEARCH_INTERVAL)); this.parent.classList.add('chat-pet-host'); + this.dragBounds.classList.add('chat-pet-drag-bounds'); this._button = this._register(new Button(this.parent, { - ariaLabel: localize('chatPet.interact', "Interact with the VS Code pet"), + ariaLabel: localize('chatPet.interact', "Interact with the VS Code pet. Use the context menu to put it on the run."), })); this._button.element.classList.add('chat-pet-button'); const resizeObserver = this._register(new dom.DisposableResizeObserver('ChatPetWidget.dragBounds', () => { @@ -356,18 +396,29 @@ export class ChatPetWidget extends Disposable { } })); const onAnimationComplete = (event: AnimationEvent) => { - if (event.animationName === 'chat-pet-exit' && !this._enabled) { + if (event.animationName === 'chat-pet-enter') { + this._button.element.classList.remove('entering'); + } else if (event.animationName === 'chat-pet-exit' && !this._enabled) { this._finishDisable(); } else if (event.animationName === 'chat-pet-yapping-fall' && !this._isDragging.get() && event.target === this._activeSprite?.container && this._button.element.dataset.state === 'yapping') { this._transientState.set('yappingMouthOpen', undefined); + } else if (event.animationName === 'chat-pet-search-down' && this._button.element.dataset.state === 'searchingDown') { + this._transientState.set(undefined, undefined); } }; this._register(dom.addDisposableListener(this._button.element, dom.EventType.ANIMATION_END, onAnimationComplete)); this._register(dom.addDisposableListener(this._button.element, 'animationcancel', onAnimationComplete)); this._register(dom.addDisposableListener(this._button.element, dom.EventType.POINTER_DOWN, event => this._startDrag(event))); this._register(dom.addDisposableListener(this._button.element, dom.EventType.KEY_DOWN, event => this._onKeyDown(event))); + this._register(dom.addDisposableListener(this._button.element, dom.EventType.CONTEXT_MENU, event => { + if (!this._enabled) { + return; + } + dom.EventHelper.stop(event, true); + this._showContextMenu(event); + })); this._register(inputChanged(() => { - if (this._enabled) { + if (this._enabled && !this.chatPetService.onTheRun.get()) { this._wake(); } })); @@ -379,6 +430,11 @@ export class ChatPetWidget extends Disposable { this._clickSuppressionScheduler.cancel(); return; } + if (this.chatPetService.onTheRun.get()) { + this._transientState.set(undefined, undefined); + this.chatPetService.setOnTheRun(false); + return; + } const wasSleeping = this._idleExpired.get() || this._renderedState === 'sleep'; if (wasSleeping) { this._wake(); @@ -397,6 +453,9 @@ export class ChatPetWidget extends Disposable { case 'jump': status(localize('chatPet.jumped', "The VS Code pet jumped")); break; + case 'cool': + status(localize('chatPet.cool', "The VS Code pet put on sunglasses")); + break; case 'yapping': status(localize('chatPet.yapping', "The VS Code pet is yapping")); break; @@ -406,7 +465,15 @@ export class ChatPetWidget extends Disposable { const motionReduced = observableFromEvent(this, this.accessibilityService.onDidChangeReducedMotion, () => this.accessibilityService.isMotionReduced()); this._register(autorun(reader => { this._motionReduced = motionReduced.read(reader); - const enabled = this.chatPetService.enabled.read(reader); + const enabled = isChatPetVisible(this.chatPetService.enabled.read(reader), isLatestFocusedWidget.read(reader)); + const variant = this.chatPetService.variant.read(reader); + const variantChanged = variant !== this._variant; + this._variant = variant; + const onTheRun = this.chatPetService.onTheRun.read(reader); + this._button.element.classList.toggle('on-the-run', onTheRun); + this._button.setAriaLabel(onTheRun + ? localize('chatPet.restore', "Bring back the VS Code pet") + : localize('chatPet.interact', "Interact with the VS Code pet. Use the context menu to put it on the run.")); const chatModel = model.read(reader); const request = chatModel?.lastRequestObs.read(reader); const needsInput = !!request?.response?.isPendingConfirmation.read(reader); @@ -432,6 +499,7 @@ export class ChatPetWidget extends Disposable { if (!enabled) { this._idleScheduler.cancel(); + this._searchScheduler.cancel(); this._transientScheduler.cancel(); if (transientState !== undefined) { this._transientState.set(undefined, undefined); @@ -442,6 +510,17 @@ export class ChatPetWidget extends Disposable { return; } + if (onTheRun) { + this._idleScheduler.cancel(); + if (!this._searchScheduler.isScheduled()) { + this._searchScheduler.schedule(); + } + const state = transientState === 'searching' || transientState === 'searchingDown' ? transientState : 'onTheRun'; + this._renderState(state, variantChanged); + return; + } + this._searchScheduler.cancel(); + if (this._busy) { this._idleScheduler.cancel(); if (idleExpired) { @@ -454,7 +533,7 @@ export class ChatPetWidget extends Disposable { } const baseState = getChatPetBaseState(hasActiveRequest, needsInput, inputHasContent, idleExpired); - this._renderState(getChatPetRenderedState(baseState, transientState, isDragging), false, isDragging); + this._renderState(getChatPetRenderedState(baseState, transientState, isDragging), variantChanged, isDragging); })); this._register(autorun(reader => { @@ -472,7 +551,7 @@ export class ChatPetWidget extends Disposable { } private _startDrag(event: PointerEvent): void { - if (!this._enabled || event.button !== 0) { + if (!this._enabled || this.chatPetService.onTheRun.get() || event.button !== 0) { return; } @@ -508,6 +587,41 @@ export class ChatPetWidget extends Disposable { }); } + private _showContextMenu(event: MouseEvent): void { + const onTheRun = this.chatPetService.onTheRun.get(); + const actions = new DisposableStore(); + this._contextMenuActions.value = actions; + const stable = actions.add(new Action('chat.pet.variant.stable', localize('chatPet.variant.stable.action', "Stable Colors"), undefined, true, () => this.chatPetService.setVariant('stable'))); + stable.checked = this.chatPetService.variant.get() === 'stable'; + const insiders = actions.add(new Action('chat.pet.variant.insiders', localize('chatPet.variant.insiders.action', "Insiders Colors"), undefined, true, () => this.chatPetService.setVariant('insiders'))); + insiders.checked = this.chatPetService.variant.get() === 'insiders'; + const onTheRunAction = actions.add(new Action( + 'chat.pet.onTheRun', + onTheRun ? localize('chatPet.comeBack.action', "Come Back") : localize('chatPet.goOnTheRun.action', "Go on the Run"), + undefined, + true, + () => { + this._transientState.set(undefined, undefined); + this.chatPetService.setOnTheRun(!onTheRun); + } + )); + const separator = new Separator(); + this.contextMenuService.showContextMenu({ + getAnchor: () => new StandardMouseEvent(dom.getWindow(this._button.element), event), + getActions: (): IAction[] => [ + onTheRunAction, + separator, + stable, + insiders, + ], + onHide: () => { + if (this._contextMenuActions.value === actions) { + this._contextMenuActions.clear(); + } + }, + }); + } + private _onKeyDown(event: KeyboardEvent): void { const keyboardEvent = new StandardKeyboardEvent(event); let delta: number; @@ -618,6 +732,19 @@ export class ChatPetWidget extends Disposable { } } + private _trySearch(): void { + if (!this._enabled || !this.chatPetService.onTheRun.get()) { + return; + } + if (this._motionReduced) { + this._searchScheduler.schedule(); + return; + } + this._transientState.set('searching', undefined); + this._renderState('searching', true); + this._searchScheduler.schedule(); + } + private _wake(): void { const wasSleeping = this._idleExpired.get() || this._renderedState === 'sleep'; this._idleExpired.set(false, undefined); @@ -642,7 +769,7 @@ export class ChatPetWidget extends Disposable { } private _renderState(state: ChatPetState, restart = false, useStaticSprite = false): void { - const sources = getSpriteSources()[state]; + const sources = getSpriteSources(this._variant)[state]; const source = this._motionReduced || useStaticSprite ? sources.reducedMotion : sources.animated; if (!restart && this._activeSprite && isChatPetImageSource(this._activeSprite.image, source.url)) { this._pendingSprite = undefined; @@ -676,11 +803,12 @@ export class ChatPetWidget extends Disposable { this._activeSprite?.container.classList.add('hidden'); sprite.container.classList.remove('hidden'); this._activeSprite = sprite; - this._startSpriteAnimation(this._pendingSource, sprite, this._spriteAnimation); - this._button.element.dataset.state = this._pendingState; - this._renderedState = this._pendingState; - this._eyes.classList.toggle('tracking', doesChatPetStateTrackCursor(this._pendingState)); - this._updateSpeechBubble(this._pendingState, true); + const state = this._pendingState; + this._startSpriteAnimation(this._pendingSource, sprite, this._spriteAnimation, () => this._onSpriteAnimationComplete(sprite, state)); + this._button.element.dataset.state = state; + this._renderedState = state; + this._eyes.classList.toggle('tracking', doesChatPetStateTrackCursor(state)); + this._updateSpeechBubble(state, true); this._pendingSprite = undefined; this._pendingSource = undefined; this._pendingState = undefined; @@ -690,7 +818,16 @@ export class ChatPetWidget extends Disposable { } } - private _startSpriteAnimation(source: ChatPetSpriteSource, sprite: ChatPetSpriteElement, animationDisposable: MutableDisposable<IDisposable>): void { + private _onSpriteAnimationComplete(sprite: ChatPetSpriteElement, state: ChatPetState): void { + if (state !== 'searching' || sprite !== this._activeSprite || !this.chatPetService.onTheRun.get()) { + return; + } + this._transientState.set('searchingDown', undefined); + this._button.element.dataset.state = 'searchingDown'; + this._renderedState = 'searchingDown'; + } + + private _startSpriteAnimation(source: ChatPetSpriteSource, sprite: ChatPetSpriteElement, animationDisposable: MutableDisposable<IDisposable>, onComplete?: () => void): void { const { frameDurations } = source; const { image, canvas } = sprite; const context = canvas.getContext('2d'); @@ -721,10 +858,15 @@ export class ChatPetWidget extends Disposable { const startTime = targetWindow.performance.now(); let currentFrame = 0; let animationFrame: number | undefined; + let completed = false; const updateFrame = (timestamp: number) => { const frame = getChatPetAnimationFrame(frameDurations, timestamp - startTime, source.iterations); if (frame.complete) { drawFrame(frame.frameIndex); + if (!completed) { + completed = true; + onComplete?.(); + } return; } if (frame.frameIndex !== currentFrame) { @@ -749,7 +891,7 @@ export class ChatPetWidget extends Disposable { return; } - const sources = getSpeechSpriteSources(); + const sources = getSpeechSpriteSources(this._variant); const source = this._motionReduced ? sources.reducedMotion : sources.animated; if (!isChatPetImageSource(this._speechBubble.image, source.url)) { this._speechAnimation.clear(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts b/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts index 8da26f7b4ed..445041d3c39 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts @@ -16,18 +16,17 @@ import { basename, isEqual } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { FileKind } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { AnimatedCounterWidget } from '../../../../browser/animatedCounterWidget.js'; import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../browser/labels.js'; import { ChatConfiguration } from '../../common/constants.js'; +import { getEditorOverrideForChatResource } from './chatEditorAssociations.js'; import '../media/chatTurnPills.css'; const CHANGES_PILL_ACTION_ID = 'chat.turnPills.changes'; @@ -91,17 +90,14 @@ export function previewFilesEqual(a: readonly IPreviewFile[], b: readonly IPrevi return true; } -/** - * Open a previewable file: markdown files open as a markdown preview, falling - * back to the default opener when it is not available (e.g. web). - */ -export async function openChatPreviewFile(file: IPreviewFile, commandService: ICommandService, openerService: IOpenerService, logService: ILogService): Promise<void> { - try { - await commandService.executeCommand('markdown.showPreview', file.uri); - } catch (err) { - logService.trace('[ChatTurnPills] Falling back to default opener for preview', err); - await openerService.open(file.uri); - } +/** Opens a turn file with the editor configured for resources opened from chat. */ +export async function openChatTurnFile(file: IPreviewFile, openerService: IOpenerService, configurationService: IConfigurationService): Promise<void> { + await openerService.open(file.uri, { + fromUserGesture: true, + editorOptions: { + override: getEditorOverrideForChatResource(file.uri, configurationService), + }, + }); } /** The data and interactions a {@link ChatTurnPillsWidget} reflects. */ @@ -113,7 +109,7 @@ export interface IChatTurnPillsModel { /** When `false` the preview pill stays hidden regardless of the data. */ readonly previewEnabled: IObservable<boolean>; openChanges(): void; - openPreviewFile(file: IPreviewFile): void; + openFile(file: IPreviewFile): void; } /** The former per-pill setting shape, retained for existing user settings. */ @@ -216,7 +212,7 @@ class ChangesPillActionViewItem extends BaseActionViewItem { * The preview pill: renders the primary previewable file as a resource label * (file icon + name). When more than one previewable file exists, a separator * and a dropdown chevron are shown; the chevron lists every previewable file. - * Activating the label opens the primary file's preview. + * Activating the label opens the primary file. */ class PreviewPillActionViewItem extends BaseActionViewItem { @@ -292,7 +288,7 @@ class PreviewPillActionViewItem extends BaseActionViewItem { * changes. * - **Preview** — shown when the turn created or edited a markdown file. * Rendered as a resource label for the primary file. Activating it opens that - * file as a markdown preview; when several exist, a dropdown lists them all. + * file; when several exist, a dropdown lists them all. * The data and the open actions are supplied by the {@link IChatTurnPillsModel} * so the same widget serves surfaces with different data sources. */ @@ -325,7 +321,7 @@ export class ChatTurnPillsWidget extends Disposable { this._resourceLabels = this._register(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); this._changesAction = this._register(new Action(CHANGES_PILL_ACTION_ID, localize('chatTurnPills.changes.tooltip', "View Current Turn Changes"), undefined, true, async () => this._model.openChanges())); - this._previewAction = this._register(new Action(PREVIEW_PILL_ACTION_ID, localize('chatTurnPills.preview.label', "Open Preview"), undefined, true, async () => this._openPrimaryPreview())); + this._previewAction = this._register(new Action(PREVIEW_PILL_ACTION_ID, localize('chatTurnPills.preview.label', "Open Preview"), undefined, true, async () => this._openPrimaryFile())); this._toolbar = this._register(new ToolBar(this.element, this._contextMenuService, { orientation: ActionsOrientation.HORIZONTAL, @@ -335,7 +331,7 @@ export class ChatTurnPillsWidget extends Disposable { return new ChangesPillActionViewItem(action, options, this._model.stats, this._instantiationService); } if (action.id === PREVIEW_PILL_ACTION_ID) { - return new PreviewPillActionViewItem(action, options, this._model.previewFiles, this._resourceLabels, file => this._model.openPreviewFile(file), anchor => this._showAllPreviews(anchor)); + return new PreviewPillActionViewItem(action, options, this._model.previewFiles, this._resourceLabels, file => this._model.openFile(file), anchor => this._showAllFiles(anchor)); } return undefined; }, @@ -372,14 +368,14 @@ export class ChatTurnPillsWidget extends Disposable { this.element.classList.toggle('hidden', actions.length === 0); } - private _openPrimaryPreview(): void { + private _openPrimaryFile(): void { const primaryFile = this._model.previewFiles.get().at(0); if (primaryFile) { - this._model.openPreviewFile(primaryFile); + this._model.openFile(primaryFile); } } - private _showAllPreviews(anchor: HTMLElement): void { + private _showAllFiles(anchor: HTMLElement): void { const files = this._model.previewFiles.get(); if (files.length === 0) { return; @@ -389,8 +385,8 @@ export class ChatTurnPillsWidget extends Disposable { getActions: () => files.map(file => toAction({ id: `${PREVIEW_PILL_ACTION_ID}.${file.uri.toString()}`, label: basename(file.uri), - class: ThemeIcon.asClassName(Codicon.openPreview), - run: () => this._model.openPreviewFile(file), + class: ThemeIcon.asClassName(Codicon.goToFile), + run: () => this._model.openFile(file), })), }); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index d41dd80f526..ec1acb89685 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -59,7 +59,7 @@ import { ChatMode, getModeNameForTelemetry, IChatMode } from '../../common/chatM import { chatAgentLeader, ChatRequestAgentPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestToolPart, ChatRequestToolSetPart, chatSubcommandLeader, formatChatQuestion, IParsedChatRequest } from '../../common/requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../../common/requestParser/chatRequestParser.js'; import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../attachments/chatVariables.js'; -import { ChatRequestQueueKind, ChatSendResult, IChatLocationData, IChatSendRequestOptions, IChatService } from '../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, ChatSendResult, ChatSendResultSent, IChatLocationData, IChatSendRequestOptions, IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { IChatSlashCommandService } from '../../common/participants/chatSlashCommands.js'; import { IChatTodoListService } from '../../common/tools/chatTodoListService.js'; @@ -91,7 +91,8 @@ import { getChatSessionType } from '../../common/model/chatUri.js'; import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { CHAT_READ_ONLY_BANNER_HEIGHT, ChatReadOnlyBanner } from './chatReadOnlyBanner.js'; import { IChatSubmitRequestHandlerService } from '../chatSubmitRequestHandlerService.js'; -import { ChatPetWidget } from './chatPetWidget.js'; +import { ChatPetWidget, isChatPetVisible } from './chatPetWidget.js'; +import { IChatPetService } from '../chatPetService.js'; const $ = dom.$; @@ -151,6 +152,25 @@ export function getImmediateSilentSlashCommandPart(parsedRequest: IParsedChatReq ); } +/** + * Settles the outcome of a `IChatService.sendRequest` call. + * + * A request that could not be handed over to the chat service is never accepted. Anything else is + * accepted right away — a queued request is accepted the moment it enters the queue, which is + * potentially long before it runs — so {@link onRequestAccepted} fires before the queued request + * settles. Resolves with the request once it has actually been sent, or `undefined` if it never was. + */ +export async function acceptAndAwaitSentRequest(result: ChatSendResult, onRequestAccepted?: () => void): Promise<ChatSendResultSent | undefined> { + if (ChatSendResult.isRejected(result)) { + return undefined; + } + + onRequestAccepted?.(); + + const sent = ChatSendResult.isQueued(result) ? await result.deferred : result; + return ChatSendResult.isSent(sent) ? sent : undefined; +} + type ChatHandoffClickEvent = { fromAgent: string; toAgent: string; @@ -456,6 +476,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @IAccessibilityService private readonly accessibilityService: IAccessibilityService, @IChatGoalSummaryService private readonly chatGoalSummaryService: IChatGoalSummaryService, @IChatSubmitRequestHandlerService private readonly chatSubmitRequestHandlerService: IChatSubmitRequestHandlerService, + @IChatPetService private readonly chatPetService: IChatPetService, ) { super(); @@ -804,7 +825,16 @@ export class ChatWidget extends Disposable implements IChatWidget { const inputContainer = this.inputPart.inputContainerElement; const petHost = inputContainer?.parentElement ?? this.inputPart.element; const inputHasContent = observableFromEvent(this, this.inputEditor.onDidChangeModelContent, () => this.inputEditor.getValue().length > 0); - this._register(this.instantiationService.createInstance(ChatPetWidget, petHost, inputContainer ?? petHost, this._viewModelObs.map(viewModel => viewModel?.model), inputHasContent, this.inputEditor.onDidChangeModelContent)); + const targetWindow = dom.getWindow(this.container); + const isLatestFocusedWidgetInWindow = observableValue(this, this.chatWidgetService.lastFocusedWidget === this); + this._register(this.chatWidgetService.onDidChangeFocusedWidget(focusedWidget => { + if (focusedWidget && dom.getWindow(focusedWidget.domNode) === targetWindow) { + isLatestFocusedWidgetInWindow.set(focusedWidget === this, undefined); + } + })); + const petVisible = derived(this, reader => isChatPetVisible(this.chatPetService.enabled.read(reader), isLatestFocusedWidgetInWindow.read(reader))); + this._register(autorun(reader => this.container.classList.toggle('chat-pet-enabled', petVisible.read(reader)))); + this._register(this.instantiationService.createInstance(ChatPetWidget, petHost, inputContainer ?? petHost, this._viewModelObs.map(viewModel => viewModel?.model), inputHasContent, petVisible, this.inputEditor.onDidChangeModelContent)); } this.renderWelcomeViewContentIfNeeded(); @@ -2890,6 +2920,7 @@ export class ChatWidget extends Disposable implements IChatWidget { attachedContext: requestInputs.attachedContext.asArray(), resolvedVariables: resolvedImageVariables, noCommandDetection: options?.noCommandDetection, + isVoiceModeInput: options?.isVoiceModeInput, ...this.getModeRequestOptions(), modeInfo, agentIdSilent: this._lockedAgent?.id, @@ -2922,8 +2953,8 @@ export class ChatWidget extends Disposable implements IChatWidget { this._maybeStartGoalSummary(requestInputs.input); } - const sent = ChatSendResult.isQueued(result) ? await result.deferred : result; - if (!ChatSendResult.isSent(sent)) { + const sent = await acceptAndAwaitSentRequest(result, options.onRequestAccepted); + if (!sent) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 2a81d98b9d8..b021b95a6c5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -116,6 +116,7 @@ import { IDictationOnboardingService } from '../../speechToText/dictationOnboard import { notifyDictationSubmitted } from '../../speechToText/dictationSession.js'; import { VoiceModeActionViewItem } from '../../voiceClient/voiceModeActionViewItem.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; +import { getAgentSessionPullRequestContextValue } from '../../agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; import { IChatAttachmentWidgetRegistry } from '../../attachments/chatAttachmentWidgetRegistry.js'; @@ -4262,6 +4263,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const scopedContextKeyService = this._chatEditsActionsDisposables.add(this.contextKeyService.createScoped(actionsContainer)); if (sessionResource) { scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, getChatSessionType(sessionResource)); + + // Metadata can arrive after first render, so track it rather than sampling once. + const sessionPullRequest = observableFromEvent( + this, + this.agentSessionsService.model.onDidChangeSessions, + () => { + const session = this.agentSessionsService.getSession(sessionResource); + return session ? getAgentSessionPullRequestContextValue(session) : ''; + }, + ); + this._chatEditsActionsDisposables.add(bindContextKey(ChatContextKeys.agentSessionPullRequest, scopedContextKeyService, r => sessionPullRequest.read(r))); } this._chatEditsActionsDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, scopedContextKeyService, r => !!sessionEntriesObs.read(r)?.length)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerPresentation.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerPresentation.ts index f9cfa11a805..429999ea3f6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerPresentation.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerPresentation.ts @@ -15,10 +15,11 @@ export function isMultiplierPricing(model: ILanguageModelChatMetadataAndIdentifi } export function getPriceCategoryLabel(priceCategory: string | undefined): string | undefined { + // The value originates from extension provided metadata, so it may not be a string at runtime + if (typeof priceCategory !== 'string' || priceCategory.length === 0) { + return undefined; + } switch (priceCategory) { - case undefined: - case '': - return undefined; case 'low': return localize('chat.priceCategory.low', "Low cost"); case 'medium': diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelProviderIcons.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelProviderIcons.ts index d2a8c602a69..4a8624bcc30 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelProviderIcons.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelProviderIcons.ts @@ -15,16 +15,20 @@ const claudeModelProviderIcon = registerIcon('chat-model-provider-claude', Codic const geminiModelProviderIcon = registerIcon('chat-model-provider-gemini', Codicon.googleGemini, localize('chatModelProviderGeminiIcon', "Icon for Gemini models.")); const kimiModelProviderIcon = registerIcon('chat-model-provider-kimi', Codicon.kimi, localize('chatModelProviderKimiIcon', "Icon for Kimi models.")); const microsoftModelProviderIcon = registerIcon('chat-model-provider-microsoft', Codicon.microsoft, localize('chatModelProviderMicrosoftIcon', "Icon for Microsoft models.")); +const xAIModelProviderIcon = registerIcon('chat-model-provider-xai', Codicon.xai, localize('chatModelProviderXAIIcon', "Icon for xAI models.")); const genericModelProviderIcon = registerIcon('chat-model-provider-generic', Codicon.sparkle, localize('chatModelProviderGenericIcon', "Icon for other model providers.")); export function getModelProviderIcon(model: ILanguageModelChatMetadataAndIdentifier): ThemeIcon { + const identity = `${model.metadata.vendor} ${model.metadata.family} ${model.metadata.id} ${model.metadata.name}`.toLowerCase(); + if (identity.includes('grok') || identity.includes('xai')) { + return xAIModelProviderIcon; + } if (model.metadata.isBYOK) { return genericModelProviderIcon; } if (isAutoLanguageModel(model)) { return copilotModelProviderIcon; } - const identity = `${model.metadata.vendor} ${model.metadata.family} ${model.metadata.id} ${model.metadata.name}`.toLowerCase(); if (identity.includes('claude') || identity.includes('anthropic')) { return claudeModelProviderIcon; } @@ -40,7 +44,8 @@ export function getModelProviderIcon(model: ILanguageModelChatMetadataAndIdentif if (identity.includes('openai') || identity.includes('gpt') || identity.includes('codex') || /\bo[134]\b/.test(identity)) { return openAIModelProviderIcon; } - if (identity.includes('copilot')) { + const modelIdentity = `${model.metadata.id} ${model.metadata.name}`.toLowerCase(); + if (modelIdentity.includes('copilot')) { return copilotModelProviderIcon; } return genericModelProviderIcon; diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 7a4530d60a9..27498125870 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -46,6 +46,11 @@ -webkit-user-select: text; } +.interactive-session.chat-pet-enabled .interactive-item-container.chat-most-recent-response::after { + content: ""; + flex: 0 0 48px; +} + .interactive-item-container:not(:has(.chat-extensions-content-part)) .header { display: flex; align-items: center; @@ -3109,15 +3114,17 @@ have to be updated for changes to the rules above, or to support more deeply nes /* The pill header already spaces its children with `gap`, so the label's own margin-right (needed in the standalone checkpoint summary) is redundant here - and leaves unwanted trailing space. */ + and leaves unwanted trailing space. The counts label also keeps its intrinsic + width so the preview action is the part that shrinks when space runs out. */ .interactive-session .chat-turn-pills-part .checkpoint-file-changes-summary-header .chat-file-changes-label { margin-right: 0; + flex: none; } .interactive-session .chat-turn-pills-part .chat-turn-preview { display: flex; align-items: center; - flex: none; + flex: 0 1 auto; gap: var(--vscode-spacing-size40); min-width: 0; overflow: hidden; @@ -3143,12 +3150,13 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } +/* The file name is only ellipsized when the header actually runs out of room, so + the action shrinks with the available width instead of at a fixed cap. */ .interactive-session .chat-turn-pills-part .chat-turn-preview-action { display: inline-flex; align-items: center; gap: var(--vscode-spacing-size40); min-width: 0; - max-width: 200px; padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size40) var(--vscode-spacing-sizeNone) var(--vscode-spacing-size20); border: none; border-radius: var(--vscode-cornerRadius-small); diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css index 1f084c19a50..66161be4c03 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet.css @@ -5,13 +5,19 @@ .chat-pet-host { position: relative; + isolation: isolate; +} + +.chat-pet-drag-bounds { + position: relative; + z-index: 1; } .chat-pet-button { position: absolute; right: var(--vscode-spacing-size320); bottom: 100%; - z-index: 100; + z-index: 0; width: 48px; height: 48px; padding: 0; @@ -20,6 +26,7 @@ background: transparent; cursor: grab; touch-action: none; + transition: transform 200ms ease-out; } .chat-pet-button.hidden { @@ -134,13 +141,13 @@ .chat-pet-button[data-state='complete'] .chat-pet-sprite { transform-origin: 50% 60%; - animation: chat-pet-complete-motion 2140ms steps(1, end); + animation: chat-pet-complete-motion 960ms steps(1, end); } .chat-pet-button[data-state='jump'] .chat-pet-sprite, .chat-pet-button[data-state='jump'] .chat-pet-eyes { transform-origin: 50% 60%; - animation: chat-pet-complete-motion 1560ms steps(1, end); + animation: chat-pet-complete-motion 960ms steps(1, end); } .chat-pet-button[data-state='yapping'] .chat-pet-sprite, @@ -154,6 +161,18 @@ transform: translateY(calc(-1 * var(--vscode-spacing-size40))) rotate(-90deg); } +.chat-pet-button.on-the-run { + transform: translateY(var(--vscode-spacing-size400)); +} + +.chat-pet-button[data-state='searching']:not(.exiting) { + animation: chat-pet-search-up 160ms steps(4, end) forwards; +} + +.chat-pet-button[data-state='searchingDown']:not(.exiting) { + animation: chat-pet-search-down 160ms steps(4, end) forwards; +} + .chat-pet-button[data-state='yapping'] .chat-pet-speech-bubble, .chat-pet-button[data-state='yappingMouthOpen'] .chat-pet-speech-bubble { left: calc(-1 * var(--vscode-spacing-size160)); @@ -249,31 +268,30 @@ transform: translateY(0) rotate(0); } - 12.5% { - transform: translateY(calc(-1 * var(--vscode-spacing-size40))) rotate(45deg); + 14.2857% { + transform: translateY(calc(-1 * var(--vscode-spacing-size40))) rotate(51deg); } - 25% { - transform: translateY(calc(-1 * var(--vscode-spacing-size80))) rotate(90deg); + 28.5714% { + transform: translateY(calc(-1 * var(--vscode-spacing-size80))) rotate(103deg); } - 37.5% { - transform: translateY(calc(-1 * var(--vscode-spacing-size80))) rotate(135deg); + 42.8571% { + transform: translateY(calc(-1 * var(--vscode-spacing-size80))) rotate(154deg); } - 50% { - transform: translateY(calc(-1 * var(--vscode-spacing-size80))) rotate(225deg); + 57.1429% { + transform: translateY(calc(-1 * var(--vscode-spacing-size80))) rotate(206deg); } - 62.5% { - transform: translateY(calc(-1 * var(--vscode-spacing-size80))) rotate(270deg); + 71.4286% { + transform: translateY(calc(-1 * var(--vscode-spacing-size80))) rotate(257deg); } - 75% { - transform: translateY(calc(-1 * var(--vscode-spacing-size40))) rotate(315deg); + 85.7143% { + transform: translateY(calc(-1 * var(--vscode-spacing-size40))) rotate(309deg); } - 87.5%, 100% { transform: translateY(0) rotate(360deg); } @@ -289,6 +307,34 @@ } } +@keyframes chat-pet-search-up { + 0% { + transform: translateY(var(--vscode-spacing-size400)); + } + + 50% { + transform: translateY(var(--vscode-spacing-size320)); + } + + 100% { + transform: translateY(var(--vscode-spacing-size160)); + } +} + +@keyframes chat-pet-search-down { + 0% { + transform: translateY(var(--vscode-spacing-size160)); + } + + 50% { + transform: translateY(var(--vscode-spacing-size320)); + } + + 100% { + transform: translateY(var(--vscode-spacing-size400)); + } +} + @keyframes chat-pet-eye-bob { 0%, 39.999% { @@ -315,6 +361,10 @@ } } +.monaco-workbench.monaco-reduce-motion .chat-pet-button { + transition: none; +} + .monaco-workbench.monaco-reduce-motion .chat-pet-button.entering, .monaco-workbench.monaco-reduce-motion .chat-pet-button.exiting, .monaco-workbench.monaco-reduce-motion .chat-pet-button.dragging.resisting, @@ -322,6 +372,8 @@ .monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='jump'] .chat-pet-sprite, .monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='jump'] .chat-pet-eyes, .monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='yapping'] .chat-pet-sprite, +.monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='searching'], +.monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='searchingDown'], .monaco-workbench.monaco-reduce-motion .chat-pet-eyes, .monaco-workbench.monaco-reduce-motion .chat-pet-pupil { animation: none; @@ -331,3 +383,11 @@ .monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='yapping'] .chat-pet-eyes { transform: translateY(calc(-1 * var(--vscode-spacing-size40))) rotate(-90deg); } + +.monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='searching'] { + transform: translateY(var(--vscode-spacing-size400)); +} + +.monaco-workbench.monaco-reduce-motion .chat-pet-button[data-state='searchingDown'] { + transform: translateY(var(--vscode-spacing-size400)); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-cool-insiders-96.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-cool-insiders-96.png new file mode 100644 index 00000000000..e1cc103ba6e Binary files /dev/null and b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-cool-insiders-96.png differ diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-cool-insiders-96.spritesheet.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-cool-insiders-96.spritesheet.png new file mode 100644 index 00000000000..208cf54a7f0 Binary files /dev/null and b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-cool-insiders-96.spritesheet.png differ diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-cool-stable-96.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-cool-stable-96.png new file mode 100644 index 00000000000..610ce31b22d Binary files /dev/null and b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-cool-stable-96.png differ diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-cool-stable-96.spritesheet.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-cool-stable-96.spritesheet.png new file mode 100644 index 00000000000..724c6840350 Binary files /dev/null and b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-cool-stable-96.spritesheet.png differ diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-search-insiders-96.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-search-insiders-96.png new file mode 100644 index 00000000000..49635ac929f Binary files /dev/null and b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-search-insiders-96.png differ diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-search-insiders-96.spritesheet.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-search-insiders-96.spritesheet.png new file mode 100644 index 00000000000..4c2471e33c3 Binary files /dev/null and b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-search-insiders-96.spritesheet.png differ diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-search-stable-96.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-search-stable-96.png new file mode 100644 index 00000000000..920f1e26a0f Binary files /dev/null and b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-search-stable-96.png differ diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-search-stable-96.spritesheet.png b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-search-stable-96.spritesheet.png new file mode 100644 index 00000000000..7f5980b9e6b Binary files /dev/null and b/src/vs/workbench/contrib/chat/browser/widget/media/chatPet/buddy-search-stable-96.spritesheet.png differ diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 266bb95c10b..30ba76d0ecc 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -80,6 +80,7 @@ import { computeVoiceGlowStyle, isGlowingVoiceState, readVoiceGlowIntensity, Voi import { combineVoiceInput } from '../../voiceClient/voiceInputUtils.js'; import { IAgentTitleBarStatusService } from '../../agentSessions/experiments/agentTitleBarStatusService.js'; import { IVoicePlaybackService } from '../../../common/voicePlaybackService.js'; +import { VOICE_AGENT_PROGRESS_SETTING } from '../../../common/voiceClient/voiceClientService.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; interface IChatViewPaneState extends Partial<IChatModelInputState> { @@ -423,9 +424,13 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { widget.input.setValue(text, false); } else { // Preserve any text the user already typed in the input. - widget.acceptInput(combineVoiceInput(widget.getInput(), text), { preserveFocus: true }); + return widget.acceptInput(combineVoiceInput(widget.getInput(), text), { + preserveFocus: true, + isVoiceModeInput: this.configurationService.getValue<boolean>(VOICE_AGENT_PROGRESS_SETTING) === true, + }); } } + return undefined; })); this._voiceBarDisposables.add(CommandsRegistry.registerCommand('_chat.voice.switchToSession', async (_accessor, resourceStr: string): Promise<boolean> => { if (!resourceStr) { diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 901850d5c1b..466353d4913 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -156,6 +156,11 @@ export namespace ChatContextKeys { export const agentSessionsViewerPosition = new RawContextKey<number>('agentSessionsViewerPosition', undefined, { type: 'number', description: localize('agentSessionsViewerPosition', "Position of the agent sessions view in the chat view.") }); export const agentSessionsViewerVisible = new RawContextKey<boolean>('agentSessionsViewerVisible', undefined, { type: 'boolean', description: localize('agentSessionsViewerVisible', "Visibility of the agent sessions view in the chat view.") }); export const agentSessionType = new RawContextKey<string>('chatSessionType', '', { type: 'string', description: localize('agentSessionType', "The type of the current agent session item.") }); + /** + * Whether the agent session item has an associated pull request. Tri-state, so gate with + * `chatSessionPullRequest != 'none'` to keep contributions visible when the state is unknown. + */ + export const agentSessionPullRequest = new RawContextKey<string>('chatSessionPullRequest', '', { type: 'string', description: localize('agentSessionPullRequest', "Whether the current agent session item has an associated pull request: 'available' or 'none'. Unset when the pull request state is unknown.") }); export const chatSessionSupportsDelegation = new RawContextKey<boolean>('chatSessionSupportsDelegation', true, { type: 'boolean', description: localize('chatSessionSupportsDelegation', "True when the current session type supports delegation.") }); export const hasPendingDelegationTarget = new RawContextKey<boolean>('chatHasPendingDelegationTarget', false, { type: 'boolean', description: localize('chatHasPendingDelegationTarget', "True when a delegation (continue in) target is selected but the request has not been submitted yet.") }); export const chatSessionSupportsFork = new RawContextKey<boolean>('chatSessionSupportsFork', false, { type: 'boolean', description: localize('chatSessionSupportsFork', "True when the current chat session provider supports forking conversations.") }); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 79f9e23b5f5..19346eb60cc 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -165,7 +165,21 @@ export interface IChatUsage { completionTokens: number; outputBuffer?: number; promptTokenDetails?: readonly IChatUsagePromptTokenDetail[]; + /** + * The Copilot credit cost of whatever this usage describes — a turn's own + * cost on a response's usage, a sub-agent's component cost on a sub-agent's. + * Scoped to its container, so summing it across responses is only ever a + * lower bound on the session; prefer {@link sessionCopilotCredits} for that. + */ copilotCredits?: number; + /** + * The whole session's Copilot credit cost as reported by the backend, rather + * than summed from the individual turns. Unlike {@link copilotCredits} this + * deliberately describes more than its container: it is authoritative when + * present, and covers work billed outside any turn (e.g. a compaction that + * ran between turns). Not every backend reports it. + */ + sessionCopilotCredits?: number; /** * The language-model ID that actually served the request. Set when a * meta-model (e.g. "auto") routes to a concrete model so consumers @@ -489,14 +503,18 @@ export interface IChatQuestionCarousel { data?: IChatQuestionAnswers; /** Whether the carousel has been submitted/skipped */ isUsed?: boolean; - /** True when accepted/answered outside the carousel UI (e.g. via voice) without structured answers. */ + /** True when accepted/answered outside the carousel UI, such as by voice or automatic reply. */ answeredExternally?: boolean; + /** True when Copilot supplied the answer through automatic reply. */ + autoReply?: boolean; /** Top-level message shown above the questions (e.g. from MCP elicitation message) */ message?: string | IMarkdownString; /** Source attribution (e.g. MCP server) */ source?: ToolDataSource; /** Terminal ID when the carousel was triggered by a terminal needing input */ terminalId?: string; + /** Visual treatment for the answered state. */ + answerPresentation?: 'conversation'; kind: 'questionCarousel'; } @@ -584,6 +602,14 @@ export interface IChatHookPart { subAgentInvocationId?: string; } +export type ChatVoiceProgressStage = 'investigating' | 'planning' | 'editing' | 'validating' | 'recovering'; + +export interface IChatVoiceProgressPart { + readonly kind: 'voiceProgress'; + readonly id: ChatVoiceProgressStage; + readonly value: string; +} + export interface IChatTerminalToolInvocationData { kind: 'terminal'; commandLine: { @@ -1451,6 +1477,7 @@ export type IChatProgress = | IChatPullRequestContent | IChatUndoStop | IChatThinkingPart + | IChatVoiceProgressPart | IChatTaskSerialized | IChatElicitationRequest | IChatElicitationRequestSerialized @@ -1790,6 +1817,7 @@ export interface IRemotePendingRequest { export interface IChatSendRequestOptions { modeInfo?: IChatRequestModeInfo; + isVoiceModeInput?: boolean; userSelectedModelId?: string; /** * The configuration (e.g. context size, thinking effort) for the selected diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 91f55601673..51637dde035 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -285,6 +285,8 @@ export class ChatService extends Disposable implements IChatService { this._register(this._sessionModels.onDidDisposeModel(model => { clearChatMarks(model.sessionResource); this.chatDebugService.endSession(model.sessionResource); + this._sessionFollowupCancelTokens.get(model.sessionResource)?.cancel(); + this._sessionFollowupCancelTokens.deleteAndDispose(model.sessionResource); // Drop the forward untitled→real mapping for this session so it stops // re-targeting late sends. The inverse alias is intentionally retained. this.chatSessionService.clearMaterializedSessionResource(model.sessionResource); @@ -1609,6 +1611,7 @@ export class ChatService extends Disposable implements IChatService { editedFileEvents: thisRequest.editedFileEvents, hooks: collectedHooks, hasHooksEnabled: !!collectedHooks && Object.values(collectedHooks).some(arr => arr.length > 0), + isVoiceModeInput: options?.isVoiceModeInput, isSystemInitiated: options?.isSystemInitiated, workingDirectory: model.workingDirectory, }; diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 4ec1e618c19..8ef773aaed3 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -485,6 +485,7 @@ export interface ILanguageModelChatInfoOptions { export interface ILanguageModelChatRequestOptions { readonly modelOptions?: IStringDictionary<unknown>; readonly configuration?: IStringDictionary<unknown>; + readonly includeEncryptedThinking?: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly [name: string]: any; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index d14742f9488..93c53dd8378 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -31,7 +31,7 @@ import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCo import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImplicitVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; import { ChatPerfMark, markChat } from '../chatPerf.js'; -import { ChatAgentVoteDirection, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatAutoModeResolutionPart, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalEdit, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatInfoMessage, IChatLocationData, IChatMarkdownContent, IChatMcpAuthenticationRequired, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMcpServersStartingSlow, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatPlanReview, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionTiming, IChatSystemNotificationPart, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsagePromptTokenDetail, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, ToolConfirmKind, isIUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatAutoModeResolutionPart, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalEdit, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatInfoMessage, IChatLocationData, IChatMarkdownContent, IChatMcpAuthenticationRequired, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMcpServersStartingSlow, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatPlanReview, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionTiming, IChatSystemNotificationPart, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsagePromptTokenDetail, IChatUsedContext, IChatVoiceProgressPart, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, ToolConfirmKind, isIUsedContext } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ChatToolInvocation } from './chatProgressTypes/chatToolInvocation.js'; import { ChatPlanReviewData } from './chatProgressTypes/chatPlanReviewData.js'; @@ -72,6 +72,7 @@ export interface ISerializableSendOptions { locationData?: IChatLocationData; attempt?: number; noCommandDetection?: boolean; + isVoiceModeInput?: boolean; agentId?: string; agentIdSilent?: string; slashCommand?: string; @@ -228,7 +229,8 @@ export type IChatProgressResponseContent = | IChatMcpServersStartingSerialized | IChatMcpAuthenticationRequired | IChatMcpServersStartingSlow - | IChatDisabledClaudeHooksPart; + | IChatDisabledClaudeHooksPart + | IChatVoiceProgressPart; export type IChatProgressResponseContentSerialized = Exclude<IChatProgressResponseContent, | IChatToolInvocation @@ -239,9 +241,10 @@ export type IChatProgressResponseContentSerialized = Exclude<IChatProgressRespon | IChatMcpAuthenticationRequired | IChatMcpServersStartingSlow | IChatDisabledClaudeHooksPart + | IChatVoiceProgressPart >; -const nonHistoryKinds = new Set(['toolInvocation', 'toolInvocationSerialized', 'undoStop']); +const nonHistoryKinds = new Set(['toolInvocation', 'toolInvocationSerialized', 'undoStop', 'voiceProgress']); function isChatProgressHistoryResponseContent(content: IChatProgressResponseContent): content is IChatProgressHistoryResponseContent { return !nonHistoryKinds.has(content.kind); } @@ -250,7 +253,7 @@ export function toChatHistoryContent(content: ReadonlyArray<IChatProgressRespons return content.filter(isChatProgressHistoryResponseContent); } -export type IChatProgressRenderableResponseContent = Exclude<IChatProgressResponseContent, IChatContentInlineReference | IChatAgentMarkdownContentWithVulnerability | IChatResponseCodeblockUriPart>; +export type IChatProgressRenderableResponseContent = Exclude<IChatProgressResponseContent, IChatContentInlineReference | IChatAgentMarkdownContentWithVulnerability | IChatResponseCodeblockUriPart | IChatVoiceProgressPart>; export interface IResponse { readonly value: ReadonlyArray<IChatProgressResponseContent>; @@ -628,6 +631,7 @@ class AbstractResponse implements IResponse { case 'elicitationSerialized': case 'thinking': case 'hook': + case 'voiceProgress': case 'multiDiffData': case 'mcpServersStarting': case 'mcpAuthenticationRequired': @@ -1539,12 +1543,27 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel } private _setUsage(usage: IChatUsage, countCompletionTokens: boolean): void { - if (this.isSameUsage(usage)) { + const currentUsage = this._usageObs.get(); + if (currentUsage && this.isSameUsage(currentUsage, usage)) { return; } + // Only a report describing a *different* model call adds to the running + // completion-token total. A backend can re-report one call several times as + // slower-arriving detail resolves — the agent host re-emits with the context + // attribution and the session cost once its RPCs return — and those + // refinements must update the stored usage without being counted again. + // + // Two consecutive calls reporting identical tokens are indistinguishable here + // and the second is treated as a refinement. That is pre-existing: the + // `isSameUsage` guard already discarded such a report wholesale. + const isNewCall = !currentUsage + || currentUsage.promptTokens !== usage.promptTokens + || currentUsage.completionTokens !== usage.completionTokens + || currentUsage.outputBuffer !== usage.outputBuffer; + this._usageObs.set(usage, undefined); - if (countCompletionTokens) { + if (countCompletionTokens && isNewCall) { const previousCompletionTokens = this._completionTokenCountObs.get() ?? 0; this._completionTokenCountObs.set(previousCompletionTokens + usage.completionTokens, undefined); } @@ -1555,13 +1574,12 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel this._elapsedMs = Math.max(0, elapsedMs); } - private isSameUsage(usage: IChatUsage): boolean { - const currentUsage = this._usageObs.get(); - return !!currentUsage - && currentUsage.promptTokens === usage.promptTokens + private isSameUsage(currentUsage: IChatUsage, usage: IChatUsage): boolean { + return currentUsage.promptTokens === usage.promptTokens && currentUsage.completionTokens === usage.completionTokens && currentUsage.outputBuffer === usage.outputBuffer && currentUsage.copilotCredits === usage.copilotCredits + && currentUsage.sessionCopilotCredits === usage.sessionCopilotCredits && equals(currentUsage.promptTokenDetails, usage.promptTokenDetails); } @@ -1680,6 +1698,7 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel outputBuffer: this.usage?.outputBuffer, promptTokenDetails: this.usage?.promptTokenDetails, copilotCredits: this.usage?.copilotCredits, + sessionCopilotCredits: this.usage?.sessionCopilotCredits, elapsedMs: this.elapsedMs ?? (this.completedAt ? Math.max(0, this.completedAt - this.confirmationAdjustedTimestamp.get()) : undefined), } satisfies WithDefinedProps<Omit<ISerializableChatResponseData, 'timestamp'>>; } @@ -1783,6 +1802,7 @@ interface ISerializableChatResponseData { outputBuffer?: number; promptTokenDetails?: readonly IChatUsagePromptTokenDetail[]; copilotCredits?: number; + sessionCopilotCredits?: number; elapsedMs?: number; } @@ -2488,14 +2508,23 @@ export class ChatModel extends Disposable implements IChatModel { } get sessionCost(): number { - let totalCredits = 0; + let summedCredits = 0; + let reportedSessionCredits = 0; for (const request of this._requests) { - const credits = request.response?.usage?.copilotCredits; - if (typeof credits === 'number') { - totalCredits += credits; + const usage = request.response?.usage; + if (typeof usage?.copilotCredits === 'number') { + summedCredits += usage.copilotCredits; + } + if (typeof usage?.sessionCopilotCredits === 'number') { + reportedSessionCredits = Math.max(reportedSessionCredits, usage.sessionCopilotCredits); } } - return totalCredits; + // A backend that reports the session total covers work billed outside any + // turn, which summing the turns would miss. Summing covers turns whose + // backend reports no session total, and any billed after the most recent + // reported total. Neither is a superset, so take whichever is larger — + // which is also independent of the order the two kinds are interleaved in. + return Math.max(summedCredits, reportedSessionCredits); } private _timestamp: number; @@ -2821,7 +2850,7 @@ export class ChatModel extends Disposable implements IChatModel { codeBlockInfos: raw.responseMarkdownInfo?.map<ICodeBlockInfo>(info => ({ suggestionId: info.suggestionId })), }); request.response.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; - if (typeof raw.completionTokens === 'number' || typeof raw.promptTokens === 'number' || typeof raw.copilotCredits === 'number') { + if (typeof raw.completionTokens === 'number' || typeof raw.promptTokens === 'number' || typeof raw.copilotCredits === 'number' || typeof raw.sessionCopilotCredits === 'number') { request.response.setUsage({ kind: 'usage', promptTokens: raw.promptTokens ?? 0, @@ -2829,6 +2858,7 @@ export class ChatModel extends Disposable implements IChatModel { outputBuffer: raw.outputBuffer, promptTokenDetails: raw.promptTokenDetails, copilotCredits: raw.copilotCredits, + sessionCopilotCredits: raw.sessionCopilotCredits, }); } if (raw.usedContext) { // @ulugbekna: if this's a new vscode sessions, doc versions are incorrect anyway? @@ -3142,7 +3172,7 @@ export class ChatModel extends Disposable implements IChatModel { message, variableData: IChatRequestVariableData.toExport(r.variableData), response: r.response ? - r.response.entireResponse.value.map(item => { + r.response.entireResponse.value.filter(item => item.kind !== 'voiceProgress').map(item => { // Keeping the shape of the persisted data the same for back compat if (item.kind === 'treeData') { return item.treeData; @@ -3267,6 +3297,7 @@ export function serializeSendOptions(options: IChatSendRequestOptions): ISeriali locationData: options.locationData, attempt: options.attempt, noCommandDetection: options.noCommandDetection, + isVoiceModeInput: options.isVoiceModeInput, agentId: options.agentId, agentIdSilent: options.agentIdSilent, slashCommand: options.slashCommand, diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts index 7b94d7c1b0e..2745c87c71a 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts @@ -26,10 +26,11 @@ export class ChatQuestionCarouselData implements IChatQuestionCarousel { public dismissedByTerminalInput?: boolean; /** - * True when the input was accepted/answered outside the carousel UI (e.g. - * via voice) without structured answers, so the summary reads "Answered". + * True when the input was accepted/answered outside the carousel UI, such + * as by voice or automatic reply. */ public answeredExternally?: boolean; + public autoReply?: boolean; /** * Marks the carousel as dismissed with the given answers and clears draft @@ -56,6 +57,7 @@ export class ChatQuestionCarouselData implements IChatQuestionCarousel { public message?: string | IMarkdownString, public source?: ToolDataSource, public terminalId?: string, + public answerPresentation?: 'conversation', ) { } toJSON(): IChatQuestionCarousel { @@ -67,9 +69,11 @@ export class ChatQuestionCarouselData implements IChatQuestionCarousel { data: this.data, isUsed: this.isUsed, answeredExternally: this.answeredExternally, + autoReply: this.autoReply, message: this.message, source: this.source, terminalId: this.terminalId, + answerPresentation: this.answerPresentation, }; } } diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index f9d24949f4f..951ca1bde83 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -10,7 +10,7 @@ import { isEqual as _urisEqual } from '../../../../../base/common/resources.js'; import { hasKey } from '../../../../../base/common/types.js'; import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js'; -import { IChatMarkdownContent, IChatMcpAuthenticationRequired, IChatMcpServersStartingSlow, ResponseModelState } from '../chatService/chatService.js'; +import { IChatMarkdownContent, IChatMcpAuthenticationRequired, IChatMcpServersStartingSlow, IChatVoiceProgressPart, ResponseModelState } from '../chatService/chatService.js'; import { ModifiedFileEntryState } from '../editing/chatEditingService.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { IChatAgentEditedFileEvent, IChatDataSerializerLog, IChatModel, IChatPendingRequest, IChatProgressResponseContent, IChatRequestModel, IChatRequestVariableData, ISerializableChatData, ISerializableChatModelInputState, ISerializableChatRequestData, ISerializablePendingRequestData, SerializedChatResponsePart, serializeSendOptions } from './chatModel.js'; @@ -38,7 +38,9 @@ const toJson = <T>(obj: T): T extends { toJSON?(): infer R } ? R : T => { return (cast && typeof cast.toJSON === 'function' ? cast.toJSON() : obj) as any; }; -const responsePartSchema = Adapt.v<Exclude<IChatProgressResponseContent, IChatMcpAuthenticationRequired | IChatMcpServersStartingSlow>, SerializedChatResponsePart>( +type PersistedResponsePart = Exclude<IChatProgressResponseContent, IChatMcpAuthenticationRequired | IChatMcpServersStartingSlow | IChatVoiceProgressPart>; + +const responsePartSchema = Adapt.v<PersistedResponsePart, SerializedChatResponsePart>( (obj): SerializedChatResponsePart => obj.kind === 'markdownContent' ? obj.content : toJson(obj), (a, b) => { if (isMarkdownString(a) && isMarkdownString(b)) { @@ -139,7 +141,7 @@ const requestSchema = Adapt.object<IChatRequestModel, ISerializableChatRequestDa isHidden: Adapt.v(() => undefined), // deprecated, always undefined for new data isCanceled: Adapt.v(() => undefined), // deprecated, modelState is used instead - response: Adapt.t(m => m.response?.entireResponse.value.filter((p): p is Exclude<IChatProgressResponseContent, IChatMcpAuthenticationRequired | IChatMcpServersStartingSlow> => p.kind !== 'mcpAuthenticationRequired' && p.kind !== 'mcpServersStartingSlow'), Adapt.array(responsePartSchema)), + response: Adapt.t(m => m.response?.entireResponse.value.filter((p): p is PersistedResponsePart => p.kind !== 'mcpAuthenticationRequired' && p.kind !== 'mcpServersStartingSlow' && p.kind !== 'voiceProgress'), Adapt.array(responsePartSchema)), responseId: Adapt.v(m => m.response?.id), responseTimestamp: Adapt.v(m => m.response?.timestamp), result: Adapt.v(m => m.response?.result, objectsEqual), @@ -160,6 +162,7 @@ const requestSchema = Adapt.object<IChatRequestModel, ISerializableChatRequestDa outputBuffer: Adapt.v(m => m.response?.usage?.outputBuffer), promptTokenDetails: Adapt.v(m => m.response?.usage?.promptTokenDetails, objectsEqual), copilotCredits: Adapt.v(m => m.response?.usage?.copilotCredits), + sessionCopilotCredits: Adapt.v(m => m.response?.usage?.sessionCopilotCredits), elapsedMs: Adapt.v(m => m.response?.elapsedMs ?? (m.response?.completedAt ? Math.max(0, m.response.completedAt - m.response.confirmationAdjustedTimestamp.get()) : undefined)), modeInfo: Adapt.v(m => m.modeInfo, objectsEqual), isSystemInitiated: Adapt.v(m => m.isSystemInitiated), diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 106ff7624c2..577a39487b8 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -175,6 +175,10 @@ export interface IChatAgentRequest { * Whether any hooks are enabled for this request. */ hasHooksEnabled?: boolean; + /** + * Whether this request was submitted through Agents Voice Mode. + */ + isVoiceModeInput?: boolean; /** * The permission level for tool auto-approval in this request. * - `'autoApprove'`: Auto-approve all tool calls and retry on errors. diff --git a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts index c87645f623d..1a83c62aed2 100644 --- a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts @@ -5,6 +5,7 @@ import { Event } from '../../../../../base/common/event.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import type { ChatVoiceProgressStage } from '../chatService/chatService.js'; /** * One selectable option on a pending question, positioned in *displayed* order. @@ -97,19 +98,31 @@ export function peekPendingId(requestId: string, part: object): string | undefin export interface IVoiceSessionContext { sessions: { id: string; + /** Human-readable name, so the backend can tell two sessions apart. */ + label?: string; is_active: boolean; agent_state: string; agent_state_detail?: string; + confirmation_type?: VoiceConfirmationType; last_response_summary?: string; pending?: IVoiceSessionPending; }[]; - active_session?: { - id: string; - last_message: string | null; - }; display_locale: string; } +export type VoiceConfirmationType = 'questionnaire' | 'elicitation' | 'plan' | 'tool' | 'generic'; +export type VoiceCheckpointId = ChatVoiceProgressStage; + +export function isVoiceCheckpointId(value: unknown): value is VoiceCheckpointId { + return value === 'investigating' || value === 'planning' || value === 'editing' || value === 'validating' || value === 'recovering'; +} + +export interface IVoiceCheckpointNarrationMetadata { + readonly requestId: string; + readonly checkpointId: VoiceCheckpointId; + readonly sequence: number; +} + /** * What a client-requested narration is speaking. Mirrors `NarrationKind` in the * voice backend. @@ -118,7 +131,7 @@ export interface IVoiceSessionContext { * by the narration model: the numbered options are the ordinals the user says * back, so a summary that drops them breaks answering. */ -export type VoiceNarrationKind = 'response' | 'confirmation' | 'question'; +export type VoiceNarrationKind = 'response' | 'confirmation' | 'question' | 'checkpoint'; /** * Structured outcome of a dispatched voice tool call. The backend speaks an @@ -160,6 +173,11 @@ export interface IVoiceAudioResponse { * direct replies and for backends that don't yet echo it (legacy fallback). */ readonly responseId?: string; + readonly requestId?: string; + readonly checkpointId?: VoiceCheckpointId; + readonly sequence?: number; + readonly narrationKind?: VoiceNarrationKind; + readonly playbackId?: string; } export interface IVoiceBargeIn { @@ -168,7 +186,7 @@ export interface IVoiceBargeIn { } /** Disposition of a client `request_narration`, reported by `narration_ack`. */ -export type IVoiceNarrationDisposition = 'accepted' | 'busy' | 'invalid'; +export type IVoiceNarrationDisposition = 'accepted' | 'busy' | 'invalid' | 'suppressed'; /** The backend's acknowledgement of a `request_narration`. */ export interface IVoiceNarrationAck { @@ -188,6 +206,8 @@ export interface IVoiceNarrationAck { export interface IVoiceNarrationSignal { readonly narrationId: string; readonly codingSessionId: string; + readonly retryable?: boolean; + readonly reason?: string; } export interface IVoiceToolCall { @@ -196,7 +216,9 @@ export interface IVoiceToolCall { readonly args: Record<string, unknown>; } -export interface IVoiceSpeechStarted { } +export interface IVoiceSpeechStarted { + readonly turnId?: string; +} export interface IVoiceSessionInit { readonly sessionId: string; @@ -344,6 +366,8 @@ export interface IVoiceClientService { */ invalidateSessionCache(sessionId: string): void; sendToolResult(callId: string, result: string | IVoiceDispatchResult): void; + /** Report that one correlated checkpoint playback attempt finished locally. */ + sendNarrationPlaybackComplete(codingSessionId: string, narrationId: string, playbackId: string): void; /** * Ask the backend to speak `text` for a session now; returns the narration id * echoed on the resulting `audio_response`, or `undefined` if nothing was @@ -358,7 +382,7 @@ export interface IVoiceClientService { * backend's mirror has caught up. The id is deliberately *not* folded into * `text`, which every dedup and retry-reuse guard keys on. */ - requestNarration(codingSessionId: string, kind: VoiceNarrationKind, text: string, narrationId?: string, pending?: { pendingId: string }): string | undefined; + requestNarration(codingSessionId: string, kind: VoiceNarrationKind, text: string, narrationId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata, confirmationType?: VoiceConfirmationType, pending?: { pendingId: string }): string | undefined; /** * Notify the backend of a session state transition. * @@ -409,8 +433,11 @@ export interface IVoiceClientService { // --- State --- readonly isConnected: boolean; readonly isResuming: boolean; + /** Whether the current socket close has an automatic retry scheduled. */ + readonly willReconnect: boolean; /** Backend session id assigned by the realtime server, or ``undefined`` when not yet established. */ readonly currentSessionId: string | undefined; } export const IVoiceClientService = createDecorator<IVoiceClientService>('voiceClientService'); +export const VOICE_AGENT_PROGRESS_SETTING = 'agents.voice.agentProgress'; diff --git a/src/vs/workbench/contrib/chat/common/voiceClient/voiceConfirmation.ts b/src/vs/workbench/contrib/chat/common/voiceClient/voiceConfirmation.ts new file mode 100644 index 00000000000..2c50744cc14 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/voiceClient/voiceConfirmation.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ElicitationState, IChatToolInvocation } from '../chatService/chatService.js'; +import type { IChatProgressResponseContent } from '../model/chatModel.js'; +import { AskQuestionsToolId } from '../tools/builtinTools/askQuestionsTool.js'; +import type { VoiceConfirmationType } from './voiceClientService.js'; + +export function isVoiceQuestionnaireInvocation(part: IChatProgressResponseContent): part is IChatToolInvocation { + return part.kind === 'toolInvocation' && part.toolId === AskQuestionsToolId; +} + +export function isPendingVoiceQuestionnaireInvocation(part: IChatProgressResponseContent): part is IChatToolInvocation { + if (!isVoiceQuestionnaireInvocation(part)) { + return false; + } + const state = part.state.get(); + return state.type === IChatToolInvocation.StateKind.WaitingForConfirmation + || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval; +} + +export function getVoiceConfirmationType(parts: readonly IChatProgressResponseContent[]): VoiceConfirmationType | undefined { + for (let index = parts.length - 1; index >= 0; index--) { + const part = parts[index]; + if (part.kind === 'questionCarousel' && !part.isUsed) { + return 'questionnaire'; + } + if (part.kind === 'elicitation2' && part.state.get() === ElicitationState.Pending) { + return 'elicitation'; + } + if (isPendingVoiceQuestionnaireInvocation(part)) { + return 'questionnaire'; + } + } + + for (let index = parts.length - 1; index >= 0; index--) { + const part = parts[index]; + if (part.kind === 'planReview' && !part.isUsed) { + return 'plan'; + } + if (part.kind === 'toolInvocation') { + const state = part.state.get(); + if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { + return 'tool'; + } + if (state.type === IChatToolInvocation.StateKind.WaitingForAuthentication) { + return 'generic'; + } + } + if (part.kind === 'confirmation' && !part.isUsed) { + return 'generic'; + } + } + + return undefined; +} diff --git a/src/vs/workbench/contrib/chat/common/widget/annotations.ts b/src/vs/workbench/contrib/chat/common/widget/annotations.ts index eb013e2b766..e27515a0330 100644 --- a/src/vs/workbench/contrib/chat/common/widget/annotations.ts +++ b/src/vs/workbench/contrib/chat/common/widget/annotations.ts @@ -96,6 +96,8 @@ export function annotateSpecialMarkdownContent(response: Iterable<IChatProgressR result.splice(previousItemIndex, 1); result.push({ ...previousItem, content: merged }); } + } else if (item.kind === 'voiceProgress') { + continue; } else { result.push(item); } diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/installDictationModelAction.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/installDictationModelAction.ts new file mode 100644 index 00000000000..c8548e5a067 --- /dev/null +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/installDictationModelAction.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { joinPath } from '../../../../../base/common/resources.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; +import { DEFAULT_LOCAL_TRANSCRIPTION_MODEL, ILocalTranscriptionService } from '../../../../../platform/localTranscription/common/localTranscription.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; +import { CHAT_CATEGORY } from '../../browser/actions/chatActions.js'; +import { INSTALL_DICTATION_MODEL_COMMAND_ID } from '../../browser/speechToText/chatSpeechToTextService.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; + +export function registerInstallDictationModelAction(): void { + const enabled = ContextKeyExpr.and( + ChatContextKeys.enabled, + ContextKeyExpr.equals('config.dictation.enabled', true), + ); + + registerAction2(class InstallDictationModelAction extends Action2 { + constructor() { + super({ + id: INSTALL_DICTATION_MODEL_COMMAND_ID, + category: CHAT_CATEGORY, + title: localize2('chat.installDictationModel', "Install Dictation Model from Local Package..."), + precondition: enabled, + menu: { + id: MenuId.CommandPalette, + when: enabled, + }, + }); + } + + async run(accessor: ServicesAccessor): Promise<void> { + const localTranscriptionService = accessor.get(ILocalTranscriptionService); + const notificationService = accessor.get(INotificationService); + const fileDialogService = accessor.get(IFileDialogService); + const progressService = accessor.get(IProgressService); + const environmentService = accessor.get(IEnvironmentService); + if (!localTranscriptionService.isSupported) { + notificationService.warn(localize('chat.installDictationModel.unsupported', "On-device dictation is not supported on this platform.")); + return; + } + + const sources = await fileDialogService.showOpenDialog({ + title: localize('chat.installDictationModel.dialogTitle', "Select the {0} CPU model package (.zip) or folder", DEFAULT_LOCAL_TRANSCRIPTION_MODEL), + openLabel: localize('chat.installDictationModel.openLabel', "Install"), + canSelectFiles: true, + canSelectFolders: true, + canSelectMany: false, + filters: [{ name: localize('chat.installDictationModel.filter', "Foundry Local Model Package"), extensions: ['zip'] }], + }); + const source = sources?.[0]; + if (!source) { + return; + } + if (source.scheme !== Schemas.file) { + notificationService.error(localize('chat.installDictationModel.localOnly', "The dictation model package must be on the local file system.")); + return; + } + + const cacheDir = joinPath(environmentService.cacheHome, 'chatDictationModels').fsPath; + try { + const result = await progressService.withProgress({ + location: ProgressLocation.Notification, + title: localize('chat.installDictationModel.progress', "Installing dictation model..."), + }, () => localTranscriptionService.importModel({ sourcePath: source.fsPath, cacheDir })); + notificationService.info(localize( + 'chat.installDictationModel.success', + "Installed {0} version {1}.", + result.model, + result.version, + )); + } catch (error) { + notificationService.error(localize( + 'chat.installDictationModel.error', + "Failed to install the dictation model: {0}", + error instanceof Error ? error.message : String(error), + )); + } + } + }); +} diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index 73d4a1b6947..c927fa1d3af 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -55,6 +55,7 @@ import { IPluginGitService } from '../common/plugins/pluginGitService.js'; import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js'; import { registerChatExportZipAction } from './actions/chatExportZip.js'; import { registerExportAgentTracesDbAction } from './actions/exportAgentTracesDb.js'; +import { registerInstallDictationModelAction } from './actions/installDictationModelAction.js'; import { shouldWarnForSessionShutdown } from './chatLifecycle.js'; import { HoldToVoiceChatInChatViewAction, InlineVoiceChatAction, KeywordActivationContribution, QuickVoiceChatAction, ReadChatResponseAloud, StartVoiceChatAction, StopListeningAction, StopListeningAndSubmitAction, StopReadAloud, StopReadChatItemAloud, VoiceChatInChatViewAction } from './actions/voiceChatActions.js'; import { OpenWorkspaceInAgentsWindowAction, OpenWorkspaceInAgentsContribution, OpenAgentsWindowAction, OpenChatSessionInAgentsWindowAction, AgentsHandoffInputTipContribution, ToggleOpenInAgentsWindowTitleBarAction, OpenWorkspaceInAgentsWindowChatTitleAction, OpenWorkspaceInAgentsWindowTitleBarAction } from './agentSessions/agentSessionsActions.js'; @@ -264,6 +265,7 @@ registerAction2(StopReadAloud); registerChatDeveloperActions(); registerChatExportZipAction(); registerExportAgentTracesDbAction(); +registerInstallDictationModelAction(); registerWorkbenchContribution2(KeywordActivationContribution.ID, KeywordActivationContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(NativeBuiltinToolsContribution.ID, NativeBuiltinToolsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index bfd5cfdf5a6..ff82ba4e572 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -25,6 +25,23 @@ suite('Chat Accessibility Help', () => { }); }); + test('describes the VS Code pet context menu', () => { + const keybindingService = { + lookupKeybindings: () => [], + } as unknown as IKeybindingService; + const helpText = getAccessibilityHelpText('agentView', keybindingService, true); + + assert.deepStrictEqual({ + keybinding: helpText.includes('<keybinding:editor.action.showContextMenu>'), + navigation: helpText.includes('use the up and down arrow keys to choose'), + actions: helpText.includes('Go on the Run') && helpText.includes('Stable Colors') && helpText.includes('Insiders Colors'), + }, { + keybinding: true, + navigation: true, + actions: true, + }); + }); + test('only describes the selection side chat affordance in the sessions window', () => { const keybindingService = { lookupKeybindings: () => [], @@ -56,4 +73,18 @@ suite('Chat Accessibility Help', () => { byDefault: false, }); }); + + test('only describes spoken agent progress in agent mode', () => { + const keybindingService = { + lookupKeybindings: () => [], + } as unknown as IKeybindingService; + + assert.deepStrictEqual({ + agentView: getAccessibilityHelpText('agentView', keybindingService, true).includes('brief progress updates'), + panelChat: getAccessibilityHelpText('panelChat', keybindingService, true).includes('brief progress updates'), + }, { + agentView: true, + panelChat: false, + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts index 5952e1b4666..d9ed95f9857 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Event } from '../../../../../../base/common/event.js'; import { mock } from '../../../../../../base/test/common/mock.js'; @@ -128,30 +129,102 @@ suite('AgentHostByokLmHandler', () => { ]); }); - test('resolves the BYOK model and buffers text + tool calls', async () => { + test('listModels carries string reasoning effort metadata from renderer BYOK schemas', async () => { + const service = new TestLanguageModelsService( + new Map<string, ILanguageModelChatMetadata>([ + ['id-reasoning', { + ...byokModel('acme', 'reasoning'), + configurationSchema: { + properties: { + reasoningEffort: { + type: 'string', + enum: ['minimal', 'low', 1, 'high'], + default: 'high', + }, + }, + }, + }], + ['id-malformed', { + ...byokModel('acme', 'malformed'), + configurationSchema: { + properties: { + reasoningEffort: { + type: 'string', + enum: [1, false], + default: 1, + }, + }, + }, + }], + ['id-plain', byokModel('acme', 'plain')], + ]), + () => responseOf([]), + ); + const handler = createHandler(service); + + const models = await handler.listModels(CancellationToken.None); + + assert.deepStrictEqual(models, [ + { + vendor: 'acme', + id: 'reasoning', + name: 'acme reasoning', + modelIdentifier: 'id-reasoning', + maxContextWindowTokens: 2000, + supportsVision: false, + supportedReasoningEfforts: ['minimal', 'low', 'high'], + defaultReasoningEffort: 'high', + }, + { vendor: 'acme', id: 'malformed', name: 'acme malformed', modelIdentifier: 'id-malformed', maxContextWindowTokens: 2000, supportsVision: false }, + { vendor: 'acme', id: 'plain', name: 'acme plain', modelIdentifier: 'id-plain', maxContextWindowTokens: 2000, supportsVision: false }, + ]); + }); + + test('buffers ordered thinking, text, tool calls, continuation and usage', async () => { const service = new TestLanguageModelsService( new Map([['id-acme-claude', byokModel('acme', 'claude')]]), () => responseOf([ + { type: 'thinking', value: 'considered ', id: 'rs_1' }, + { type: 'thinking', value: ['options'], id: 'rs_1', metadata: { encrypted_content: 'opaque' } }, + { type: 'thinking', value: '', id: 'thinking_2', metadata: { signature: 'sig', _completeThinking: 'full thought' } }, { type: 'text', value: 'hello ' }, { type: 'text', value: 'world' }, { type: 'tool_use', name: 'getWeather', toolCallId: 't1', parameters: { city: 'NYC' } }, + { type: 'tool_use', name: 'apply_patch', toolCallId: 't2', parameters: { input: 'patch' } }, + { type: 'data', mimeType: 'stateful_marker', data: VSBuffer.fromString('claude\\resp_provider') }, + { type: 'data', mimeType: 'usage', data: VSBuffer.fromString('{"prompt_tokens":10,"completion_tokens":5,"completion_tokens_details":{"reasoning_tokens":2}}') }, ]), ); const handler = createHandler(service); const result = await handler.chat( - { vendor: 'acme', modelId: 'claude', messages: [{ role: 'user', content: 'hi' }] }, + { + vendor: 'acme', + modelId: 'claude', + input: [{ type: 'message', role: 'user', content: [{ type: 'text', text: 'hi' }] }], + tools: [ + { type: 'function', name: 'getWeather' }, + { type: 'custom', name: 'apply_patch' }, + ], + }, CancellationToken.None, ); assert.strictEqual(service.captured?.modelId, 'id-acme-claude'); assert.deepStrictEqual(result, { - content: 'hello world', - toolCalls: [{ id: 't1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }], + output: [ + { type: 'reasoning', id: 'rs_1', summary: ['considered ', 'options'], encryptedContent: 'opaque', metadata: { encrypted_content: 'opaque' } }, + { type: 'reasoning', id: 'thinking_2', summary: [''], encryptedContent: 'vscode-reasoning-metadata:{"signature":"sig","_completeThinking":"full thought"}', metadata: { signature: 'sig', _completeThinking: 'full thought' } }, + { type: 'message', content: [{ type: 'text', text: 'hello world' }] }, + { type: 'function_call', callId: 't1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }, + { type: 'custom_tool_call', callId: 't2', name: 'apply_patch', input: 'patch' }, + ], + responseId: 'resp_provider', + usage: { inputTokens: 10, outputTokens: 5, reasoningTokens: 2 }, }); }); - test('maps bridge messages to LM API chat messages', async () => { + test('maps ordered Responses input and options to LM API chat messages', async () => { const service = new TestLanguageModelsService( new Map([['id', byokModel('acme', 'claude')]]), () => responseOf([{ type: 'text', value: 'ok' }]), @@ -162,41 +235,63 @@ suite('AgentHostByokLmHandler', () => { { vendor: 'acme', modelId: 'claude', - messages: [ - { role: 'system', content: 'be helpful' }, - { role: 'user', content: 'hi' }, - { role: 'assistant', content: '', toolCalls: [{ id: 't1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }] }, - { role: 'tool', content: 'sunny', toolCallId: 't1' }, + instructions: 'be helpful', + previousResponseId: 'resp_previous', + reasoningEffort: 'high', + modelOptions: { temperature: 0.5 }, + tools: [ + { type: 'function', name: 'getWeather', parametersSchema: { type: 'object' } }, + { type: 'custom', name: 'apply_patch' }, + ], + input: [ + { type: 'reasoning', id: 'rs_1', summary: ['thought'], encryptedContent: 'opaque' }, + { type: 'reasoning', id: 'rs_2', summary: ['other thought'], encryptedContent: 'vscode-reasoning-metadata:{"signature":"sig-2","_completeThinking":"other complete thought"}' }, + { type: 'message', role: 'assistant', content: [{ type: 'text', text: 'check' }, { type: 'text', text: 'ing' }] }, + { type: 'function_call', callId: 't1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }, + { type: 'custom_tool_call', callId: 't2', name: 'apply_patch', input: 'patch' }, + { type: 'function_call_output', callId: 't1', output: 'sunny' }, + { type: 'custom_tool_call_output', callId: 't2', output: 'Done!' }, + { type: 'message', role: 'user', content: [{ type: 'text', text: 'hi' }] }, ], }, CancellationToken.None, ); - assert.deepStrictEqual(service.captured?.messages, [ - { role: ChatMessageRole.System, content: [{ type: 'text', value: 'be helpful' }] }, - { role: ChatMessageRole.User, content: [{ type: 'text', value: 'hi' }] }, - { role: ChatMessageRole.Assistant, content: [{ type: 'tool_use', name: 'getWeather', toolCallId: 't1', parameters: { city: 'NYC' } }] }, - // A `tool` message (with a toolCallId) rides on a User-role message and carries its - // payload solely in the tool_result part — no duplicate leading text part. - { role: ChatMessageRole.User, content: [{ type: 'tool_result', toolCallId: 't1', value: [{ type: 'text', value: 'sunny' }] }] }, - ]); - }); - - test('maps a tool message without a toolCallId to a plain user text part', async () => { - const service = new TestLanguageModelsService( - new Map([['id', byokModel('acme', 'claude')]]), - () => responseOf([{ type: 'text', value: 'ok' }]), - ); - const handler = createHandler(service); - - await handler.chat( - { vendor: 'acme', modelId: 'claude', messages: [{ role: 'tool', content: 'orphaned tool output' }] }, - CancellationToken.None, - ); - - assert.deepStrictEqual(service.captured?.messages, [ - { role: ChatMessageRole.User, content: [{ type: 'text', value: 'orphaned tool output' }] }, - ]); + const messages = service.captured?.messages.map(message => ({ + role: message.role, + content: message.content.map(part => part.type === 'data' ? { ...part, data: part.data.toString() } : part), + })); + assert.deepStrictEqual({ + messages, + options: service.captured?.options, + }, { + messages: [ + { role: ChatMessageRole.Assistant, content: [{ type: 'data', mimeType: 'stateful_marker', data: 'claude\\resp_previous' }] }, + { role: ChatMessageRole.System, content: [{ type: 'text', value: 'be helpful' }] }, + { + role: ChatMessageRole.Assistant, + content: [ + { type: 'thinking', value: ['thought'], id: 'rs_1', metadata: { encrypted_content: 'opaque' } }, + { type: 'thinking', value: ['other thought'], id: 'rs_2', metadata: { signature: 'sig-2', _completeThinking: 'other complete thought' } }, + { type: 'text', value: 'checking' }, + { type: 'tool_use', name: 'getWeather', toolCallId: 't1', parameters: { city: 'NYC' } }, + { type: 'tool_use', name: 'apply_patch', toolCallId: 't2', parameters: { input: 'patch' } }, + ], + }, + { role: ChatMessageRole.User, content: [{ type: 'tool_result', toolCallId: 't1', value: [{ type: 'text', value: 'sunny' }] }] }, + { role: ChatMessageRole.User, content: [{ type: 'tool_result', toolCallId: 't2', value: [{ type: 'text', value: 'Done!' }] }] }, + { role: ChatMessageRole.User, content: [{ type: 'text', value: 'hi' }] }, + ], + options: { + modelOptions: { temperature: 0.5 }, + includeEncryptedThinking: true, + configuration: { reasoningEffort: 'high' }, + tools: [ + { name: 'getWeather', description: '', inputSchema: { type: 'object' } }, + { name: 'apply_patch', description: '', inputSchema: { type: 'object', properties: { input: { type: 'string' } }, required: ['input'] } }, + ], + }, + }); }); test('returns an error result when no BYOK model matches', async () => { @@ -204,11 +299,11 @@ suite('AgentHostByokLmHandler', () => { const handler = createHandler(service); const result = await handler.chat( - { vendor: 'acme', modelId: 'missing', messages: [] } satisfies IByokLmChatRequest, + { vendor: 'acme', modelId: 'missing', input: [] } satisfies IByokLmChatRequest, CancellationToken.None, ); - assert.strictEqual(result.content, ''); + assert.deepStrictEqual(result.output, []); assert.ok(result.error?.includes('acme/missing'), `expected error to name the model: ${result.error}`); }); @@ -220,10 +315,10 @@ suite('AgentHostByokLmHandler', () => { const handler = createHandler(service); const result = await handler.chat( - { vendor: 'acme', modelId: 'claude', messages: [{ role: 'user', content: 'hi' }] }, + { vendor: 'acme', modelId: 'claude', input: [{ type: 'message', role: 'user', content: [{ type: 'text', text: 'hi' }] }] }, CancellationToken.None, ); - assert.deepStrictEqual(result, { content: '', error: 'provider exploded' }); + assert.deepStrictEqual(result, { output: [], error: 'provider exploded' }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 4aded1ff042..bd2ee462635 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -1981,6 +1981,43 @@ suite('AgentHostChatContribution', () => { })); + test('flushes pending chat input draft when the session is disposed', async () => { + const { sessionHandler, agentHostService, chatService } = createContribution(disposables); + const backendSession = AgentSession.uri('copilot', 'draft-sync-dispose'); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/draft-sync-dispose' }); + seedDraftSession(agentHostService, backendSession, 'Draft Sync Dispose'); + const { inputModel } = createDraftInputModel({ + attachments: [], + mode: { id: 'agent', kind: ChatModeKind.Agent }, + selectedModel: undefined, + inputText: '', + selections: [], + contrib: {}, + }); + chatService.setSession(sessionResource, upcastPartial<IChatModel>({ + sessionResource, + inputModel, + onDidChangePendingRequests: Event.None, + getPendingRequests: () => [], + })); + const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + agentHostService.dispatchedActions.length = 0; + inputModel.setState({ inputText: 'typed before switching away' }); + chatSession.dispose(); + chatSession.dispose(); + + assert.deepStrictEqual(agentHostService.dispatchedActions.map(d => ({ channel: d.channel, action: d.action })), [{ + channel: buildDefaultChatUri(backendSession.toString()), + action: { + type: ActionType.ChatDraftChanged, + draft: { + text: 'typed before switching away', + origin: { kind: MessageKind.User }, + }, + }, + }]); + }); + test('applies a remote draft to a clean live input', async () => { const modelMetadata = upcastPartial<ILanguageModelChatMetadata>({ id: 'opus-4.7', name: 'Opus 4.7' }); const languageModels = new Map<string, ILanguageModelChatMetadata>([ @@ -4031,6 +4068,71 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(collected[0][0].kind, 'toolInvocation'); })); + test('tool deltas update one streaming invocation and transition it in place', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + + fire({ type: 'chat/toolCallStart', session, turnId, toolCallId: 'tc-stream', toolName: 'view', displayName: 'View' } as ChatAction); + const invocation = collected.flat().find((part): part is IChatToolInvocation => part.kind === 'toolInvocation'); + assert.ok(invocation); + fire({ + type: 'chat/toolCallDelta', + session, + turnId, + toolCallId: 'tc-stream', + content: '{"path":"/workspace/file.ts"}', + invocationMessage: 'Viewing file', + } as ChatAction); + + const streamingState = invocation.state.get(); + assert.strictEqual(streamingState.type, IChatToolInvocation.StateKind.Streaming); + assert.strictEqual(collected.flat().filter(part => part.kind === 'toolInvocation').length, 1); + if (streamingState.type === IChatToolInvocation.StateKind.Streaming) { + assert.deepStrictEqual({ + partialInput: streamingState.partialInput.get(), + streamingMessage: streamingState.streamingMessage.get(), + }, { + partialInput: { path: '/workspace/file.ts' }, + streamingMessage: 'Viewing file', + }); + } + + fire({ + type: 'chat/toolCallReady', + session, + turnId, + toolCallId: 'tc-stream', + invocationMessage: 'Viewing file', + toolInput: '{"path":"/workspace/file.ts"}', + confirmed: 'not-needed', + } as ChatAction); + assert.strictEqual(invocation.state.get().type, IChatToolInvocation.StateKind.Executing); + assert.strictEqual(collected.flat().filter(part => part.kind === 'toolInvocation').length, 1); + + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); + await turnPromise; + })); + + test('turn completion cancels a server tool that is still streaming', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + + fire({ type: 'chat/toolCallStart', session, turnId, toolCallId: 'tc-stream-cancel', toolName: 'view', displayName: 'View' } as ChatAction); + fire({ + type: 'chat/toolCallDelta', + session, + turnId, + toolCallId: 'tc-stream-cancel', + content: '{"path":"/workspace/file.ts', + } as ChatAction); + const invocation = collected.flat().find((part): part is IChatToolInvocation => part.kind === 'toolInvocation'); + assert.ok(invocation); + + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); + await turnPromise; + assert.strictEqual(invocation.state.get().type, IChatToolInvocation.StateKind.Cancelled); + })); + test('tool_complete event transitions toolInvocation to completed', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); @@ -8935,6 +9037,54 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(toolInvocation!.toolCallId, 'tc-running'); }); + test('adopts and updates an active streaming tool call after reconnect', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const sessionUri = AgentSession.uri('copilot', 'reconnect-streaming-tool'); + const sessionState = makeSessionStateWithActiveTurn(sessionUri.toString()); + sessionState.activeTurn!.responseParts.push({ + kind: ResponsePartKind.ToolCall, + toolCall: { + toolCallId: 'tc-streaming', + toolName: 'view', + displayName: 'View', + status: ToolCallStatus.Streaming, + partialInput: '{"path":"/workspace/file.ts"}', + invocationMessage: 'Viewing file', + }, + }); + agentHostService.sessionStates.set(sessionUri.toString(), sessionState); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/reconnect-streaming-tool' }); + const session = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => session.dispose())); + const progress = session.progressObs?.get() ?? []; + const invocation = progress.find((part): part is IChatToolInvocation => part.kind === 'toolInvocation'); + assert.ok(invocation); + const streamingState = invocation.state.get(); + assert.strictEqual(streamingState.type, IChatToolInvocation.StateKind.Streaming); + if (streamingState.type === IChatToolInvocation.StateKind.Streaming) { + assert.deepStrictEqual(streamingState.partialInput.get(), { path: '/workspace/file.ts' }); + } + + agentHostService.fireAction({ + channel: sessionUri.toString(), + action: { + type: ActionType.ChatToolCallReady, + turnId: 'turn-active', + toolCallId: 'tc-streaming', + invocationMessage: 'Viewing file', + toolInput: '{"path":"/workspace/file.ts"}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }, + serverSeq: 1, + origin: undefined, + }); + await timeout(0); + + assert.strictEqual(invocation.state.get().type, IChatToolInvocation.StateKind.Executing); + assert.strictEqual((session.progressObs?.get() ?? []).filter(part => part.kind === 'toolInvocation').length, 1); + }); + test('handles active turn with pending tool confirmation', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService } = createContribution(disposables); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPromptCacheNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPromptCacheNotification.test.ts index 128321b08b4..d1bc1381b78 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPromptCacheNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostPromptCacheNotification.test.ts @@ -51,7 +51,7 @@ suite('AgentHostPromptCacheNotification', () => { clock.restore(); }); - test('does not show before ten minutes after expiration', async () => { + test('does not show before expiration', async () => { const clock = sinon.useFakeTimers({ now: new Date('2026-07-24T12:00:00.000Z') }); const notificationService = new TestNotificationService(); const contribution = store.add(new AgentHostPromptCacheNotification( @@ -66,12 +66,12 @@ suite('AgentHostPromptCacheNotification', () => { await Promise.resolve(); assert.strictEqual(notificationService.notifications.size, 0); - subscription.setValue(createState('2026-07-24T11:49:59.999Z')); + subscription.setValue(createState('2026-07-24T11:59:59.999Z')); assert.strictEqual(notificationService.notifications.size, 1); clock.restore(); }); - test('shows immediately after the ten-minute boundary', async () => { + test('shows at the expiration boundary', async () => { const clock = sinon.useFakeTimers({ now: new Date('2026-07-24T12:00:00.000Z') }); const notificationService = new TestNotificationService(); const contribution = store.add(new AgentHostPromptCacheNotification( @@ -85,7 +85,7 @@ suite('AgentHostPromptCacheNotification', () => { store.add(contribution.trackSession(sessionResource, subscription)); await Promise.resolve(); - await clock.tickAsync(11 * 60 * 1000); + await clock.tickAsync(60 * 1000 - 1); assert.strictEqual(notificationService.notifications.size, 0); await clock.tickAsync(1); assert.strictEqual(notificationService.notifications.size, 1); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionPullRequest.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionPullRequest.test.ts new file mode 100644 index 00000000000..79d1b3ecd79 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionPullRequest.test.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { getAgentSessionPullRequestContextValue, getAgentSessionPullRequestUri } from '../../../browser/agentSessions/agentSessionsModel.js'; + +suite('agentSessionPullRequest', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function probe(metadata: { [key: string]: unknown } | undefined) { + return { + uri: getAgentSessionPullRequestUri({ metadata })?.toString(), + contextValue: getAgentSessionPullRequestContextValue({ metadata }) + }; + } + + test('resolves from pullRequestUrl, falls back to number + owner/name, otherwise none', () => { + assert.deepStrictEqual([ + probe(undefined), + probe({}), + probe({ pullRequestUrl: 'https://github.com/microsoft/vscode/pull/42' }), + probe({ pullRequestNumber: 42, owner: 'microsoft', name: 'vscode' }), + // A task-backed cloud session that has not produced a pull request. + probe({ owner: 'microsoft', name: 'vscode', branch: 'copilot/fix-1' }), + // Partial data is not enough to build a pull request url. + probe({ pullRequestNumber: 42, owner: 'microsoft' }), + // Empty owner/name would produce `https://github.com///pull/42`. + probe({ pullRequestNumber: 42, owner: '', name: '' }), + probe({ pullRequestNumber: 42, owner: 'microsoft', name: '' }), + // Non-string/number metadata must not be coerced. + probe({ pullRequestUrl: 42 }), + probe({ pullRequestNumber: '42', owner: 'microsoft', name: 'vscode' }), + ], [ + { uri: undefined, contextValue: 'none' }, + { uri: undefined, contextValue: 'none' }, + { uri: 'https://github.com/microsoft/vscode/pull/42', contextValue: 'available' }, + { uri: 'https://github.com/microsoft/vscode/pull/42', contextValue: 'available' }, + { uri: undefined, contextValue: 'none' }, + { uri: undefined, contextValue: 'none' }, + { uri: undefined, contextValue: 'none' }, + { uri: undefined, contextValue: 'none' }, + { uri: undefined, contextValue: 'none' }, + { uri: undefined, contextValue: 'none' }, + ]); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 968f2ebe76b..58a7045e5fa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -9,12 +9,13 @@ import { hasKey } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import type { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { AgentHostAutoReplyAnswer } from '../../../../../../platform/agentHost/common/agentHostSchema.js'; import { McpAuthRequiredReason } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { fromAgentHostUri, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { buildSubagentChatUri, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatMarkdownContent, type IChatTerminalToolInvocationData, type IChatThinkingPart, type IChatUsage } from '../../../common/chatService/chatService.js'; import { isToolResultInputOutputDetails, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; -import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, completedToolCallToSerialized, toolCallStateToInvocation as rawToolCallStateToInvocation, toolCallStateToPreparedInvocation as rawToolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, usageInfoToAutoModeResolution, usageInfoToQuotas, formatTurnResponseDetails, rewriteAgentHostLinkTarget, rewriteMarkdownLinks, type TurnModelLookup } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; +import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, completedToolCallToSerialized, containsAutomaticReplyAnswer, createInputRequestCarousel, toolCallStateToInvocation as rawToolCallStateToInvocation, toolCallStateToPreparedInvocation as rawToolCallStateToPreparedInvocation, toolCallStateToStreamingInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, usageInfoToAutoModeResolution, usageInfoToQuotas, formatTurnResponseDetails, rewriteAgentHostLinkTarget, rewriteMarkdownLinks, type TurnModelLookup } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; // ---- Helper factories ------------------------------------------------------- @@ -124,6 +125,23 @@ suite('stateToProgressAdapter', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('detects the canonical automatic reply answer', () => { + assert.deepStrictEqual([ + containsAutomaticReplyAnswer({ + question: { + state: ChatInputAnswerState.Submitted, + value: { kind: ChatInputAnswerValueKind.Text, value: AgentHostAutoReplyAnswer }, + }, + }), + containsAutomaticReplyAnswer({ + question: { + state: ChatInputAnswerState.Submitted, + value: { kind: ChatInputAnswerValueKind.Text, value: 'User answer' }, + }, + }), + ], [true, false]); + }); + suite('rewriteAgentHostLinkTarget', () => { test('supports absolute paths and file URIs with validated locations', () => { const unwrap = (href: string) => fromAgentHostUri(URI.parse(rewriteAgentHostLinkTarget(href, 'my-host'))).toString(); @@ -281,6 +299,56 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(details.isError, false); }); + test('restores an answered ask-user interaction as a hidden tool plus conversational summary', () => { + const turn = createTurn({ + responseParts: [ + { + kind: ResponsePartKind.ToolCall, + toolCall: createCompletedToolCall({ toolName: 'ask_user' }), + }, + { + kind: ResponsePartKind.InputRequest, + request: { + id: 'input-1', + questions: [{ + id: 'q1', + kind: ChatInputQuestionKind.SingleSelect, + message: 'What should we work on?', + required: true, + options: [ + { id: 'fix', label: 'Fix a bug' }, + { id: 'feature', label: 'Implement a feature' }, + ], + }], + answers: { + q1: { + state: ChatInputAnswerState.Submitted, + value: { kind: ChatInputAnswerValueKind.Selected, value: 'fix' }, + }, + }, + }, + response: ChatInputResponseKind.Accept, + }, + ], + }); + const history = turnsToHistory(URI.file('/'), [turn], 'p'); + const parts = history[1].type === 'response' ? history[1].parts : []; + const tool = parts[0] as IChatToolInvocationSerialized; + const carousel = parts[1]; + + assert.deepStrictEqual({ + toolPresentation: tool.presentation, + carouselKind: carousel.kind, + answerPresentation: carousel.kind === 'questionCarousel' ? carousel.answerPresentation : undefined, + answer: carousel.kind === 'questionCarousel' ? carousel.data?.q1 : undefined, + }, { + toolPresentation: ToolInvocationPresentation.HiddenAfterComplete, + carouselKind: 'questionCarousel', + answerPresentation: 'conversation', + answer: { selectedValue: 'fix', freeformValue: undefined }, + }); + }); + test('generic failed tool call in history uses error text as output', () => { const turn = createTurn({ responseParts: [{ @@ -877,6 +945,43 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(invocation.source, ToolDataSource.Internal); }); + test('renders ask-user tools as waiting progress that hides after completion', () => { + const toolNames = ['ask_user', 'AskUserQuestion', 'request_user_input']; + const live = toolNames.map(toolName => { + const invocation = toolCallStateToInvocation(createToolCallState({ toolName })); + return { + message: invocation.invocationMessage, + presentation: invocation.presentation, + }; + }); + const restored = completedToolCallToSerialized(createCompletedToolCall({ toolName: 'ask_user' }), undefined, URI.file('/'), 'local'); + const failed = completedToolCallToSerialized(createCompletedToolCall({ toolName: 'ask_user', success: false }), undefined, URI.file('/'), 'local'); + + assert.deepStrictEqual({ live, restoredPresentation: restored.presentation, failedPresentation: failed.presentation }, { + live: toolNames.map(() => ({ + message: 'Waiting for answer...', + presentation: ToolInvocationPresentation.HiddenAfterComplete, + })), + restoredPresentation: ToolInvocationPresentation.HiddenAfterComplete, + failedPresentation: undefined, + }); + }); + + test('marks Agent Host input requests for conversational answer rendering', () => { + const carousel = createInputRequestCarousel({ + id: 'input-1', + questions: [{ + id: 'q1', + kind: ChatInputQuestionKind.SingleSelect, + message: 'Choose one', + required: true, + options: [{ id: 'a', label: 'Option A' }], + }], + }, 'local'); + + assert.strictEqual(carousel.answerPresentation, 'conversation'); + }); + test('attaches automation result data to live and restored configureAutomation calls', () => { const content: ToolResultContent[] = [{ type: ToolResultContentType.Text, @@ -1243,12 +1348,33 @@ suite('stateToProgressAdapter', () => { type AnyToolCallState = Parameters<typeof rawToolCallStateToPreparedInvocation>[0]; test('toolCallStateToStreamingInvocation starts in the native Streaming state', () => { - const tc: AnyToolCallState = { toolCallId: 'tc-stream', toolName: 'bash', displayName: 'Bash', status: ToolCallStatus.Streaming }; + const tc: AnyToolCallState = { + toolCallId: 'tc-stream', + toolName: 'bash', + displayName: 'Bash', + status: ToolCallStatus.Streaming, + partialInput: '{"command":"npm test","description":"Run', + invocationMessage: 'Running npm test', + }; const invocation = toolCallStateToStreamingInvocation(tc, undefined); - assert.strictEqual(invocation.toolCallId, 'tc-stream'); - assert.strictEqual(invocation.toolId, 'bash'); - assert.strictEqual(invocation.state.get().type, IChatToolInvocation.StateKind.Streaming); - assert.strictEqual(IChatToolInvocation.isComplete(invocation), false); + const state = invocation.state.get(); + assert.strictEqual(state.type, IChatToolInvocation.StateKind.Streaming); + if (state.type !== IChatToolInvocation.StateKind.Streaming) { + return; + } + assert.deepStrictEqual({ + toolCallId: invocation.toolCallId, + toolId: invocation.toolId, + partialInput: state.partialInput.get(), + streamingMessage: state.streamingMessage.get(), + isComplete: IChatToolInvocation.isComplete(invocation), + }, { + toolCallId: 'tc-stream', + toolId: 'bash', + partialInput: { command: 'npm test', description: 'Run' }, + streamingMessage: 'Running npm test', + isComplete: false, + }); }); test('toolCallStateToStreamingInvocation preserves subagent metadata before ready', () => { @@ -1274,6 +1400,32 @@ suite('stateToProgressAdapter', () => { }); }); + test('finalizeToolInvocation preserves cancellation from streaming', () => { + const invocation = toolCallStateToStreamingInvocation({ + toolCallId: 'tc-cancelled', + toolName: 'client_tool', + displayName: 'Client Tool', + status: ToolCallStatus.Streaming, + }, undefined); + finalizeToolInvocation(invocation, { + toolCallId: 'tc-cancelled', + toolName: 'client_tool', + displayName: 'Client Tool', + status: ToolCallStatus.Cancelled, + invocationMessage: 'Running client tool', + reason: ToolCallCancellationReason.Denied, + reasonMessage: 'Denied by the server', + }); + + assert.deepStrictEqual(invocation.state.get(), { + type: IChatToolInvocation.StateKind.Cancelled, + reason: ToolConfirmKind.Denied, + reasonMessage: 'Denied by the server', + parameters: undefined, + confirmationMessages: undefined, + }); + }); + test('transitionFromStreaming with a pending terminal prepared invocation yields a single terminal confirmation card', () => { // A terminal command streamed its args, then requested confirmation. const streaming = toolCallStateToStreamingInvocation({ toolCallId: 'tc-term', toolName: 'bash', displayName: 'Bash', status: ToolCallStatus.Streaming }, undefined); diff --git a/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts b/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts index 24063cd6081..145335ae2af 100644 --- a/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts @@ -7,12 +7,19 @@ import assert from 'assert'; import { Emitter } from '../../../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { ICodeEditorService } from '../../../../../../editor/browser/services/codeEditorService.js'; import { Range } from '../../../../../../editor/common/core/range.js'; +import { TrackedRangeStickiness } from '../../../../../../editor/common/model.js'; +import { TestCodeEditorService } from '../../../../../../editor/test/browser/editorTestServices.js'; +import { createTestCodeEditor } from '../../../../../../editor/test/browser/testCodeEditor.js'; +import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; +import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { TestThemeService } from '../../../../../../platform/theme/test/common/testThemeService.js'; import { IDynamicVariable, toAttachedContextDynamicVariable } from '../../../common/attachments/chatVariables.js'; import { IChatWidget } from '../../../browser/chat.js'; import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../../../browser/attachments/chatVariables.js'; -import { ChatDynamicVariableModel } from '../../../browser/attachments/chatDynamicVariables.js'; +import { ChatDynamicVariableModel, dynamicVariableDecorationType } from '../../../browser/attachments/chatDynamicVariables.js'; import { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { IToolData, ToolDataSource, ToolAndToolSetEnablementMap } from '../../../common/tools/languageModelToolsService.js'; import { observableValue } from '../../../../../../base/common/observable.js'; @@ -220,6 +227,94 @@ suite('inline attachment references', () => { suite('ChatDynamicVariableModel', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + function createDynamicVariableModel(text: string): { editor: ReturnType<typeof createTestCodeEditor>; model: ChatDynamicVariableModel } { + const textModel = store.add(createTextModel(text)); + const codeEditorService = store.add(new TestCodeEditorService(new TestThemeService())); + store.add(codeEditorService.registerDecorationType('test', dynamicVariableDecorationType, { + rangeBehavior: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + })); + const editor = store.add(createTestCodeEditor(textModel, { + serviceCollection: new ServiceCollection([ICodeEditorService, codeEditorService]), + })); + const onDidChangeActiveInputEditor = store.add(new Emitter<void>()); + const onDidChangeAttachments = store.add(new Emitter<{ deleted: readonly string[]; added: readonly IChatRequestVariableEntry[]; updated: readonly IChatRequestVariableEntry[] }>()); + const widget = { + input: { + attachmentModel: { + attachments: [], + onDidChange: onDidChangeAttachments.event, + }, + }, + inputEditor: editor, + onDidChangeActiveInputEditor: onDidChangeActiveInputEditor.event, + refreshParsedInput: () => { }, + } as unknown as IChatWidget; + const model = store.add(new ChatDynamicVariableModel(widget, { + getUriLabel: () => '', + } as unknown as ILabelService)); + return { editor, model }; + } + + test('keeps a reference when editing text before it', () => { + const { editor, model } = createDynamicVariableModel('explain #sym:example '); + model.addReference(createMockVariable({ + range: new Range(1, 9, 1, 21), + })); + + editor.executeEdits('test', [{ + range: new Range(1, 1, 1, 21), + text: 'describe #sym:example', + }]); + + assert.deepStrictEqual({ + text: editor.getValue(), + variables: model.variables.map(variable => variable.range), + }, { + text: 'describe #sym:example ', + variables: [new Range(1, 10, 1, 22)], + }); + }); + + test('removes a reference without deleting replacement text', () => { + const { editor, model } = createDynamicVariableModel('explain #sym:example '); + model.addReference(createMockVariable({ + range: new Range(1, 9, 1, 21), + })); + + editor.executeEdits('test', [{ + range: new Range(1, 1, 1, 21), + text: 'describe', + }]); + + assert.deepStrictEqual({ + text: editor.getValue(), + variables: model.variables, + }, { + text: 'describe ', + variables: [], + }); + }); + + test('removes the whole reference when editing inside it', () => { + const { editor, model } = createDynamicVariableModel('explain #sym:example '); + model.addReference(createMockVariable({ + range: new Range(1, 9, 1, 21), + })); + + editor.executeEdits('test', [{ + range: new Range(1, 14, 1, 15), + text: 'X', + }]); + + assert.deepStrictEqual({ + text: editor.getValue(), + variables: model.variables, + }, { + text: 'explain ', + variables: [], + }); + }); + test('does not retain attachment payload after the backing attachment is removed', () => { const attachment = createMockAttachment({ kind: 'image', @@ -303,6 +398,7 @@ suite('ChatDynamicVariableModel', () => { getModel: () => ({ getValueInRange: () => '#attachment', getDecorationRange: () => new Range(1, 1, 1, 20), + getOffsetAt: (position: { column: number }) => position.column - 1, }), setDecorationsByType: (_owner: string, _type: string, decorations: Array<{ hoverMessage?: { value: string } }>) => { for (const decoration of decorations) { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts index 9fec4617435..7c0b4bb5f00 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts @@ -5,12 +5,39 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { resolveDictationLanguage } from '../../browser/speechToText/dictationLanguage.js'; import { createDictationCleanupSystemPrompt, createIncrementalDictationTranscript, getIncrementalDictationCleanupRange, stripDictationFillers } from '../../browser/speechToText/chatSpeechToTextService.js'; suite('ChatSpeechToTextService', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('resolves the dictation language from Voice Mode configuration and browser locale', () => { + assert.deepStrictEqual({ + explicit: resolveDictationLanguage('fr-FR', 'de-DE'), + automatic: resolveDictationLanguage('auto', 'uk-UA'), + regionalAutomatic: resolveDictationLanguage('auto', 'pt-BR'), + additionalSupportedAutomatic: resolveDictationLanguage('auto', 'he-IL'), + unsupportedRegion: resolveDictationLanguage('auto', 'en-AU'), + explicitSpanish: resolveDictationLanguage('es', 'en-US'), + explicitAdaptationReady: resolveDictationLanguage('lt', 'en-US'), + regionalPortugueseFallback: resolveDictationLanguage('auto', 'pt-AO'), + invalidExplicit: resolveDictationLanguage('not a locale', 'de-DE'), + missing: resolveDictationLanguage(undefined, undefined), + }, { + explicit: 'fr-FR', + automatic: 'uk-UA', + regionalAutomatic: 'pt-BR', + additionalSupportedAutomatic: 'he-IL', + unsupportedRegion: 'en-US', + explicitSpanish: 'es-US', + explicitAdaptationReady: 'lt-LT', + regionalPortugueseFallback: 'pt-PT', + invalidExplicit: 'auto', + missing: 'auto', + }); + }); + test('shows each cleaned prefix with the remaining raw transcript', () => { assert.deepStrictEqual( [ diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 756b69f1967..e7983cd0bfa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ICommandEvent, ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ICommandEvent, ICommandService, CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyExpression, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -22,6 +22,7 @@ import { NullWorkbenchAssignmentService } from '../../../../services/assignment/ import { ChatTipService, CREATE_AGENT_INSTRUCTIONS_TRACKING_COMMAND, CREATE_AGENT_TRACKING_COMMAND, CREATE_PROMPT_TRACKING_COMMAND, CREATE_SKILL_TRACKING_COMMAND, FORK_CONVERSATION_TRACKING_COMMAND, IChatTip, ITipDefinition, TipEligibilityTracker } from '../../browser/chatTipService.js'; import { AgentInstructionFileType, IPromptPath, IPromptsService, IAgentInstructionFile, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { URI } from '../../../../../base/common/uri.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { storeSelectedModel } from '../../common/chatSelectedModel.js'; @@ -29,7 +30,7 @@ import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; -import { ChatTipTier, TIP_CATALOG } from '../../browser/chatTipCatalog.js'; +import { ChatTipTier, TIP_CATALOG, extractCommandIds } from '../../browser/chatTipCatalog.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { TestChatEntitlementService } from '../../../../test/common/workbenchTestServices.js'; import { IChatService } from '../../common/chatService/chatService.js'; @@ -73,6 +74,31 @@ suite('ChatTipService', () => { let mockInstructionFiles: IAgentInstructionFile[]; let mockPromptInstructionFiles: IPromptPath[]; let chatEntitlementService: TestChatEntitlementService; + let catalogCommandRegistrations: Map<string, IDisposable>; + + /** + * Registers every `command:` link referenced by the real {@link TIP_CATALOG} so that tips are + * considered eligible, simulating a running workbench where these commands exist. Returns a map + * keyed by command id so individual registrations can be disposed to simulate a missing command. + */ + function registerCatalogCommands(): Map<string, IDisposable> { + const registrations = new Map<string, IDisposable>(); + for (const tip of TIP_CATALOG) { + const message = tip.buildMessage({ + keybindingService: { lookupKeybinding: () => undefined } as Partial<IKeybindingService> as IKeybindingService, + experimentalTipMessages: new Map(), + }).value; + for (const commandId of extractCommandIds(message)) { + if (registrations.has(commandId) || CommandsRegistry.getCommand(commandId)) { + continue; + } + const registration = CommandsRegistry.registerCommand(commandId, () => { }); + registrations.set(commandId, registration); + testDisposables.add(registration); + } + } + return registrations; + } function createProductService(hasCopilot: boolean): IProductService { return { @@ -132,6 +158,7 @@ suite('ChatTipService', () => { lookupKeybinding: () => undefined, } as Partial<IKeybindingService> as IKeybindingService); instantiationService.stub(IWorkbenchAssignmentService, new NullWorkbenchAssignmentService()); + catalogCommandRegistrations = registerCatalogCommands(); }); test('returns a welcome tip', () => { @@ -605,6 +632,20 @@ suite('ChatTipService', () => { assert.strictEqual(previousTip.id, 'tip.planMode', 'Expected previous tip to reverse the preferred ordering'); }); + test('excludes a tip whose command is not registered', () => { + // Simulate a shipped build where the tip references a command that was never registered + // (see https://github.com/microsoft/vscode/issues/328231). + catalogCommandRegistrations.get('workbench.action.chat.openPlan')!.dispose(); + + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Agent'); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); + contextKeyService.createKey(ChatContextKeys.chatModelId.key, 'auto'); + + assertTipNeverShown(service, 'tip.planMode'); + }); + test('getNextEligibleTip returns next tip even when only one remains', async () => { const service = createService(); diff --git a/src/vs/workbench/contrib/chat/test/browser/micButtonMenuActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/micButtonMenuActions.test.ts new file mode 100644 index 00000000000..a3f1684ad19 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/micButtonMenuActions.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { getDictationContextMenuActions, getVoiceModeContextMenuActions } from '../../browser/speechToText/micButtonMenuActions.js'; + +suite('Mic button menu actions', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const commandService = upcastPartial<ICommandService>({}); + const configurationService = upcastPartial<IConfigurationService>({}); + const keybindingService = upcastPartial<IKeybindingService>({}); + + test('groups and shortens Voice Mode actions', () => { + const actions = getVoiceModeContextMenuActions(commandService, configurationService, keybindingService, 'voice.start'); + + assert.deepStrictEqual(actions.map(action => action.label), [ + 'Configure Keybinding', + 'Disable', + '', + 'Open Settings', + 'Configure Instructions', + 'Show Introduction', + 'Select Microphone', + ]); + }); + + test('groups and shortens dictation actions', () => { + const actions = getDictationContextMenuActions(commandService, configurationService, keybindingService, 'dictation.start'); + + assert.deepStrictEqual(actions.map(action => action.label), [ + 'Configure Keybinding', + 'Disable', + '', + 'Open Settings', + 'Configure Instructions', + 'Show Introduction', + 'Select Microphone', + ]); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/micCaptureService.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/micCaptureService.test.ts new file mode 100644 index 00000000000..c594089ade1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/micCaptureService.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mainWindow } from '../../../../../../base/browser/window.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import { TestNotificationService } from '../../../../../../platform/notification/test/common/testNotificationService.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; +import { MIC_CAPTURE_CHUNK_SIZE, MicCaptureService } from '../../../browser/voiceClient/micCaptureService.js'; + +suite('MicCaptureService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('buffers 32 ms voice chunks at 16 kHz', () => { + assert.deepStrictEqual({ + samples: MIC_CAPTURE_CHUNK_SIZE, + durationMs: MIC_CAPTURE_CHUNK_SIZE / 16, + }, { + samples: 512, + durationMs: 32, + }); + }); + + test('propagates capture setup failures after cleaning up acquired resources', async () => { + const setupError = new Error('audio source setup failed'); + let trackStopCalls = 0; + const track = new class extends mock<MediaStreamTrack>() { + override stop(): void { trackStopCalls++; } + }(); + const stream = new class extends mock<MediaStream>() { + override getTracks(): MediaStreamTrack[] { return [track]; } + override getAudioTracks(): MediaStreamTrack[] { return []; } + }(); + const targetWindow = Object.create(mainWindow) as Window & typeof globalThis; + Object.defineProperties(targetWindow, { + navigator: { + value: { + mediaDevices: { + getUserMedia: async () => stream, + }, + }, + }, + AudioContext: { + value: class { + close(): Promise<void> { return Promise.resolve(); } + createMediaStreamSource(): never { throw setupError; } + }, + }, + }); + const service = store.add(new MicCaptureService( + store.add(new TestStorageService()), + new TestNotificationService(), + new NullLogService(), + )); + service.prepare(targetWindow); + + await assert.rejects(() => service.pttDown('turn-1'), error => error === setupError); + assert.deepStrictEqual({ + isCapturing: service.isCapturing, + trackStopCalls, + }, { + isCapturing: false, + trackStopCalls: 1, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceClientService.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceClientService.test.ts index b30425357bd..f3765510417 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceClientService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceClientService.test.ts @@ -12,7 +12,7 @@ import { NullLogService } from '../../../../../../platform/log/common/log.js'; import product from '../../../../../../platform/product/common/product.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { VoiceClientService } from '../../../browser/voiceClient/voiceClientService.js'; -import { IVoiceAudioResponse, IVoiceBargeIn, IVoiceTranscription } from '../../../common/voiceClient/voiceClientService.js'; +import { IVoiceAudioResponse, IVoiceBargeIn, IVoiceNarrationAck, IVoiceNarrationSignal, IVoiceSpeechStarted, IVoiceTranscription } from '../../../common/voiceClient/voiceClientService.js'; class TestWebSocket { static instance: TestWebSocket | undefined; @@ -125,6 +125,46 @@ suite('VoiceClientService', () => { }]); }); + test('preserves the turn ID on speech-started events', async () => { + const { service } = createService(); + const events: IVoiceSpeechStarted[] = []; + store.add(service.onSpeechStarted(event => events.push(event))); + + await service.connect(createTestWindow()); + socket().onmessage?.(new mainWindow.MessageEvent('message', { + data: JSON.stringify({ + type: 'speech_started', + turn_id: 'passive-turn', + }), + })); + + assert.deepStrictEqual(events, [{ turnId: 'passive-turn' }]); + }); + + test('preserves checkpoint interruption metadata from the backend', async () => { + const { service } = createService(); + const events: IVoiceNarrationSignal[] = []; + store.add(service.onNarrationInterrupted(event => events.push(event))); + + await service.connect(createTestWindow()); + socket().onmessage?.(new mainWindow.MessageEvent('message', { + data: JSON.stringify({ + type: 'narration_interrupted', + narration_id: 'checkpoint-narration', + coding_session_id: 'chat-session:/one', + retryable: false, + reason: 'superseded_by_response', + }), + })); + + assert.deepStrictEqual(events, [{ + narrationId: 'checkpoint-narration', + codingSessionId: 'chat-session:/one', + retryable: false, + reason: 'superseded_by_response', + }]); + }); + test('preserves the backend turn ID when audio has a narration ID', async () => { const { service } = createService(); const events: IVoiceAudioResponse[] = []; @@ -143,6 +183,11 @@ suite('VoiceClientService', () => { is_final: false, turn_id: 'backend-turn', narration_id: 'client-narration', + request_id: 'request-1', + checkpoint_id: 'planning', + sequence: 1, + narration_kind: 'checkpoint', + playback_id: 'playback-1', }), })); @@ -154,6 +199,11 @@ suite('VoiceClientService', () => { transcript: undefined, turnId: 'backend-turn', responseId: 'client-narration', + requestId: 'request-1', + checkpointId: 'planning', + sequence: 1, + narrationKind: 'checkpoint', + playbackId: 'playback-1', }]); }); @@ -249,6 +299,191 @@ suite('VoiceClientService', () => { ]); }); + test('sends first-class checkpoint narration metadata', async () => { + const { service } = createService(); + await service.connect(createTestWindow()); + service.sendStartSession({ sessions: [], display_locale: '' }, 'machine'); + + const narrationId = service.requestNarration('chat-session:/one', 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 2, + }); + service.sendNarrationPlaybackComplete('chat-session:/one', narrationId!, 'playback-1'); + + assert.deepStrictEqual(socket().sent.slice(1), [ + { + type: 'request_narration', + coding_session_id: 'chat-session:/one', + kind: 'checkpoint', + text: 'Updating the code.', + narration_id: narrationId, + request_id: 'request-1', + checkpoint_id: 'editing', + sequence: 2, + }, + { + type: 'narration_playback_complete', + coding_session_id: 'chat-session:/one', + narration_id: narrationId, + playback_id: 'playback-1', + }, + ]); + }); + + test('sends typed confirmation narration metadata', async () => { + const { service } = createService(); + await service.connect(createTestWindow()); + service.sendStartSession({ sessions: [], display_locale: '' }, 'machine'); + + const narrationId = service.requestNarration( + 'chat-session:/one', + 'confirmation', + 'questionnaire: 1 question', + undefined, + undefined, + 'questionnaire', + ); + + assert.deepStrictEqual(socket().sent[1], { + type: 'request_narration', + coding_session_id: 'chat-session:/one', + kind: 'confirmation', + text: 'questionnaire: 1 question', + narration_id: narrationId, + confirmation_type: 'questionnaire', + }); + }); + + test('persists and clears typed confirmation session state', async () => { + const { service } = createService(); + await service.connect(createTestWindow()); + socket().onopen?.(); + service.sendStartSession({ sessions: [], display_locale: '' }, 'machine'); + + service.sendSessionContext({ + sessions: [{ + id: 'chat-session:/one', + is_active: true, + agent_state: 'waiting_for_confirmation', + agent_state_detail: 'questionnaire: 1 question', + confirmation_type: 'questionnaire', + }], + display_locale: 'en-US', + }); + service.flushSessionContext(); + service.sendSessionContext({ + sessions: [{ + id: 'chat-session:/one', + is_active: true, + agent_state: 'idle', + }], + display_locale: 'en-US', + }); + service.flushSessionContext(); + + assert.deepStrictEqual(socket().sent.slice(1), [ + { + type: 'session_context', + mode: 'delta', + upserts: [{ + id: 'chat-session:/one', + is_active: true, + agent_state: 'waiting_for_confirmation', + agent_state_detail: 'questionnaire: 1 question', + confirmation_type: 'questionnaire', + }], + removes: [], + }, + { + type: 'session_context', + mode: 'delta', + upserts: [{ + id: 'chat-session:/one', + agent_state: 'idle', + agent_state_detail: null, + confirmation_type: null, + }], + removes: [], + }, + ]); + }); + + test('invalidated context preserves pending deletion tombstones', async () => { + const { service } = createService(); + await service.connect(createTestWindow()); + socket().onopen?.(); + service.sendStartSession({ sessions: [], display_locale: '' }, 'machine'); + const sessionId = 'chat-session:/one'; + + service.sendSessionContext({ + sessions: [{ + id: sessionId, + is_active: true, + agent_state: 'waiting_for_confirmation', + agent_state_detail: 'Which region?', + confirmation_type: 'questionnaire', + pending: { + type: 'questions', + pending_id: 'request-1#p1', + request_id: 'request-1', + questions: [], + }, + }], + display_locale: 'en-US', + }); + service.flushSessionContext(); + service.invalidateSessionCache(sessionId); + service.sendSessionContext({ + sessions: [{ + id: sessionId, + is_active: true, + agent_state: 'waiting_for_confirmation', + agent_state_detail: 'Which region?', + confirmation_type: 'questionnaire', + }], + display_locale: 'en-US', + }); + service.flushSessionContext(); + + assert.deepStrictEqual(socket().sent.at(-1), { + type: 'session_context', + mode: 'delta', + upserts: [{ + id: sessionId, + is_active: true, + agent_state: 'waiting_for_confirmation', + agent_state_detail: 'Which region?', + confirmation_type: 'questionnaire', + pending: null, + }], + removes: [], + }); + }); + + test('normalizes legacy suppressed narration acknowledgements', async () => { + const { service } = createService(); + const events: IVoiceNarrationAck[] = []; + store.add(service.onNarrationAck(event => events.push(event))); + await service.connect(createTestWindow()); + + socket().onmessage?.(new mainWindow.MessageEvent('message', { + data: JSON.stringify({ + type: 'narration_ack', + narration_id: 'narration-1', + coding_session_id: 'chat-session:/one', + disposition: 'suppressed', + reason: 'stale', + }), + })); + assert.deepStrictEqual(events, [{ + narrationId: 'narration-1', + codingSessionId: 'chat-session:/one', + disposition: 'suppressed', + reason: 'stale', + }]); + }); + test('flags a passive ptt_start for hands-free barge-in listens', async () => { const { service } = createService(); @@ -269,7 +504,7 @@ suite('VoiceClientService', () => { await service.connect(createTestWindow()); service.sendStartSession({ sessions: [], display_locale: '' }, 'machine'); - const questionId = service.requestNarration('cs1', 'question', 'Which region?', undefined, { pendingId: 'p1' }); + const questionId = service.requestNarration('cs1', 'question', 'Which region?', undefined, undefined, undefined, { pendingId: 'p1' }); const replyId = service.requestNarration('cs1', 'response', 'Done.'); assert.deepStrictEqual(socket().sent.filter(message => message.type === 'request_narration'), [ @@ -282,7 +517,7 @@ suite('VoiceClientService', () => { const { service } = createService(); await service.connect(createTestWindow()); - const narrationId = service.requestNarration('cs1', 'question', 'Which region?', undefined, { pendingId: 'p1' }); + const narrationId = service.requestNarration('cs1', 'question', 'Which region?', undefined, undefined, undefined, { pendingId: 'p1' }); assert.strictEqual(narrationId, undefined); assert.deepStrictEqual(socket().sent.filter(message => message.type === 'request_narration'), []); @@ -301,10 +536,12 @@ suite('VoiceClientService', () => { type: message.type, session_context: message.session_context, voice: message.voice, + auto_narrate: message.auto_narrate, })), [{ type: 'start_session', session_context: { sessions: [], display_locale: 'fr-FR' }, voice: 'kevin_neutral', + auto_narrate: false, }]); }); @@ -455,6 +692,7 @@ suite('VoiceClientService', () => { session_context: message.session_context, voice: message.voice, voice_instructions: message.voice_instructions, + auto_narrate: message.auto_narrate, })), }, { disconnectedMessages: [], @@ -464,6 +702,7 @@ suite('VoiceClientService', () => { session_context: { sessions: [], display_locale: 'de-DE' }, voice: 'daniel_neutral', voice_instructions: 'Keep replies concise.', + auto_narrate: false, }], }); }); @@ -522,4 +761,16 @@ suite('VoiceClientService', () => { assert.strictEqual(service.isResuming, false); assert.strictEqual(service.currentSessionId, undefined); }); + + test('reports when an abnormal close has scheduled a reconnect', async () => { + const { service } = createService(); + await service.connect(createTestWindow()); + socket().onopen?.(); + + socket().onclose?.(new mainWindow.CloseEvent('close', { code: 4000 })); + + assert.strictEqual(service.willReconnect, true); + service.disconnect(); + assert.strictEqual(service.willReconnect, false); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts index b62a29d4d5b..48345dd5c65 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts @@ -8,6 +8,7 @@ import sinon from 'sinon'; import { mainWindow } from '../../../../../../base/browser/window.js'; import { DeferredPromise } from '../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; @@ -19,29 +20,36 @@ import { ICommandService } from '../../../../../../platform/commands/common/comm import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; -import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { INotification, INotificationHandle, INotificationService, NoOpNotification } from '../../../../../../platform/notification/common/notification.js'; +import { TestNotificationService } from '../../../../../../platform/notification/test/common/testNotificationService.js'; import { NullTelemetryService, NullTelemetryServiceShape } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { IVoiceTranscriptStore, IVoiceTranscriptTurn } from '../../../../agentsVoice/common/voiceTranscriptStore.js'; -import { IAgentSessionsModel } from '../../../browser/agentSessions/agentSessionsModel.js'; +import { AgentSessionStatus, IAgentSessionsModel } from '../../../browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../browser/agentSessions/agentSessionsService.js'; import { IChatWidgetService } from '../../../browser/chat.js'; import { IMicCaptureService } from '../../../browser/voiceClient/micCaptureService.js'; import { ITtsPlaybackService } from '../../../browser/voiceClient/ttsPlaybackService.js'; import { IVoiceSessionController, VoiceSessionController } from '../../../browser/voiceClient/voiceSessionController.js'; import { IVoiceToolDispatchService } from '../../../browser/voiceClient/voiceToolDispatchService.js'; -import { IChatService } from '../../../common/chatService/chatService.js'; +import { ChatSendResult, ElicitationState, IChatConfirmation, IChatSendRequestOptions, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; -import { derivePendingId, IVoiceAudioResponse, IVoiceBargeIn, IVoiceClientService, IVoiceNarrationSignal, IVoiceSpeechStarted, IVoiceToolCall, IVoiceTranscription, VoiceNarrationKind, IVoiceDispatchResult } from '../../../common/voiceClient/voiceClientService.js'; -import { IChatModel } from '../../../common/model/chatModel.js'; +import { derivePendingId, IVoiceAudioResponse, IVoiceBargeIn, IVoiceCheckpointNarrationMetadata, IVoiceClientService, IVoiceDispatchResult, IVoiceNarrationAck, IVoiceNarrationSignal, IVoiceSessionContext, IVoiceSpeechStarted, IVoiceToolCall, IVoiceTranscription, peekPendingId, VoiceConfirmationType, VoiceNarrationKind, VOICE_AGENT_PROGRESS_SETTING } from '../../../common/voiceClient/voiceClientService.js'; +import { IChatModel, IChatProgressResponseContent, IChatResponseModel } from '../../../common/model/chatModel.js'; +import { ChatElicitationRequestPart } from '../../../common/model/chatProgressTypes/chatElicitationRequestPart.js'; +import { ChatPlanReviewData } from '../../../common/model/chatProgressTypes/chatPlanReviewData.js'; +import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { IVoicePlaybackService } from '../../../common/voicePlaybackService.js'; +import { AskQuestionsToolId } from '../../../common/tools/builtinTools/askQuestionsTool.js'; import { MockChatService } from '../../common/chatService/mockChatService.js'; class TestVoiceClientService extends mock<IVoiceClientService>() { private narrationCounter = 0; - readonly requests: { sessionId: string; kind: VoiceNarrationKind; text: string; narrationId: string; pendingId?: string }[] = []; + readonly requests: { sessionId: string; kind: VoiceNarrationKind; text: string; narrationId: string; pendingId?: string; checkpoint?: IVoiceCheckpointNarrationMetadata; confirmationType?: VoiceConfirmationType }[] = []; + readonly sessionCommands: ('start' | 'resume')[] = []; + readonly sessionCommandSent = new DeferredPromise<void>(); private readonly audioResponseEmitter = new Emitter<IVoiceAudioResponse>(); override readonly onAudioResponse = this.audioResponseEmitter.event; private readonly bargeInEmitter = new Emitter<IVoiceBargeIn>(); @@ -52,35 +60,64 @@ class TestVoiceClientService extends mock<IVoiceClientService>() { override readonly onToolCall = this.toolCallEmitter.event; private readonly speechStartedEmitter = new Emitter<IVoiceSpeechStarted>(); override readonly onSpeechStarted = this.speechStartedEmitter.event; - override readonly onNarrationAck = Event.None; + private readonly narrationAckEmitter = new Emitter<IVoiceNarrationAck>(); + override readonly onNarrationAck = this.narrationAckEmitter.event; private readonly narrationUnblockedEmitter = new Emitter<IVoiceNarrationSignal>(); override readonly onNarrationUnblocked = this.narrationUnblockedEmitter.event; private readonly narrationInterruptedEmitter = new Emitter<IVoiceNarrationSignal>(); override readonly onNarrationInterrupted = this.narrationInterruptedEmitter.event; - override readonly onSessionInit = Event.None; + private readonly sessionInitEmitter = new Emitter<{ sessionId: string }>(); + override readonly onSessionInit = this.sessionInitEmitter.event; override readonly onError = Event.None; private readonly connectionStateEmitter = new Emitter<boolean>(); override readonly onDidChangeConnectionState = this.connectionStateEmitter.event; override readonly onFatalDisconnect = Event.None; override readonly onTurnAutoEnded = Event.None; private connected = false; + private resuming = false; + private reconnecting = false; override get isConnected(): boolean { return this.connected; } + override get isResuming(): boolean { return this.resuming; } + override get willReconnect(): boolean { return this.reconnecting; } override disconnect(): void { this.connected = false; } override async connect(): Promise<void> { } - override sendSessionContext(): void { } - override flushSessionContext(): void { } - readonly toolResults: { callId: string; result: string }[] = []; + readonly wireEvents: ({ type: 'session_context'; context: IVoiceSessionContext } | { type: 'request_narration'; kind: VoiceNarrationKind; text: string; confirmationType?: VoiceConfirmationType })[] = []; + private pendingContext: IVoiceSessionContext | undefined; + override sendSessionContext(context: IVoiceSessionContext): void { + this.pendingContext = context; + } + override flushSessionContext(): void { + if (this.pendingContext) { + this.wireEvents.push({ type: 'session_context', context: this.pendingContext }); + this.pendingContext = undefined; + } + } + override invalidateSessionCache(): void { } + override sendStartSession(): void { + this.sessionCommands.push('start'); + this.sessionCommandSent.complete(); + } + override sendResumeSession(): void { + this.sessionCommands.push('resume'); + this.sessionCommandSent.complete(); + } + readonly playbackCompletions: { sessionId: string; narrationId: string; playbackId: string }[] = []; + override sendNarrationPlaybackComplete(codingSessionId: string, narrationId: string, playbackId: string): void { + this.playbackCompletions.push({ sessionId: codingSessionId, narrationId, playbackId }); + } + readonly toolResults: { callId: string; result: string | IVoiceDispatchResult }[] = []; private toolResultResolver: (() => void) | undefined; readonly toolResultReceived = new Promise<void>(resolve => this.toolResultResolver = resolve); - override sendToolResult(callId: string, result: string): void { + override sendToolResult(callId: string, result: string | IVoiceDispatchResult): void { this.toolResults.push({ callId, result }); this.toolResultResolver?.(); } - override requestNarration(codingSessionId: string, kind: VoiceNarrationKind, text: string, narrationId?: string, pending?: { pendingId: string }): string | undefined { + override requestNarration(codingSessionId: string, kind: VoiceNarrationKind, text: string, narrationId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata, confirmationType?: VoiceConfirmationType, pending?: { pendingId: string }): string | undefined { const id = narrationId ?? `narration-${++this.narrationCounter}`; - this.requests.push({ sessionId: codingSessionId, kind, text, narrationId: id, ...(pending ? { pendingId: pending.pendingId } : {}) }); + this.requests.push({ sessionId: codingSessionId, kind, text, narrationId: id, ...(pending ? { pendingId: pending.pendingId } : {}), ...(checkpoint ? { checkpoint } : {}), ...(confirmationType ? { confirmationType } : {}) }); + this.wireEvents.push({ type: 'request_narration', kind, text, ...(confirmationType ? { confirmationType } : {}) }); return id; } @@ -100,32 +137,47 @@ class TestVoiceClientService extends mock<IVoiceClientService>() { this.toolCallEmitter.fire(event); } - fireSpeechStarted(): void { - this.speechStartedEmitter.fire({}); + fireSpeechStarted(turnId?: string): void { + this.speechStartedEmitter.fire({ turnId }); } fireNarrationInterrupted(event: IVoiceNarrationSignal): void { this.narrationInterruptedEmitter.fire(event); } + fireNarrationAck(event: IVoiceNarrationAck): void { + this.narrationAckEmitter.fire(event); + } + fireNarrationUnblocked(event: IVoiceNarrationSignal): void { this.narrationUnblockedEmitter.fire(event); } - fireConnectionState(connected: boolean): void { + fireConnectionState(connected: boolean, willReconnect = false): void { this.connected = connected; + this.reconnecting = !connected && willReconnect; this.connectionStateEmitter.fire(connected); } + setResuming(resuming: boolean): void { + this.resuming = resuming; + } + + fireSessionInit(): void { + this.sessionInitEmitter.fire({ sessionId: 'voice-session' }); + } + dispose(): void { this.audioResponseEmitter.dispose(); this.bargeInEmitter.dispose(); this.transcriptionEmitter.dispose(); this.toolCallEmitter.dispose(); this.speechStartedEmitter.dispose(); + this.narrationAckEmitter.dispose(); this.narrationUnblockedEmitter.dispose(); this.narrationInterruptedEmitter.dispose(); this.connectionStateEmitter.dispose(); + this.sessionInitEmitter.dispose(); } } @@ -133,6 +185,12 @@ class RecordingMicCaptureService extends mock<IMicCaptureService>() { readonly pttDownCalls: { turnId: string; passive: boolean | undefined }[] = []; abortCalls = 0; prepareCalls = 0; + startCaptureCalls = 0; + stopCaptureCalls = 0; + readonly captureStarted = new DeferredPromise<void>(); + constructor(private readonly captureBarrier?: Promise<void>) { + super(); + } override readonly onPttStart = Event.None; override readonly onPttAudioChunk = Event.None; override readonly onPttEnd = Event.None; @@ -140,8 +198,14 @@ class RecordingMicCaptureService extends mock<IMicCaptureService>() { override readonly analyserNode = undefined; override isMuted = false; override prepare(): void { this.prepareCalls++; } - override async startCapture(): Promise<void> { } - override stopCapture(): void { } + override async startCapture(): Promise<void> { + this.startCaptureCalls++; + if (this.startCaptureCalls === 1) { + this.captureStarted.complete(); + } + await this.captureBarrier; + } + override stopCapture(): void { this.stopCaptureCalls++; } override abortPtt(): void { this.abortCalls++; } override pttUp(): void { } override suppressUntil(): void { } @@ -150,6 +214,15 @@ class RecordingMicCaptureService extends mock<IMicCaptureService>() { } } +class VoiceTestNotificationService extends TestNotificationService { + readonly notifications: INotification[] = []; + + override notify(notification: INotification): INotificationHandle { + this.notifications.push(notification); + return new NoOpNotification(); + } +} + class TestTtsPlaybackService extends mock<ITtsPlaybackService>() { readonly playedAudio: string[] = []; stopCount = 0; @@ -186,6 +259,19 @@ class TestTtsPlaybackService extends mock<ITtsPlaybackService>() { } } +class DeferredFirstTtsPlaybackService extends TestTtsPlaybackService { + private deferNextStart = true; + + override playAudioChunk(audio: string): void { + if (audio && this.deferNextStart) { + this.deferNextStart = false; + this.playedAudio.push(audio); + return; + } + super.playAudioChunk(audio); + } +} + class TestMicCaptureService extends mock<IMicCaptureService>() { override readonly onPttStart = Event.None; override readonly onPttAudioChunk = Event.None; @@ -208,22 +294,46 @@ class TestMicCaptureService extends mock<IMicCaptureService>() { class TestAgentSessionsService extends mock<IAgentSessionsService>() { override readonly onDidChangeSessionArchivedState = Event.None; - override readonly model: IAgentSessionsModel = { - onWillResolve: Event.None, - onDidResolve: Event.None, - sessions: [], - onDidChangeSessions: Event.None, - onDidChangeSessionArchivedState: Event.None, - resolved: true, - getSession: () => undefined, - observeSession: () => observableValue('session', undefined), - resolve: async () => { }, + override readonly model: IAgentSessionsModel; + + constructor(sessions: readonly unknown[] = []) { + super(); + this.model = { + onWillResolve: Event.None, + onDidResolve: Event.None, + sessions: sessions as IAgentSessionsModel['sessions'], + onDidChangeSessions: Event.None, + onDidChangeSessionArchivedState: Event.None, + resolved: true, + getSession: () => undefined, + observeSession: () => observableValue('session', undefined), + resolve: async () => { }, + }; + } +} + +/** An agent session entry as `_buildSessionContext` reads it. */ +function agentSessionEntry(id: string, label: string | undefined, status: AgentSessionStatus) { + return { + resource: URI.parse(id), + label, + status, + isArchived: () => false, + timing: { created: Date.now(), lastRequestEnded: Date.now() }, }; } class TestChatService extends mock<IChatService>() { override readonly chatModels = observableValue('chatModels', []); + readonly sendRequestOptions: (IChatSendRequestOptions | undefined)[] = []; override getSession(): undefined { return undefined; } + override async sendRequest(_sessionResource: URI, _message: string, options?: IChatSendRequestOptions): Promise<ChatSendResult> { + this.sendRequestOptions.push(options); + return { kind: 'rejected', reason: 'test' }; + } + + /** A session that never loads: the controller eagerly loads models for waiting sessions. */ + override async acquireOrLoadSession(): Promise<undefined> { return undefined; } } /** @@ -244,8 +354,18 @@ class ControllableChatService extends mock<IChatService>() { } /** Minimal chat model whose last request carries one unanswered question form. */ -function questionCarouselModel(part: object, requestId = 'req-1'): IChatModel { - const lastRequest = { id: requestId, response: { response: { value: [part] } } }; +function pendingPartsModel(parts: object | object[], requestId = 'req-1', pendingDetail?: string): IChatModel { + const value = Array.isArray(parts) ? parts : [parts]; + const lastRequest = { + id: requestId, + response: { + response: { value }, + isPendingConfirmation: observableValue<{ detail?: string } | undefined>( + 'pending', + pendingDetail === undefined ? undefined : { detail: pendingDetail }, + ), + }, + }; return { getRequests: () => [lastRequest], } as unknown as IChatModel; @@ -269,6 +389,21 @@ function pendingConfirmationModel(resource: URI): IChatModel { } as unknown as IChatModel; } +function pendingResponsePartModel(resource: URI, part: IChatProgressResponseContent, detail = 'Needs approval', reportPending = true): IChatModel { + const response = { + isPendingConfirmation: observableValue<{ detail?: string } | undefined>('pending', reportPending ? { detail } : undefined), + isIncomplete: observableValue('incomplete', false), + response: { value: [part], getMarkdown: () => '' }, + }; + const lastRequest = { response }; + return { + sessionResource: resource, + title: 'Chat', + getRequests: () => [lastRequest], + lastRequestObs: observableValue('lastRequest', lastRequest), + } as unknown as IChatModel; +} + function completedResponseModel(markdown: string, errorMessage?: string, isCanceled = false): IChatModel { const response = { isPendingConfirmation: observableValue('pending', undefined), @@ -334,11 +469,13 @@ suite('VoiceSessionController', () => { commandService: ICommandService = new TestCommandService(), telemetryService: NullTelemetryServiceShape = NullTelemetryService, micCaptureService: IMicCaptureService = new TestMicCaptureService(), - configurationService: IConfigurationService = new TestConfigurationService({ 'agents.voice.handsFree': false }), + configurationService: IConfigurationService = new TestConfigurationService({ 'agents.voice.handsFree': false, [VOICE_AGENT_PROGRESS_SETTING]: true }), chatService: IChatService = new TestChatService(), promptsService: IPromptsService = new class extends mock<IPromptsService>() { override async getVoiceInstructions(): Promise<undefined> { return undefined; } }(), + agentSessionsService: IAgentSessionsService = new TestAgentSessionsService(), + notificationService: INotificationService = new VoiceTestNotificationService(), ): IVoiceSessionController { store.add({ dispose: () => voiceClientService.dispose() }); store.add(ttsPlaybackService); @@ -354,7 +491,7 @@ suite('VoiceSessionController', () => { override notifyPlaybackStart(): void { } override notifyPlaybackEnd(): void { } }(), - new TestAgentSessionsService(), + agentSessionsService, chatService, commandService, new class extends mock<IAuthenticationService>() { @@ -372,11 +509,25 @@ suite('VoiceSessionController', () => { }(), new TestAccessibilityService(), new TestChatWidgetService(), - new class extends mock<INotificationService>() { }(), + notificationService, promptsService, )); } + function createVoiceProgressResponse(id: string, requestId = `request-${id}`) { + const changeEmitter = store.add(new Emitter<{ reason: 'other' }>()); + const parts: { kind: 'voiceProgress'; id: string; value: string }[] = []; + const state = { + id, + requestId, + isComplete: false, + isCanceled: false, + onDidChange: changeEmitter.event, + response: { value: parts }, + }; + return { changeEmitter, parts, response: state as unknown as IChatResponseModel, state }; + } + test('includes response errors in the summary sent to the voice backend', () => { const controller = createController(new TestVoiceClientService()); const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { state: string; last_response_summary?: string }; @@ -440,6 +591,2761 @@ suite('VoiceSessionController', () => { }); }); + test('warms hands-free capture before starting or resuming the backend session', async () => { + const results: { + command: 'start' | 'resume'; + beforeWarmup: { + prepareCalls: number; + startCaptureCalls: number; + stopCaptureCalls: number; + sessionCommands: readonly ('start' | 'resume')[]; + socketConnected: boolean; + }; + afterWarmup: readonly ('start' | 'resume')[]; + }[] = []; + for (const command of ['start', 'resume'] as const) { + const voiceClientService = new TestVoiceClientService(); + voiceClientService.setResuming(command === 'resume'); + const captureBarrier = new DeferredPromise<void>(); + const micCaptureService = new RecordingMicCaptureService(captureBarrier.p); + const controller = createController( + voiceClientService, + undefined, + undefined, + undefined, + micCaptureService, + new TestConfigurationService({ 'agents.voice.handsFree': true }), + ); + await controller.connect(mainWindow); + + voiceClientService.fireConnectionState(true); + await micCaptureService.captureStarted.p; + const beforeWarmup = { + prepareCalls: micCaptureService.prepareCalls, + startCaptureCalls: micCaptureService.startCaptureCalls, + stopCaptureCalls: micCaptureService.stopCaptureCalls, + sessionCommands: [...voiceClientService.sessionCommands], + socketConnected: voiceClientService.isConnected, + }; + + captureBarrier.complete(); + await voiceClientService.sessionCommandSent.p; + results.push({ command, beforeWarmup, afterWarmup: voiceClientService.sessionCommands }); + } + + assert.deepStrictEqual(results, [{ + command: 'start', + beforeWarmup: { + prepareCalls: 1, + startCaptureCalls: 1, + stopCaptureCalls: 0, + sessionCommands: [], + socketConnected: true, + }, + afterWarmup: ['start'], + }, { + command: 'resume', + beforeWarmup: { + prepareCalls: 1, + startCaptureCalls: 1, + stopCaptureCalls: 1, + sessionCommands: [], + socketConnected: true, + }, + afterWarmup: ['resume'], + }]); + }); + + test('keeps microphone acquisition lazy when hands-free mode is disabled', async () => { + const voiceClientService = new TestVoiceClientService(); + const micCaptureService = new RecordingMicCaptureService(); + const controller = createController( + voiceClientService, + undefined, + undefined, + undefined, + micCaptureService, + new TestConfigurationService({ 'agents.voice.handsFree': false }), + ); + await controller.connect(mainWindow); + + voiceClientService.fireConnectionState(true); + await voiceClientService.sessionCommandSent.p; + + assert.deepStrictEqual({ + prepareCalls: micCaptureService.prepareCalls, + startCaptureCalls: micCaptureService.startCaptureCalls, + sessionCommands: voiceClientService.sessionCommands, + }, { + prepareCalls: 1, + startCaptureCalls: 0, + sessionCommands: ['start'], + }); + }); + + test('hands-free warm-up failure returns to idle and allows retry', async () => { + const voiceClientService = new TestVoiceClientService(); + const resetObserved = new DeferredPromise<void>(); + const micCaptureService = new class extends RecordingMicCaptureService { + override async startCapture(): Promise<void> { + this.startCaptureCalls++; + if (this.startCaptureCalls === 1) { + throw new Error('microphone unavailable'); + } + } + }(); + const controller = createController( + voiceClientService, + undefined, + undefined, + undefined, + micCaptureService, + new TestConfigurationService({ 'agents.voice.handsFree': true }), + undefined, + undefined, + undefined, + new class extends VoiceTestNotificationService { + override notify(notification: INotification): INotificationHandle { + resetObserved.complete(); + return super.notify(notification); + } + }(), + ); + await controller.connect(mainWindow); + + voiceClientService.fireConnectionState(true); + await resetObserved.p; + await Promise.resolve(); + const afterFailure = { + startCaptureCalls: micCaptureService.startCaptureCalls, + stopCaptureCalls: micCaptureService.stopCaptureCalls, + sessionCommands: [...voiceClientService.sessionCommands], + connecting: controller.isConnecting.get(), + connected: controller.isConnected.get(), + status: controller.statusText.get(), + }; + + await controller.connect(mainWindow); + voiceClientService.fireConnectionState(true); + await Promise.resolve(); + await Promise.resolve(); + assert.deepStrictEqual({ + afterFailure, + startCaptureCalls: micCaptureService.startCaptureCalls, + sessionCommands: voiceClientService.sessionCommands, + }, { + afterFailure: { + startCaptureCalls: 1, + stopCaptureCalls: 1, + sessionCommands: [], + connecting: false, + connected: false, + status: 'Tap to start', + }, + startCaptureCalls: 2, + sessionCommands: ['start'], + }); + }); + + test('hands-free permission denial does not add a generic connection notification', async () => { + const voiceClientService = new TestVoiceClientService(); + const notificationService = new VoiceTestNotificationService(); + const permissionError = new Error('Permission denied'); + permissionError.name = 'NotAllowedError'; + const micCaptureService = new class extends RecordingMicCaptureService { + override async startCapture(): Promise<void> { + this.startCaptureCalls++; + throw permissionError; + } + }(); + const controller = createController( + voiceClientService, + undefined, + undefined, + undefined, + micCaptureService, + new TestConfigurationService({ 'agents.voice.handsFree': true }), + undefined, + undefined, + undefined, + notificationService, + ); + await controller.connect(mainWindow); + voiceClientService.fireConnectionState(true); + await clock.tickAsync(0); + + assert.deepStrictEqual({ + startCaptureCalls: micCaptureService.startCaptureCalls, + notifications: notificationService.notifications.map(notification => notification.message), + sessionCommands: voiceClientService.sessionCommands, + connecting: controller.isConnecting.get(), + connected: controller.isConnected.get(), + status: controller.statusText.get(), + }, { + startCaptureCalls: 1, + notifications: [], + sessionCommands: [], + connecting: false, + connected: false, + status: 'Tap to start', + }); + }); + + test('connect watchdog covers a stalled hands-free warm-up', async () => { + const voiceClientService = new TestVoiceClientService(); + const captureBarrier = new DeferredPromise<void>(); + const micCaptureService = new RecordingMicCaptureService(captureBarrier.p); + const controller = createController( + voiceClientService, + undefined, + undefined, + undefined, + micCaptureService, + new TestConfigurationService({ 'agents.voice.handsFree': true }), + ); + await controller.connect(mainWindow); + + voiceClientService.fireConnectionState(true); + await micCaptureService.captureStarted.p; + clock.tick(10_000); + captureBarrier.complete(); + await Promise.resolve(); + + assert.deepStrictEqual({ + stopCaptureCalls: micCaptureService.stopCaptureCalls, + sessionCommands: voiceClientService.sessionCommands, + connecting: controller.isConnecting.get(), + connected: controller.isConnected.get(), + status: controller.statusText.get(), + }, { + stopCaptureCalls: 1, + sessionCommands: [], + connecting: false, + connected: false, + status: 'Tap to start', + }); + }); + + test('clean socket close during acquisition aborts initialization and allows explicit retry', async () => { + const voiceClientService = new TestVoiceClientService(); + const firstCaptureBarrier = new DeferredPromise<void>(); + const micCaptureService = new RecordingMicCaptureService(firstCaptureBarrier.p); + const controller = createController( + voiceClientService, + undefined, + undefined, + undefined, + micCaptureService, + new TestConfigurationService({ 'agents.voice.handsFree': true }), + ); + await controller.connect(mainWindow); + + voiceClientService.fireConnectionState(true); + await micCaptureService.captureStarted.p; + voiceClientService.fireConnectionState(false); + firstCaptureBarrier.complete(); + await Promise.resolve(); + + const afterDrop = { + startCaptureCalls: micCaptureService.startCaptureCalls, + stopCaptureCalls: micCaptureService.stopCaptureCalls, + sessionCommands: [...voiceClientService.sessionCommands], + connected: controller.isConnected.get(), + status: controller.statusText.get(), + }; + + await controller.connect(mainWindow); + voiceClientService.fireConnectionState(true); + await voiceClientService.sessionCommandSent.p; + + assert.deepStrictEqual({ + afterDrop, + afterRetry: { + startCaptureCalls: micCaptureService.startCaptureCalls, + stopCaptureCalls: micCaptureService.stopCaptureCalls, + sessionCommands: voiceClientService.sessionCommands, + connected: controller.isConnected.get(), + }, + }, { + afterDrop: { + startCaptureCalls: 1, + stopCaptureCalls: 1, + sessionCommands: [], + connected: false, + status: 'Tap to start', + }, + afterRetry: { + startCaptureCalls: 2, + stopCaptureCalls: 1, + sessionCommands: ['start'], + connected: true, + }, + }); + }); + + test('transient socket drop during acquisition retries warm-up before starting the session', async () => { + const voiceClientService = new TestVoiceClientService(); + const firstCaptureBarrier = new DeferredPromise<void>(); + const micCaptureService = new RecordingMicCaptureService(firstCaptureBarrier.p); + const controller = createController( + voiceClientService, + undefined, + undefined, + undefined, + micCaptureService, + new TestConfigurationService({ 'agents.voice.handsFree': true }), + ); + await controller.connect(mainWindow); + + voiceClientService.fireConnectionState(true); + await micCaptureService.captureStarted.p; + voiceClientService.fireConnectionState(false, true); + firstCaptureBarrier.complete(); + await Promise.resolve(); + const afterDrop = { + connecting: controller.isConnecting.get(), + reconnecting: controller.isReconnecting.get(), + stopCaptureCalls: micCaptureService.stopCaptureCalls, + sessionCommands: [...voiceClientService.sessionCommands], + status: controller.statusText.get(), + }; + + voiceClientService.fireConnectionState(true); + await voiceClientService.sessionCommandSent.p; + + assert.deepStrictEqual({ + afterDrop, + afterRetry: { + startCaptureCalls: micCaptureService.startCaptureCalls, + sessionCommands: voiceClientService.sessionCommands, + connected: controller.isConnected.get(), + }, + }, { + afterDrop: { + connecting: false, + reconnecting: true, + stopCaptureCalls: 1, + sessionCommands: [], + status: 'Reconnecting...', + }, + afterRetry: { + startCaptureCalls: 2, + sessionCommands: ['start'], + connected: true, + }, + }); + }); + + test('narrates visible questionnaire prompts and choices immediately without internal ids', () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionResource = URI.parse('chat-session:/mars-questionnaire'); + const carousel = new ChatQuestionCarouselData([ + { + id: 'mars_feature_scope', + type: 'singleSelect', + title: 'mars_feature_scope', + message: new MarkdownString('Which Mars features should the experience include?'), + description: 'Choose the main exploration scope.', + options: [ + { id: 'surface_only', label: 'Surface explorer - Drive between landmarks', value: 'surface_only' }, + { id: 'science_missions', label: 'Science missions - Collect samples and run experiments', value: 'science_missions' }, + ], + }, + { + id: 'mars_navigation_mode', + type: 'singleSelect', + title: 'mars_navigation_mode', + message: 'How should people navigate Mars?', + options: [ + { id: 'guided', label: 'Guided route', value: 'guided' }, + { id: 'free_roam', label: 'Free roam', value: 'free_roam' }, + ], + }, + { + id: 'mars_data_approach', + type: 'multiSelect', + title: 'mars_data_approach', + message: 'Which Mars data should be available?', + options: [ + { id: 'terrain', label: 'Terrain maps', value: 'terrain' }, + { id: 'weather', label: 'Weather readings', value: 'weather' }, + ], + }, + { + id: 'mars_rendering_style', + type: 'singleSelect', + title: 'mars_rendering_style', + message: 'What visual style should Mars use?', + options: [ + { id: 'realistic', label: 'Photorealistic', value: 'realistic' }, + { id: 'illustrated', label: 'Illustrated', value: 'illustrated' }, + ], + allowFreeformInput: true, + }, + ], true, 'mars_internal_resolve_id', undefined, false, new MarkdownString('Help shape the Mars experience.')); + const model = pendingResponsePartModel(sessionResource, carousel, 'questions: mars_feature_scope, mars_navigation_mode, mars_data_approach, mars_rendering_style'); + const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { state: string; detail?: string; confirmation_type?: VoiceConfirmationType }; + const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as (sessionId: string, state: string, detail: string | undefined, summary: string | undefined, shown: string, confirmationType?: VoiceConfirmationType) => void; + const isConnected = Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const watchVoiceProgress = Reflect.get(controller, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + const progress = createVoiceProgressResponse('mars-progress'); + + isConnected.set(true, undefined); + controller.setActiveSessionShown(sessionResource); + watchVoiceProgress.call(controller, sessionResource, progress.response); + progress.parts.push({ kind: 'voiceProgress', id: 'editing', value: 'Updating the Mars experience.' }); + progress.changeEmitter.fire({ reason: 'other' }); + const stateInfo = getAgentStateInfo.call(controller, model); + handleStateChange.call(controller, sessionResource.toString(), stateInfo.state, stateInfo.detail, undefined, sessionResource.toString(), stateInfo.confirmation_type); + const immediateRequestCount = voiceClientService.requests.length; + clock.tick(5_000); + + assert.deepStrictEqual({ + stateInfo, + immediateRequestCount, + request: voiceClientService.requests.map(request => ({ kind: request.kind, text: request.text, confirmationType: request.confirmationType })), + containsInternalIds: ['mars_feature_scope', 'mars_navigation_mode', 'mars_data_approach', 'mars_rendering_style', 'surface_only', 'free_roam'] + .some(id => stateInfo.detail?.includes(id)), + }, { + stateInfo: { + state: 'waiting_for_confirmation', + confirmation_type: 'questionnaire', + detail: [ + 'questionnaire: 4 questions', + 'context: Help shape the Mars experience.', + '1. Which Mars features should the experience include?', + 'details: Choose the main exploration scope.', + 'options: Surface explorer - Drive between landmarks; Science missions - Collect samples and run experiments; a custom response is also available', + '2. How should people navigate Mars?', + 'options: Guided route; Free roam; a custom response is also available', + '3. Which Mars data should be available?', + 'options: Terrain maps; Weather readings; a custom response is also available', + '4. What visual style should Mars use?', + 'options: Photorealistic; Illustrated; a custom response is also available', + 'The questionnaire is open in GitHub Copilot.', + ].join('\n'), + }, + immediateRequestCount: 1, + request: [{ + kind: 'confirmation', + confirmationType: 'questionnaire', + text: [ + 'questionnaire: 4 questions', + 'context: Help shape the Mars experience.', + '1. Which Mars features should the experience include?', + 'details: Choose the main exploration scope.', + 'options: Surface explorer - Drive between landmarks; Science missions - Collect samples and run experiments; a custom response is also available', + '2. How should people navigate Mars?', + 'options: Guided route; Free roam; a custom response is also available', + '3. Which Mars data should be available?', + 'options: Terrain maps; Weather readings; a custom response is also available', + '4. What visual style should Mars use?', + 'options: Photorealistic; Illustrated; a custom response is also available', + 'The questionnaire is open in GitHub Copilot.', + ].join('\n'), + }], + containsInternalIds: false, + }); + }); + + test('extracts visible runtime askQuestions data before carousel persistence', () => { + const voiceClientService = new TestVoiceClientService(); + const chatService = new ControllableChatService(); + const controller = createController(voiceClientService, undefined, undefined, undefined, undefined, undefined, chatService); + const sessionResource = URI.parse('chat-session:/runtime-mars-questionnaire'); + const rawQuestions: { + header: string; + question: string; + message?: string; + options: { label: string; description: string }[]; + multiSelect?: boolean; + }[] = [ + { + header: 'mars_scope', + question: 'What\'s the scope for Mars integration?', + message: 'This optional detail appears only after the carousel is appended.', + options: [ + { label: 'Full parallel system', description: 'Mars as a complete alternative view with its own layers, data, and panels (like a separate mode)' }, + { label: 'Comparison view', description: 'Earth and Mars side-by-side for comparison purposes' }, + { label: 'Solar system integration', description: 'Mars as part of an expandable planetary system (Earth, Mars, potentially others)' }, + { label: 'Just 3D Mars visualization', description: 'Focus on rendering Mars with minimal data layers for now' }, + ], + }, + { + header: 'mars_data', + question: 'What data should Mars display?', + options: [ + { label: 'Rovers & missions', description: 'Show NASA/international rovers, landing sites, and active missions' }, + { label: 'Geological features', description: 'Volcanoes, canyons, polar caps, water ice deposits' }, + { label: 'Real-time data', description: 'Current rover telemetry, atmospheric data, dust storms' }, + { label: 'Habitability layers', description: 'Radiation, temperature, water availability zones' }, + { label: 'All of the above', description: 'Full comprehensive Mars visualization' }, + ], + multiSelect: true, + }, + { + header: 'mars_textures', + question: 'How should Mars be textured?', + options: [ + { label: 'Procedurally generated (like Earth)', description: 'Canvas-based procedural generation matching current Earth approach' }, + { label: 'Real NASA imagery', description: 'Use actual Mars satellite imagery (requires downloading/hosting image files)' }, + { label: 'Simplified stylized', description: 'Simple color palette (red/orange) like a simplified Earth' }, + ], + }, + { + header: 'mars_timeline', + question: 'Should Mars have historical/future data?', + options: [ + { label: 'Current only', description: 'Show current rovers and active missions' }, + { label: 'Historical missions', description: 'Include past rovers (Spirit, Opportunity, etc.) and historical landing sites' }, + { label: 'Future missions', description: 'Include planned future missions and colonization zones' }, + { label: 'All timeframes', description: 'Full timeline from first landing to future missions' }, + ], + }, + ]; + const backingTool = new class extends mock<IChatToolInvocation>() { + override readonly kind = 'toolInvocation' as const; + override readonly toolId = AskQuestionsToolId; + override readonly toolCallId = 'toolu_runtime'; + override readonly invocationMessage = 'Asked 4 questions (mars_scope, mars_data, mars_textures, mars_timeline)'; + override readonly state = observableValue<IChatToolInvocation.State>('toolState', { + type: IChatToolInvocation.StateKind.WaitingForConfirmation, + parameters: { questions: rawQuestions }, + confirmationMessages: undefined, + confirm: () => { }, + }); + }(); + const parts: IChatProgressResponseContent[] = [backingTool]; + const pendingConfirmation = observableValue<{ detail?: string } | undefined>('pending', { detail: 'Asked 4 questions' }); + const response = { + isPendingConfirmation: pendingConfirmation, + isIncomplete: observableValue('incomplete', false), + response: { value: parts, getMarkdown: () => '' }, + }; + const lastRequest = { id: 'request-runtime-questionnaire', response }; + const model = { + sessionResource, + title: 'Chat', + lastMessageDate: Date.now(), + getRequests: () => [lastRequest], + lastRequestObs: observableValue('lastRequest', lastRequest), + } as unknown as IChatModel; + const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { + state: string; + detail?: string; + confirmation_type?: VoiceConfirmationType; + }; + const checkSessionStateChanges = Reflect.get(controller, '_checkSessionStateChanges') as () => void; + const previousStates = Reflect.get(controller, '_prevSessionStates') as Map<string, { + state: string; + detail: string; + confirmationType?: VoiceConfirmationType; + lastResponseSummary: string; + }>; + + controller.setActiveSessionShown(sessionResource); + chatService.setModels([model]); + previousStates.set(sessionResource.toString(), { state: 'thinking', detail: '', lastResponseSummary: '' }); + const pendingInfo = getAgentStateInfo.call(controller, model); + checkSessionStateChanges.call(controller); + const requestsBeforeCarousel = voiceClientService.requests.length; + const narrationBeforeCarousel = voiceClientService.requests.at(-1); + + const runtimeCarousel = new ChatQuestionCarouselData(rawQuestions.map((question, index) => ({ + id: `toolu_runtime:${index}`, + type: question.multiSelect ? 'multiSelect' : 'singleSelect', + title: question.header, + message: question.question, + detailedMessage: question.message, + options: question.options.map(option => ({ + id: option.label, + label: `${option.label} - ${option.description}`, + value: option.label, + })), + allowFreeformInput: true, + })), true, 'toolu_runtime'); + parts.push(runtimeCarousel); + checkSessionStateChanges.call(controller); + const requestsAfterCarousel = voiceClientService.requests.length; + const narrationAfterCarousel = voiceClientService.requests.at(-1); + + assert.deepStrictEqual({ + pendingState: pendingInfo.state, + pendingType: pendingInfo.confirmation_type, + pendingHasVisibleDetail: pendingInfo.detail?.startsWith('questionnaire: 4 questions'), + requestsBeforeCarousel, + requestsAfterCarousel, + initialNarrationKind: narrationBeforeCarousel?.kind, + initialNarrationType: narrationBeforeCarousel?.confirmationType, + initialHasQuestionCount: narrationBeforeCarousel?.text.startsWith('questionnaire: 4 questions'), + initialHasFirstPrompt: narrationBeforeCarousel?.text.includes('1. What\'s the scope for Mars integration?'), + initialHasLastPrompt: narrationBeforeCarousel?.text.includes('4. Should Mars have historical/future data?'), + followupNarrationKind: narrationAfterCarousel?.kind, + followupHasVisibleOptionDescription: narrationAfterCarousel?.text.includes('Full parallel system - Mars as a complete alternative view'), + includesLateDetails: narrationAfterCarousel?.text.includes('This optional detail appears only after the carousel is appended.'), + usedFallback: narrationBeforeCarousel?.text === 'I need your input in the open questionnaire.', + containsHiddenIds: ['mars_scope', 'mars_data', 'mars_textures', 'mars_timeline', 'toolu_runtime'] + .some(value => narrationBeforeCarousel?.text.includes(value) || narrationAfterCarousel?.text.includes(value)), + }, { + pendingState: 'waiting_for_confirmation', + pendingType: 'questionnaire', + pendingHasVisibleDetail: true, + requestsBeforeCarousel: 1, + requestsAfterCarousel: 2, + initialNarrationKind: 'confirmation', + initialNarrationType: 'questionnaire', + initialHasQuestionCount: true, + initialHasFirstPrompt: true, + initialHasLastPrompt: true, + followupNarrationKind: 'question', + followupHasVisibleOptionDescription: true, + includesLateDetails: false, + usedFallback: false, + containsHiddenIds: false, + }); + }); + + test('defers runtime askQuestions narration until visible parameters populate', () => { + const voiceClientService = new TestVoiceClientService(); + const chatService = new ControllableChatService(); + const controller = createController(voiceClientService, undefined, undefined, undefined, undefined, undefined, chatService); + const sessionResource = URI.parse('chat-session:/late-runtime-questionnaire'); + const toolState = observableValue<IChatToolInvocation.State>('toolState', { + type: IChatToolInvocation.StateKind.WaitingForConfirmation, + parameters: { questions: [] }, + confirmationMessages: undefined, + confirm: () => { }, + }); + const backingTool = new class extends mock<IChatToolInvocation>() { + override readonly kind = 'toolInvocation' as const; + override readonly toolId = AskQuestionsToolId; + override readonly invocationMessage = 'Asking a clarifying question'; + override readonly state = toolState; + }(); + const pendingConfirmation = observableValue<{ detail?: string } | undefined>('pending', { detail: 'Asking a clarifying question' }); + const response = { + isPendingConfirmation: pendingConfirmation, + isIncomplete: observableValue('incomplete', false), + response: { value: [backingTool], getMarkdown: () => '' }, + }; + const lastRequest = { id: 'request-late-questionnaire', response }; + const model = { + sessionResource, + title: 'Chat', + lastMessageDate: Date.now(), + getRequests: () => [lastRequest], + lastRequestObs: observableValue('lastRequest', lastRequest), + } as unknown as IChatModel; + const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { + state: string; + detail?: string; + confirmation_type?: VoiceConfirmationType; + }; + const checkSessionStateChanges = Reflect.get(controller, '_checkSessionStateChanges') as () => void; + const previousStates = Reflect.get(controller, '_prevSessionStates') as Map<string, { + state: string; + detail: string; + confirmationType?: VoiceConfirmationType; + lastResponseSummary: string; + }>; + + controller.setActiveSessionShown(sessionResource); + chatService.setModels([model]); + previousStates.set(sessionResource.toString(), { state: 'thinking', detail: '', lastResponseSummary: '' }); + const pendingInfo = getAgentStateInfo.call(controller, model); + checkSessionStateChanges.call(controller); + const requestsBeforePopulation = voiceClientService.requests.length; + + toolState.set({ + type: IChatToolInvocation.StateKind.WaitingForConfirmation, + parameters: { + questions: [{ + header: 'internal_scope', + question: 'Which Mars scope should GitHub Copilot use?', + options: [{ + label: 'Comparison view', + description: 'Show Earth and Mars side-by-side', + value: 'hidden-value', + }], + recommended: true, + }], + }, + confirmationMessages: undefined, + confirm: () => { }, + }, undefined); + checkSessionStateChanges.call(controller); + const narration = voiceClientService.requests.at(-1); + + assert.deepStrictEqual({ + pendingInfo, + requestsBeforePopulation, + narration: narration ? { + kind: narration.kind, + confirmationType: narration.confirmationType, + text: narration.text, + } : undefined, + containsHiddenMetadata: ['internal_scope', 'hidden-value', 'recommended'] + .some(value => narration?.text.includes(value)), + }, { + pendingInfo: { + state: 'waiting_for_confirmation', + confirmation_type: 'questionnaire', + }, + requestsBeforePopulation: 0, + narration: { + kind: 'confirmation', + confirmationType: 'questionnaire', + text: [ + 'questionnaire: 1 question', + '1. Which Mars scope should GitHub Copilot use?', + 'options: Comparison view - Show Earth and Mars side-by-side; a custom response is also available', + 'The questionnaire is open in GitHub Copilot.', + ].join('\n'), + }, + containsHiddenMetadata: false, + }); + }); + + test('carries questionnaire type in session context and clears it when resolved', () => { + const chatService = new ControllableChatService(); + const controller = createController(new TestVoiceClientService(), undefined, undefined, undefined, undefined, undefined, chatService); + const sessionResource = URI.parse('chat-session:/durable-questionnaire'); + const carousel = new ChatQuestionCarouselData([{ + id: 'hidden-question-id', + type: 'singleSelect', + title: 'Hidden title key', + message: 'Which deployment should GitHub Copilot use?', + options: [{ id: 'hidden-option-id', label: 'Preview deployment', value: 'hidden-option-value' }], + }], true); + const pendingConfirmation = observableValue<{ detail?: string } | undefined>('pending', { detail: 'Needs approval' }); + const response = { + isPendingConfirmation: pendingConfirmation, + isIncomplete: observableValue('incomplete', false), + response: { value: [carousel], getMarkdown: () => '' }, + }; + const lastRequest = { id: 'request-questionnaire', response }; + const model = { + sessionResource, + title: 'Chat', + lastMessageDate: Date.now(), + getRequests: () => [lastRequest], + lastRequestObs: observableValue('lastRequest', lastRequest), + } as unknown as IChatModel; + const buildSessionContext = Reflect.get(controller, '_buildSessionContext') as () => { sessions: readonly Record<string, unknown>[] }; + + controller.setActiveSessionShown(sessionResource); + chatService.setModels([model]); + const pendingContext = buildSessionContext.call(controller).sessions[0]; + carousel.isUsed = true; + carousel.isUsed = true; + pendingConfirmation.set(undefined, undefined); + const resolvedContext = buildSessionContext.call(controller).sessions[0]; + + const pending = pendingContext?.['pending'] as Record<string, unknown> | undefined; + assert.deepStrictEqual({ + pendingContext: pendingContext ? { + id: pendingContext['id'], + is_active: pendingContext['is_active'], + agent_state: pendingContext['agent_state'], + agent_state_detail: pendingContext['agent_state_detail'], + confirmation_type: pendingContext['confirmation_type'], + pending: pending ? { + type: pending['type'], + request_id: pending['request_id'], + pendingIdMatchesRequest: typeof pending['pending_id'] === 'string' && pending['pending_id'].startsWith('request-questionnaire#'), + allow_skip: pending['allow_skip'], + questions: pending['questions'], + } : undefined, + } : undefined, + resolvedContext, + }, { + pendingContext: { + id: sessionResource.toString(), + is_active: true, + agent_state: 'waiting_for_confirmation', + agent_state_detail: [ + 'questionnaire: 1 question', + '1. Which deployment should GitHub Copilot use?', + 'options: Preview deployment; a custom response is also available', + 'The questionnaire is open in GitHub Copilot.', + ].join('\n'), + confirmation_type: 'questionnaire', + pending: { + type: 'questions', + request_id: 'request-questionnaire', + pendingIdMatchesRequest: true, + allow_skip: true, + questions: [{ + id: 'hidden-question-id', + type: 'singleSelect', + title: 'Which deployment should GitHub Copilot use?', + allow_freeform: true, + options: [{ label: 'Preview deployment', value: 'hidden-option-value' }], + }], + }, + }, + resolvedContext: { + id: sessionResource.toString(), + label: 'Chat', + is_active: true, + agent_state: 'idle', + }, + }); + }); + + test('routes structured pending responses to the same action that is narrated', () => { + const chatService = new ControllableChatService(); + const controller = createController(new TestVoiceClientService(), undefined, undefined, undefined, undefined, undefined, chatService); + const buildSessionContext = Reflect.get(controller, '_buildSessionContext') as () => { sessions: readonly { pending?: { type: string; pending_id: string; message?: string } }[] }; + const waitingTool = (id: string, postApproval = false) => new class extends mock<IChatToolInvocation>() { + override readonly kind = 'toolInvocation' as const; + override readonly toolId = id; + override readonly invocationMessage = `Run ${id}`; + override readonly state = observableValue<IChatToolInvocation.State>(`${id}State`, postApproval ? { + type: IChatToolInvocation.StateKind.WaitingForPostApproval, + parameters: {}, + confirmationMessages: { title: `Approve ${id}?`, message: `Review ${id}.` }, + confirmed: { type: ToolConfirmKind.UserAction }, + resultDetails: undefined, + confirm: () => { }, + contentForModel: [], + } : { + type: IChatToolInvocation.StateKind.WaitingForConfirmation, + parameters: {}, + confirmationMessages: { title: `Approve ${id}?`, message: `Review ${id}.` }, + confirm: () => { }, + }); + }(); + const pendingFor = (resource: URI, requestId: string, parts: IChatProgressResponseContent[]) => { + const response = { + isPendingConfirmation: observableValue<{ detail?: string } | undefined>(`${requestId}Pending`, { detail: 'Needs input' }), + isIncomplete: observableValue(`${requestId}Incomplete`, false), + response: { value: parts, getMarkdown: () => '' }, + }; + const lastRequest = { id: requestId, response }; + const model = { + sessionResource: resource, + title: 'Chat', + lastMessageDate: Date.now(), + getRequests: () => [lastRequest], + lastRequestObs: observableValue(`${requestId}LastRequest`, lastRequest), + } as unknown as IChatModel; + controller.setActiveSessionShown(resource); + chatService.setModels([model]); + return buildSessionContext.call(controller).sessions[0]?.pending; + }; + + const questionnaire = new ChatQuestionCarouselData([{ + id: 'region', + type: 'singleSelect', + title: 'Region', + message: 'Which region?', + options: [{ id: 'west', label: 'West US', value: 'westus' }], + }], true); + const unrelatedTool = waitingTool('unrelated'); + const questionnairePending = pendingFor(URI.parse('chat-session:/questionnaire-route'), 'request-questionnaire-route', [questionnaire, unrelatedTool]); + + const plan = new ChatPlanReviewData('Review plan', 'Plan body', [{ id: 'implement', label: 'Implement Plan' }], true); + const olderTool = waitingTool('older'); + const planPending = pendingFor(URI.parse('chat-session:/plan-route'), 'request-plan-route', [olderTool, plan]); + + const postApprovalTool = waitingTool('post-approval', true); + const postApprovalPending = pendingFor(URI.parse('chat-session:/post-route'), 'request-post-route', [postApprovalTool]); + + const askQuestionsTool = waitingTool(AskQuestionsToolId); + const olderQuestionnaire = new ChatQuestionCarouselData([{ + id: 'older-region', + type: 'singleSelect', + title: 'Older region', + message: 'Which older region?', + options: [{ id: 'east', label: 'East US', value: 'eastus' }], + }], true); + const askQuestionsPending = pendingFor(URI.parse('chat-session:/ask-route'), 'request-ask-route', [olderQuestionnaire, askQuestionsTool]); + + assert.deepStrictEqual({ + questionnaire: { + type: questionnairePending?.type, + idMatches: questionnairePending?.pending_id === peekPendingId('request-questionnaire-route', questionnaire), + }, + plan: { + type: planPending?.type, + idMatches: planPending?.pending_id === peekPendingId('request-plan-route', olderTool), + }, + postApproval: { + type: postApprovalPending?.type, + idMatches: postApprovalPending?.pending_id === peekPendingId('request-post-route', postApprovalTool), + }, + askQuestionsBeforeCarousel: { + type: askQuestionsPending?.type, + idMatches: askQuestionsPending?.pending_id === peekPendingId('request-ask-route', olderQuestionnaire), + }, + }, { + questionnaire: { type: 'questions', idMatches: true }, + plan: { type: 'approval', idMatches: true }, + postApproval: { type: 'approval', idMatches: true }, + askQuestionsBeforeCarousel: { type: 'questions', idMatches: true }, + }); + }); + + test('flushes exact typed context before fresh and changed confirmation narration', () => { + const scenarios: { + name: string; + part: IChatProgressResponseContent; + fromState: string; + fromDetail: string; + fromType?: VoiceConfirmationType; + expectedType: VoiceConfirmationType; + expectedDetail: string; + }[] = [ + { + name: 'fresh-generic', + part: { + kind: 'confirmation', + title: 'Install extensions?', + message: 'Review the visible extension approval.', + data: {}, + }, + fromState: 'thinking', + fromDetail: '', + expectedType: 'generic', + expectedDetail: [ + 'confirmation: Install extensions?', + 'Review the visible extension approval.', + ].join('\n'), + }, + { + name: 'plan-to-generic', + part: { + kind: 'confirmation', + title: 'Confirm the revised plan?', + message: 'Review the revised plan confirmation.', + data: {}, + }, + fromState: 'waiting_for_confirmation', + fromDetail: [ + 'plan approval: Review the implementation plan', + 'choices: Implement Plan', + 'The plan is open in GitHub Copilot.', + ].join('\n'), + fromType: 'plan', + expectedType: 'generic', + expectedDetail: [ + 'confirmation: Confirm the revised plan?', + 'Review the revised plan confirmation.', + ].join('\n'), + }, + { + name: 'detail-change', + part: { + kind: 'confirmation', + title: 'Approve the updated extension set?', + message: 'Review the updated visible extension approval.', + data: {}, + }, + fromState: 'waiting_for_confirmation', + fromDetail: 'confirmation: Approve the old extension set?', + fromType: 'generic', + expectedType: 'generic', + expectedDetail: [ + 'confirmation: Approve the updated extension set?', + 'Review the updated visible extension approval.', + ].join('\n'), + }, + ]; + const results: { + name: string; + contextBeforeRequest: boolean; + contextSession: Record<string, unknown> | undefined; + request: { kind: VoiceNarrationKind; text: string; confirmationType?: VoiceConfirmationType } | undefined; + }[] = []; + + for (const scenario of scenarios) { + const voiceClientService = new TestVoiceClientService(); + const chatService = new ControllableChatService(); + const controller = createController(voiceClientService, undefined, undefined, undefined, undefined, undefined, chatService); + const sessionResource = URI.parse(`chat-session:/${scenario.name}`); + const model = pendingResponsePartModel(sessionResource, scenario.part); + const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { + state: string; + detail?: string; + confirmation_type?: VoiceConfirmationType; + }; + const pendingChanges = Reflect.get(controller, '_pendingStateChanges') as Map<string, { + sessionId: string; + currentState: string; + label: string; + detail?: string; + confirmationType?: VoiceConfirmationType; + fromState: string; + fromDetail: string; + fromConfirmationType?: VoiceConfirmationType; + fromResponseSummary: string; + pendingId: string; + fromPendingId: string; + }>; + const emitPendingStateChanges = Reflect.get(controller, '_emitPendingStateChanges') as () => void; + const pendingIdFor = Reflect.get(controller, '_pendingIdFor') as (sessionId: string) => string; + + controller.setActiveSessionShown(sessionResource); + chatService.setModels([model]); + voiceClientService.wireEvents.length = 0; + const stateInfo = getAgentStateInfo.call(controller, model); + pendingChanges.set(sessionResource.toString(), { + sessionId: sessionResource.toString(), + currentState: stateInfo.state, + label: 'Chat', + detail: stateInfo.detail, + confirmationType: stateInfo.confirmation_type, + fromState: scenario.fromState, + fromDetail: scenario.fromDetail, + fromConfirmationType: scenario.fromType, + fromResponseSummary: '', + pendingId: pendingIdFor.call(controller, sessionResource.toString()), + fromPendingId: '', + }); + emitPendingStateChanges.call(controller); + + const requestIndex = voiceClientService.wireEvents.findIndex(event => event.type === 'request_narration'); + const contextEvents = voiceClientService.wireEvents.slice(0, requestIndex).filter(event => event.type === 'session_context'); + const contextSession = contextEvents.at(-1)?.context.sessions.find(session => session.id === sessionResource.toString()); + const request = voiceClientService.wireEvents[requestIndex]; + results.push({ + name: scenario.name, + contextBeforeRequest: requestIndex > 0 && contextEvents.length > 0, + contextSession, + request: request?.type === 'request_narration' ? request : undefined, + }); + } + + assert.deepStrictEqual(results.map(result => ({ + name: result.name, + contextBeforeRequest: result.contextBeforeRequest, + contextState: result.contextSession?.['agent_state'], + contextDetail: result.contextSession?.['agent_state_detail'], + contextType: result.contextSession?.['confirmation_type'], + request: result.request, + })), scenarios.map(scenario => ({ + name: scenario.name, + contextBeforeRequest: true, + contextState: 'waiting_for_confirmation', + contextDetail: scenario.expectedDetail, + contextType: scenario.expectedType, + request: { + type: 'request_narration', + kind: 'confirmation', + text: scenario.expectedDetail, + confirmationType: scenario.expectedType, + }, + }))); + }); + + test('same confirmation text with a new type is not deduplicated', async () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionId = 'chat-session:/typed-confirmation-dedup'; + const narrate = Reflect.get(controller, '_narrate') as ( + sessionId: string, + kind: VoiceNarrationKind, + text: string, + reuseId?: string, + checkpoint?: IVoiceCheckpointNarrationMetadata, + confirmationType?: VoiceConfirmationType, + ) => boolean; + await controller.connect(mainWindow); + + const questionnaireSent = narrate.call(controller, sessionId, 'confirmation', 'I need your input.', undefined, undefined, 'questionnaire'); + const duplicateQuestionnaireSent = narrate.call(controller, sessionId, 'confirmation', 'I need your input.', undefined, undefined, 'questionnaire'); + const planSent = narrate.call(controller, sessionId, 'confirmation', 'I need your input.', undefined, undefined, 'plan'); + + assert.deepStrictEqual({ + questionnaireSent, + duplicateQuestionnaireSent, + planSent, + types: voiceClientService.requests.map(request => request.confirmationType), + }, { + questionnaireSent: true, + duplicateQuestionnaireSent: false, + planSent: true, + types: ['questionnaire', 'plan'], + }); + }); + + test('reconnect replays only confirmations matching current text and type', async () => { + const cases: { + name: string; + pending: { kind: 'response' | 'confirmation'; text: string; confirmationType?: VoiceConfirmationType }; + current: { kind: 'response' | 'confirmation'; text: string; confirmationType?: VoiceConfirmationType } | undefined; + }[] = [ + { + name: 'generic-to-plan', + pending: { kind: 'confirmation', text: 'Review this item.', confirmationType: 'generic' }, + current: { kind: 'confirmation', text: 'Review this item.', confirmationType: 'plan' }, + }, + { + name: 'generic-to-idle', + pending: { kind: 'confirmation', text: 'Review this item.', confirmationType: 'generic' }, + current: { kind: 'response', text: 'Done.' }, + }, + { + name: 'legacy-to-generic', + pending: { kind: 'confirmation', text: 'Review this item.' }, + current: { kind: 'confirmation', text: 'Review this item.', confirmationType: 'generic' }, + }, + { + name: 'matching-generic', + pending: { kind: 'confirmation', text: 'Review this item.', confirmationType: 'generic' }, + current: { kind: 'confirmation', text: 'Review this item.', confirmationType: 'generic' }, + }, + { + name: 'matching-legacy', + pending: { kind: 'confirmation', text: 'Legacy confirmation.' }, + current: { kind: 'confirmation', text: 'Legacy confirmation.' }, + }, + { + name: 'response-conflicts-with-generic', + pending: { kind: 'response', text: 'Old final response.' }, + current: { kind: 'confirmation', text: 'Current confirmation.', confirmationType: 'generic' }, + }, + { + name: 'response-summary-changed', + pending: { kind: 'response', text: 'Old final response.' }, + current: { kind: 'response', text: 'New final response.' }, + }, + { + name: 'matching-response', + pending: { kind: 'response', text: 'Final response.' }, + current: { kind: 'response', text: 'Final response.' }, + }, + ]; + const results: { name: string; requests: { kind: VoiceNarrationKind; text: string; confirmationType?: VoiceConfirmationType }[] }[] = []; + + for (const testCase of cases) { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionId = `chat-session:/${testCase.name}`; + await controller.connect(mainWindow); + const retries = Reflect.get(controller, '_pendingNarrationRetries') as Map<string, typeof testCase.pending>; + retries.set(sessionId, testCase.pending); + Reflect.set(controller, '_currentNarratable', () => testCase.current); + controller.setActiveSessionShown(URI.parse(sessionId)); + + voiceClientService.fireSessionInit(); + results.push({ + name: testCase.name, + requests: voiceClientService.requests.map(request => ({ + kind: request.kind, + text: request.text, + ...(request.confirmationType ? { confirmationType: request.confirmationType } : {}), + })), + }); + } + + assert.deepStrictEqual(results, [ + { name: 'generic-to-plan', requests: [] }, + { name: 'generic-to-idle', requests: [] }, + { name: 'legacy-to-generic', requests: [] }, + { name: 'matching-generic', requests: [{ kind: 'confirmation', text: 'Review this item.', confirmationType: 'generic' }] }, + { name: 'matching-legacy', requests: [{ kind: 'confirmation', text: 'Legacy confirmation.' }] }, + { name: 'response-conflicts-with-generic', requests: [] }, + { name: 'response-summary-changed', requests: [] }, + { name: 'matching-response', requests: [{ kind: 'response', text: 'Final response.' }] }, + ]); + }); + + test('busy confirmation retries only when current text and type still match', () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionId = 'chat-session:/deferred-confirmation'; + const sessionKey = (Reflect.get(controller, '_sessionKey') as (sessionId: string) => string).call(controller, sessionId); + const deferred = Reflect.get(controller, '_deferredNarrations') as Map<string, { + narrationId: string; + kind: 'confirmation'; + text: string; + reuseNarrationId: boolean; + confirmationType?: VoiceConfirmationType; + }>; + const retry = Reflect.get(controller, '_retryDeferredNarration') as (sessionKey: string) => boolean; + controller.setActiveSessionShown(URI.parse(sessionId)); + + deferred.set(sessionKey, { + narrationId: 'stale-type', + kind: 'confirmation', + text: 'Review this item.', + reuseNarrationId: true, + confirmationType: 'generic', + }); + Reflect.set(controller, '_currentNarratable', () => ({ kind: 'confirmation', text: 'Review this item.', confirmationType: 'plan' })); + const staleTypeRetried = retry.call(controller, sessionKey); + + deferred.set(sessionKey, { + narrationId: 'stale-text', + kind: 'confirmation', + text: 'Old detail.', + reuseNarrationId: true, + confirmationType: 'generic', + }); + Reflect.set(controller, '_currentNarratable', () => ({ kind: 'confirmation', text: 'New detail.', confirmationType: 'generic' })); + const staleTextRetried = retry.call(controller, sessionKey); + + deferred.set(sessionKey, { + narrationId: 'matching', + kind: 'confirmation', + text: 'Current detail.', + reuseNarrationId: true, + confirmationType: 'generic', + }); + Reflect.set(controller, '_currentNarratable', () => ({ kind: 'confirmation', text: 'Current detail.', confirmationType: 'generic' })); + const matchingRetried = retry.call(controller, sessionKey); + + assert.deepStrictEqual({ + staleTypeRetried, + staleTextRetried, + matchingRetried, + requests: voiceClientService.requests.map(request => ({ + narrationId: request.narrationId, + text: request.text, + confirmationType: request.confirmationType, + })), + deferredCount: deferred.size, + }, { + staleTypeRetried: false, + staleTextRetried: false, + matchingRetried: true, + requests: [{ + narrationId: 'matching', + text: 'Current detail.', + confirmationType: 'generic', + }], + deferredCount: 0, + }); + }); + + test('auto-approve ignores questionnaire backing tools', () => { + const controller = createController(new TestVoiceClientService()); + const confirmed: ToolConfirmKind[] = []; + const toolInvocation = new class extends mock<IChatToolInvocation>() { + override readonly kind = 'toolInvocation' as const; + override readonly state = observableValue<IChatToolInvocation.State>('toolState', { + type: IChatToolInvocation.StateKind.WaitingForConfirmation, + parameters: {}, + confirmationMessages: { + title: 'Submit questionnaire?', + message: 'Submits the questionnaire answers.', + }, + confirm: reason => confirmed.push(reason.type), + }); + override readonly invocationMessage = 'Submit questionnaire'; + }(); + const questionnaire = new ChatQuestionCarouselData([{ + id: 'hidden-question-id', + type: 'singleSelect', + title: 'Choose an option', + options: [{ id: 'hidden-option-id', label: 'Visible option', value: 'hidden-value' }], + }], true); + const pendingConfirmation = observableValue<{ detail?: string } | undefined>('pending', { detail: 'Needs input' }); + const modelWithQuestionnaire = { + getRequests: () => [{ + response: { + isPendingConfirmation: pendingConfirmation, + response: { value: [toolInvocation, questionnaire] }, + }, + }], + } as unknown as IChatModel; + const modelWithTool = { + getRequests: () => [{ + response: { + isPendingConfirmation: pendingConfirmation, + response: { value: [toolInvocation] }, + }, + }], + } as unknown as IChatModel; + const autoApprovePendingTools = Reflect.get(controller, '_autoApprovePendingTools') as (model: IChatModel) => void; + + autoApprovePendingTools.call(controller, modelWithQuestionnaire); + autoApprovePendingTools.call(controller, modelWithTool); + + assert.deepStrictEqual(confirmed, [ToolConfirmKind.UserAction]); + }); + + test('handles freeform and defers empty questionnaire data', () => { + const controller = createController(new TestVoiceClientService()); + const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { state: string; detail?: string }; + const freeform = new ChatQuestionCarouselData([{ + id: 'internal_name_key', + type: 'text', + title: 'internal_name_key', + message: 'What should we call the Mars explorer?', + }], false); + const missing = new ChatQuestionCarouselData([], true, 'hidden_resolve_id'); + const internalTitleOnly = new ChatQuestionCarouselData([{ + id: 'internal_prompt_key', + type: 'text', + title: 'internal_prompt_key', + }], true); + const noCustomOption = new ChatQuestionCarouselData([{ + id: 'navigation', + type: 'singleSelect', + title: 'Navigation', + message: 'Choose a navigation mode.', + options: [{ id: 'guided', label: 'Guided route', value: 'guided' }], + allowFreeformInput: false, + }], true); + + assert.deepStrictEqual([ + getAgentStateInfo.call(controller, pendingResponsePartModel(URI.parse('chat-session:/freeform'), freeform, undefined, false)), + getAgentStateInfo.call(controller, pendingResponsePartModel(URI.parse('chat-session:/missing'), missing, undefined, false)), + getAgentStateInfo.call(controller, pendingResponsePartModel(URI.parse('chat-session:/internal-title'), internalTitleOnly, undefined, false)), + getAgentStateInfo.call(controller, pendingResponsePartModel(URI.parse('chat-session:/no-custom'), noCustomOption, undefined, false)), + ], [ + { + state: 'waiting_for_confirmation', + confirmation_type: 'questionnaire', + detail: [ + 'questionnaire: 1 question', + '1. What should we call the Mars explorer?', + 'response: enter a free-form answer in GitHub Copilot', + 'The questionnaire is open in GitHub Copilot.', + ].join('\n'), + }, + { + state: 'waiting_for_confirmation', + confirmation_type: 'questionnaire' + }, + { + state: 'waiting_for_confirmation', + confirmation_type: 'questionnaire', + detail: [ + 'questionnaire: 1 question', + '1. I need your input in the open questionnaire.', + 'response: enter a free-form answer in GitHub Copilot', + 'The questionnaire is open in GitHub Copilot.', + ].join('\n'), + }, + { + state: 'waiting_for_confirmation', + confirmation_type: 'questionnaire', + detail: [ + 'questionnaire: 1 question', + '1. Choose a navigation mode.', + 'options: Guided route', + 'The questionnaire is open in GitHub Copilot.', + ].join('\n'), + }, + ]); + }); + + test('bounds questionnaire questions and options with omission counts', () => { + const controller = createController(new TestVoiceClientService()); + const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { detail?: string }; + const carousel = new ChatQuestionCarouselData(Array.from({ length: 8 }, (_, questionIndex) => ({ + id: `internal_question_${questionIndex}`, + type: 'singleSelect' as const, + title: `Internal question ${questionIndex}`, + message: `Visible question ${questionIndex + 1}?`, + options: Array.from({ length: 8 }, (_, optionIndex) => ({ + id: `internal_option_${questionIndex}_${optionIndex}`, + label: `Visible option ${optionIndex + 1}`, + value: `hidden_value_${optionIndex}`, + })), + })), true); + const detail = getAgentStateInfo.call(controller, pendingResponsePartModel(URI.parse('chat-session:/bounded'), carousel)).detail ?? ''; + + assert.deepStrictEqual({ + withinLimit: detail.length <= 2_400, + includesOptionOmission: detail.includes('3 more options'), + includesQuestionOmission: detail.includes('2 more questions are open in GitHub Copilot.'), + containsInternalIds: detail.includes('internal_question_') || detail.includes('internal_option_') || detail.includes('hidden_value_'), + }, { + withinLimit: true, + includesOptionOmission: true, + includesQuestionOmission: true, + containsInternalIds: false, + }); + }); + + test('distinguishes plan, elicitation, and tool approval using visible text', () => { + const controller = createController(new TestVoiceClientService()); + const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { state: string; detail?: string }; + const plan = new ChatPlanReviewData('Review the Mars implementation plan', '# Hidden plan body', [ + { id: 'internal_implement', label: 'Implement Plan', description: 'Start making the changes' }, + { id: 'internal_autopilot', label: 'Continue in Autopilot', description: 'Proceed automatically' }, + ], true, undefined, 'internal_plan_resolve_id'); + const elicitation = new ChatElicitationRequestPart( + new MarkdownString('Choose a deployment target'), + 'Select where GitHub Copilot should deploy the preview.', + 'Your choice is required before continuing.', + 'Continue', + 'Cancel', + async () => ElicitationState.Accepted, + ); + const confirmation: IChatConfirmation = { + kind: 'confirmation', + title: 'Install recommended extensions?', + message: new MarkdownString('This installs the extensions shown in the open approval.'), + buttons: ['Install', 'Cancel'], + data: { hiddenInternalId: 'extension_install' }, + }; + + assert.deepStrictEqual([ + getAgentStateInfo.call(controller, pendingResponsePartModel(URI.parse('chat-session:/plan'), plan)), + getAgentStateInfo.call(controller, pendingResponsePartModel(URI.parse('chat-session:/elicitation'), elicitation)), + getAgentStateInfo.call(controller, pendingResponsePartModel(URI.parse('chat-session:/confirmation'), confirmation)), + ], [ + { + state: 'waiting_for_confirmation', + confirmation_type: 'plan', + detail: [ + 'plan approval: Review the Mars implementation plan', + 'choices: Implement Plan - Start making the changes; Continue in Autopilot - Proceed automatically', + 'The plan is open in GitHub Copilot.', + ].join('\n'), + }, + { + state: 'waiting_for_confirmation', + confirmation_type: 'elicitation', + detail: [ + 'input request: Choose a deployment target', + 'Your choice is required before continuing.', + 'Select where GitHub Copilot should deploy the preview.', + 'choices: Continue; Cancel', + ].join('\n'), + }, + { + state: 'waiting_for_confirmation', + confirmation_type: 'generic', + detail: [ + 'confirmation: Install recommended extensions?', + 'This installs the extensions shown in the open approval.', + 'choices: Install; Cancel', + ].join('\n'), + }, + ]); + }); + + test('uses visible tool confirmation messages instead of hidden parameters', () => { + const controller = createController(new TestVoiceClientService()); + const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { state: string; detail?: string }; + const toolState = observableValue<IChatToolInvocation.State>('toolState', { + type: IChatToolInvocation.StateKind.WaitingForConfirmation, + parameters: { + command: 'hidden-internal-command', + explanation: 'hidden-internal-explanation', + }, + confirmationMessages: { + title: new MarkdownString('Run the workspace build?'), + message: 'This runs the build task shown in the approval.', + }, + confirm: () => { }, + }); + const toolInvocation = new class extends mock<IChatToolInvocation>() { + override readonly kind = 'toolInvocation' as const; + override readonly state = toolState; + override readonly invocationMessage = 'Run the workspace build'; + }(); + const stateInfo = getAgentStateInfo.call(controller, pendingResponsePartModel(URI.parse('chat-session:/tool'), toolInvocation)); + + assert.deepStrictEqual({ + stateInfo, + containsHiddenParameters: stateInfo.detail?.includes('hidden-internal'), + }, { + stateInfo: { + state: 'waiting_for_confirmation', + confirmation_type: 'tool', + detail: [ + 'tool approval: Run the workspace build?', + 'This runs the build task shown in the approval.', + ].join('\n'), + }, + containsHiddenParameters: false, + }); + }); + + test('narrates authentication using the visible server name without hidden server metadata', () => { + const controller = createController(new TestVoiceClientService()); + const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { state: string; detail?: string }; + const authenticationState = observableValue<IChatToolInvocation.State>('authenticationState', { + type: IChatToolInvocation.StateKind.WaitingForAuthentication, + parameters: { hiddenParameter: 'secret-internal-value' }, + confirmationMessages: undefined, + confirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, + server: { + id: 'hidden-server-id', + name: 'Mars Data MCP', + resource: 'hidden-server-resource', + }, + cancel: () => { }, + }); + const toolInvocation = new class extends mock<IChatToolInvocation>() { + override readonly kind = 'toolInvocation' as const; + override readonly state = authenticationState; + override readonly invocationMessage = 'Authenticate the Mars data server'; + }(); + const stateInfo = getAgentStateInfo.call(controller, pendingResponsePartModel(URI.parse('chat-session:/authentication'), toolInvocation, 'Authenticate Mars Data MCP to continue...')); + + assert.deepStrictEqual({ + stateInfo, + containsHiddenMetadata: ['hidden-server-id', 'hidden-server-resource', 'secret-internal-value'] + .some(value => stateInfo.detail?.includes(value)), + }, { + stateInfo: { + state: 'waiting_for_confirmation', + confirmation_type: 'generic', + detail: [ + 'authentication request: MCP authentication required', + 'The MCP server Mars Data MCP requires authentication to continue this tool call.', + 'choices: Authenticate; Cancel', + ].join('\n'), + }, + containsHiddenMetadata: false, + }); + }); + + test('does not watch progress when agent progress is not enabled', () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController( + voiceClientService, + undefined, + undefined, + undefined, + undefined, + new TestConfigurationService({ 'agents.voice.handsFree': false }), + ); + const sessionResource = URI.parse('chat-session:/disabled-progress'); + const { changeEmitter, parts, response } = createVoiceProgressResponse('response-disabled'); + const isConnected = Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const watchVoiceProgress = Reflect.get(controller, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + + isConnected.set(true, undefined); + controller.setActiveSessionShown(sessionResource); + watchVoiceProgress.call(controller, sessionResource, response); + parts.push({ kind: 'voiceProgress', id: 'investigating', value: 'Investigating the relevant code.' }); + changeEmitter.fire({ reason: 'other' }); + clock.tick(10_000); + + assert.deepStrictEqual(voiceClientService.requests, []); + }); + + test('marks voice requests only when agent progress is enabled', async () => { + const disabledChatService = new TestChatService(); + const disabledController = createController( + new TestVoiceClientService(), + undefined, + undefined, + undefined, + undefined, + new TestConfigurationService({ 'agents.voice.handsFree': false }), + disabledChatService, + ); + const enabledChatService = new TestChatService(); + const enabledController = createController( + new TestVoiceClientService(), + undefined, + undefined, + undefined, + undefined, + new TestConfigurationService({ 'agents.voice.handsFree': false, [VOICE_AGENT_PROGRESS_SETTING]: true }), + enabledChatService, + ); + const sendVoiceRequest = Reflect.get(disabledController, '_sendVoiceRequest') as (resource: URI, text: string) => Promise<ChatSendResult | undefined>; + + await sendVoiceRequest.call(disabledController, URI.parse('chat-session:/disabled'), 'Check the code.'); + await sendVoiceRequest.call(enabledController, URI.parse('chat-session:/enabled'), 'Check the code.'); + + assert.deepStrictEqual({ + disabled: disabledChatService.sendRequestOptions[0]?.isVoiceModeInput, + enabled: enabledChatService.sendRequestOptions[0]?.isVoiceModeInput, + }, { + disabled: false, + enabled: true, + }); + }); + + test('delays, coalesces, and preserves throttled voice progress for the shown request', () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionResource = URI.parse('chat-session:/voice-progress'); + const { changeEmitter, parts, response } = createVoiceProgressResponse('response-1'); + const isConnected = Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const watchVoiceProgress = Reflect.get(controller, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + const sessionKey = (Reflect.get(controller, '_sessionKey') as (sessionId: string) => string).call(controller, sessionResource.toString()); + const lastSpokenAt = Reflect.get(controller, '_lastSpokenAtBySession') as Map<string, number>; + + isConnected.set(true, undefined); + controller.setActiveSessionShown(sessionResource); + watchVoiceProgress.call(controller, sessionResource, response); + ttsPlaybackService.playAudioChunk('ack'); + Reflect.set(controller, '_currentPlaybackSessionId', sessionResource.toString()); + Reflect.set(controller, '_currentPlaybackResponseId', 'ack-response'); + parts.push({ kind: 'voiceProgress', id: 'investigating', value: 'Investigating the relevant code.' }); + parts.push({ kind: 'voiceProgress', id: 'editing', value: 'Updating the code.' }); + changeEmitter.fire({ reason: 'other' }); + clock.tick(4_000); + parts.push({ kind: 'voiceProgress', id: 'validating', value: 'Validating the changes.' }); + changeEmitter.fire({ reason: 'other' }); + clock.tick(1_000); + assert.strictEqual(voiceClientService.requests.length, 0); + lastSpokenAt.set(sessionKey, Date.now()); + ttsPlaybackService.stopPlayback(); + clock.tick(4_999); + assert.strictEqual(voiceClientService.requests.length, 0); + clock.tick(1); + + parts.push({ kind: 'voiceProgress', id: 'recovering', value: 'Trying a different approach.' }); + changeEmitter.fire({ reason: 'other' }); + clock.tick(9_999); + assert.strictEqual(voiceClientService.requests.length, 1); + clock.tick(1); + + assert.deepStrictEqual(voiceClientService.requests.map(request => ({ + kind: request.kind, + text: request.text, + checkpoint: request.checkpoint, + })), [ + { + kind: 'checkpoint', + text: 'Validating the changes.', + checkpoint: { requestId: 'request-response-1', checkpointId: 'validating', sequence: 1 }, + }, + { + kind: 'checkpoint', + text: 'Trying a different approach.', + checkpoint: { requestId: 'request-response-1', checkpointId: 'recovering', sequence: 2 }, + }, + ]); + }); + + test('sends the first semantic checkpoint after five seconds without prior speech', () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionResource = URI.parse('chat-session:/initial-progress-delay'); + const { changeEmitter, parts, response } = createVoiceProgressResponse('response-initial-delay'); + const isConnected = Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const watchVoiceProgress = Reflect.get(controller, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + + isConnected.set(true, undefined); + controller.setActiveSessionShown(sessionResource); + watchVoiceProgress.call(controller, sessionResource, response); + parts.push({ kind: 'voiceProgress', id: 'editing', value: 'Updating the code.' }); + changeEmitter.fire({ reason: 'other' }); + clock.tick(4_999); + assert.strictEqual(voiceClientService.requests.length, 0); + clock.tick(1); + + assert.deepStrictEqual(voiceClientService.requests.map(request => request.checkpoint), [{ + requestId: 'request-response-initial-delay', + checkpointId: 'editing', + sequence: 1, + }]); + }); + + test('schedules all five semantic stages once at the existing cadence', () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionResource = URI.parse('chat-session:/five-progress-stages'); + const { changeEmitter, parts, response } = createVoiceProgressResponse('response-five-stages'); + const isConnected = Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const watchVoiceProgress = Reflect.get(controller, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + const stages = ['investigating', 'planning', 'editing', 'validating', 'recovering'] as const; + + isConnected.set(true, undefined); + controller.setActiveSessionShown(sessionResource); + watchVoiceProgress.call(controller, sessionResource, response); + for (const [index, stage] of stages.entries()) { + parts.push({ kind: 'voiceProgress', id: stage, value: `${stage} update` }); + changeEmitter.fire({ reason: 'other' }); + clock.tick(index === 0 ? 5_000 : 10_000); + } + parts.push({ kind: 'voiceProgress', id: 'recovering', value: 'duplicate recovery' }); + changeEmitter.fire({ reason: 'other' }); + clock.tick(10_000); + + assert.deepStrictEqual(voiceClientService.requests.map(request => ({ + text: request.text, + checkpoint: request.checkpoint, + })), stages.map((stage, index) => ({ + text: `${stage} update`, + checkpoint: { + requestId: 'request-response-five-stages', + checkpointId: stage, + sequence: index + 1, + }, + }))); + }); + + test('final response cancels pending voice progress', () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionResource = URI.parse('chat-session:/final-cancels-progress'); + const { changeEmitter, parts, response } = createVoiceProgressResponse('response-final'); + const isConnected = Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const watchVoiceProgress = Reflect.get(controller, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as (sessionId: string, state: string, detail: string | undefined, summary: string | undefined, shown: string) => void; + + isConnected.set(true, undefined); + controller.setActiveSessionShown(sessionResource); + watchVoiceProgress.call(controller, sessionResource, response); + parts.push({ kind: 'voiceProgress', id: 'editing', value: 'Updating the code.' }); + changeEmitter.fire({ reason: 'other' }); + handleStateChange.call(controller, sessionResource.toString(), 'idle', undefined, 'Finished successfully.', sessionResource.toString()); + clock.tick(5_000); + + assert.deepStrictEqual(voiceClientService.requests.map(request => request.kind), ['response']); + }); + + test('confirmation cancels pending voice progress', () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionResource = URI.parse('chat-session:/confirmation-cancels-progress'); + const { changeEmitter, parts, response } = createVoiceProgressResponse('response-confirmation'); + const isConnected = Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const watchVoiceProgress = Reflect.get(controller, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as (sessionId: string, state: string, detail: string | undefined, summary: string | undefined, shown: string) => void; + + isConnected.set(true, undefined); + controller.setActiveSessionShown(sessionResource); + watchVoiceProgress.call(controller, sessionResource, response); + parts.push({ kind: 'voiceProgress', id: 'validating', value: 'Validating the changes.' }); + changeEmitter.fire({ reason: 'other' }); + handleStateChange.call(controller, sessionResource.toString(), 'waiting_for_confirmation', 'Approve the command.', undefined, sessionResource.toString()); + clock.tick(5_000); + + assert.deepStrictEqual(voiceClientService.requests.map(request => request.kind), ['confirmation']); + }); + + test('request cancellation and disconnect cancel pending voice progress', () => { + const firstVoiceClient = new TestVoiceClientService(); + const firstController = createController(firstVoiceClient); + const firstSession = URI.parse('chat-session:/cancelled-progress'); + const firstResponse = createVoiceProgressResponse('response-cancelled'); + const firstConnected = Reflect.get(firstController, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const firstWatch = Reflect.get(firstController, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + + firstConnected.set(true, undefined); + firstController.setActiveSessionShown(firstSession); + firstWatch.call(firstController, firstSession, firstResponse.response); + firstResponse.parts.push({ kind: 'voiceProgress', id: 'editing', value: 'Updating the code.' }); + firstResponse.changeEmitter.fire({ reason: 'other' }); + firstController.markUserCancelled(firstSession.toString()); + + const secondVoiceClient = new TestVoiceClientService(); + const secondController = createController(secondVoiceClient); + const secondSession = URI.parse('chat-session:/disconnected-progress'); + const secondResponse = createVoiceProgressResponse('response-disconnected'); + const secondConnected = Reflect.get(secondController, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const secondWatch = Reflect.get(secondController, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + + secondConnected.set(true, undefined); + secondController.setActiveSessionShown(secondSession); + secondWatch.call(secondController, secondSession, secondResponse.response); + secondResponse.parts.push({ kind: 'voiceProgress', id: 'recovering', value: 'Trying another approach.' }); + secondResponse.changeEmitter.fire({ reason: 'other' }); + secondController.disconnect('explicit'); + clock.tick(5_000); + + assert.deepStrictEqual({ + cancelledRequests: firstVoiceClient.requests, + disconnectedRequests: secondVoiceClient.requests, + }, { + cancelledRequests: [], + disconnectedRequests: [], + }); + }); + + test('transient disconnect retains the latest pending checkpoint until reconnect', () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionResource = URI.parse('chat-session:/reconnect-progress'); + const { changeEmitter, parts, response } = createVoiceProgressResponse('response-reconnect'); + const isConnected = Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const watchVoiceProgress = Reflect.get(controller, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + + isConnected.set(true, undefined); + controller.setActiveSessionShown(sessionResource); + watchVoiceProgress.call(controller, sessionResource, response); + parts.push({ kind: 'voiceProgress', id: 'editing', value: 'Updating the code.' }); + changeEmitter.fire({ reason: 'other' }); + clock.tick(4_000); + isConnected.set(false, undefined); + clock.tick(1_000); + assert.strictEqual(voiceClientService.requests.length, 0); + isConnected.set(true, undefined); + + assert.deepStrictEqual(voiceClientService.requests.map(request => request.checkpoint), [{ + requestId: 'request-response-reconnect', + checkpointId: 'editing', + sequence: 1, + }]); + }); + + test('a new voice request cancels only the shown session checkpoint', () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const shownSession = URI.parse('chat-session:/shown-progress'); + const backgroundSession = URI.parse('chat-session:/background-progress'); + const shownResponse = createVoiceProgressResponse('response-shown'); + const backgroundResponse = createVoiceProgressResponse('response-background'); + const isConnected = Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const watchVoiceProgress = Reflect.get(controller, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + + isConnected.set(true, undefined); + controller.setActiveSessionShown(shownSession); + watchVoiceProgress.call(controller, shownSession, shownResponse.response); + watchVoiceProgress.call(controller, backgroundSession, backgroundResponse.response); + shownResponse.parts.push({ kind: 'voiceProgress', id: 'editing', value: 'Updating shown code.' }); + backgroundResponse.parts.push({ kind: 'voiceProgress', id: 'validating', value: 'Validating background code.' }); + shownResponse.changeEmitter.fire({ reason: 'other' }); + backgroundResponse.changeEmitter.fire({ reason: 'other' }); + controller.pttDown('explicit'); + controller.setActiveSessionShown(backgroundSession); + clock.tick(5_000); + + assert.deepStrictEqual(voiceClientService.requests.map(request => request.checkpoint?.requestId), ['request-response-background']); + }); + + test('barge-in and a new explicit voice request cancel pending voice progress', () => { + const bargeVoiceClient = new TestVoiceClientService(); + const bargeController = createController(bargeVoiceClient); + const bargeSession = URI.parse('chat-session:/barge-progress'); + const bargeResponse = createVoiceProgressResponse('response-barge'); + const bargeConnected = Reflect.get(bargeController, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const bargeWatch = Reflect.get(bargeController, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + const handleBargeIn = Reflect.get(bargeController, '_handleBargeIn') as (event: IVoiceBargeIn) => void; + + bargeConnected.set(true, undefined); + bargeController.setActiveSessionShown(bargeSession); + bargeWatch.call(bargeController, bargeSession, bargeResponse.response); + bargeResponse.parts.push({ kind: 'voiceProgress', id: 'editing', value: 'Updating the code.' }); + bargeResponse.changeEmitter.fire({ reason: 'other' }); + handleBargeIn.call(bargeController, { turnId: 'new-turn', interruptedTurnId: 'old-turn' }); + + const pttVoiceClient = new TestVoiceClientService(); + const pttController = createController(pttVoiceClient); + const pttSession = URI.parse('chat-session:/ptt-progress'); + const pttResponse = createVoiceProgressResponse('response-ptt'); + const pttConnected = Reflect.get(pttController, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const pttWatch = Reflect.get(pttController, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + + pttConnected.set(true, undefined); + pttController.setActiveSessionShown(pttSession); + pttWatch.call(pttController, pttSession, pttResponse.response); + pttResponse.parts.push({ kind: 'voiceProgress', id: 'validating', value: 'Validating the changes.' }); + pttResponse.changeEmitter.fire({ reason: 'other' }); + pttController.pttDown('explicit'); + clock.tick(5_000); + + assert.deepStrictEqual({ + bargeRequests: bargeVoiceClient.requests, + pttRequests: pttVoiceClient.requests, + }, { + bargeRequests: [], + pttRequests: [], + }); + }); + + test('busy, invalid, and legacy suppressed checkpoints are never retried', () => { + const dispositions = ['busy', 'invalid', 'suppressed'] as const; + const results: boolean[] = []; + for (const disposition of dispositions) { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionId = `chat-session:/${disposition}`; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + const handleAck = Reflect.get(controller, '_handleNarrationAck') as (event: IVoiceNarrationAck) => void; + const retryDeferred = Reflect.get(controller, '_retryDeferredNarration') as (sessionKey: string, narrationId?: string) => boolean; + const sessionKey = (Reflect.get(controller, '_sessionKey') as (sessionId: string) => string).call(controller, sessionId); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: `request-${disposition}`, + checkpointId: 'editing', + sequence: 1, + }); + const request = voiceClientService.requests[0]; + handleAck.call(controller, { + narrationId: request.narrationId, + codingSessionId: sessionId, + disposition, + }); + results.push(retryDeferred.call(controller, sessionKey, request.narrationId)); + } + + assert.deepStrictEqual(results, [false, false, false]); + }); + + test('active checkpoint playback is preempted when final response audio starts', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionId = 'chat-session:/checkpoint-final'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const checkpointId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'checkpoint', + isFirstChunk: true, + isFinal: false, + codingSessionId: sessionId, + responseId: checkpointId, + }); + narrate.call(controller, sessionId, 'response', 'Everything is complete.'); + assert.strictEqual(ttsPlaybackService.stopCount, 0); + const finalId = voiceClientService.requests[1].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'final', + isFirstChunk: true, + isFinal: true, + codingSessionId: sessionId, + responseId: finalId, + }); + voiceClientService.fireAudioResponse({ + audio: 'stale-checkpoint', + isFirstChunk: false, + isFinal: true, + codingSessionId: sessionId, + responseId: checkpointId, + }); + + assert.deepStrictEqual({ + stopCount: ttsPlaybackService.stopCount, + playedAudio: ttsPlaybackService.playedAudio, + playbackCompletions: voiceClientService.playbackCompletions, + }, { + stopCount: 1, + playedAudio: ['checkpoint', 'final'], + playbackCompletions: [], + }); + }); + + test('empty final response does not preempt active checkpoint playback', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionId = 'chat-session:/checkpoint-empty-response'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const checkpointId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'checkpoint', + isFirstChunk: true, + isFinal: false, + codingSessionId: sessionId, + responseId: checkpointId, + }); + narrate.call(controller, sessionId, 'response', 'Progress-only final summary.'); + const responseId = voiceClientService.requests[1].narrationId; + voiceClientService.fireAudioResponse({ + audio: '', + isFirstChunk: true, + isFinal: true, + codingSessionId: sessionId, + responseId, + }); + + assert.deepStrictEqual({ + stopCount: ttsPlaybackService.stopCount, + playedAudio: ttsPlaybackService.playedAudio, + isPlaying: ttsPlaybackService.isPlaying, + }, { + stopCount: 0, + playedAudio: ['checkpoint'], + isPlaying: true, + }); + }); + + test('completed checkpoint playback acknowledges the correlated playback id', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionId = 'chat-session:/checkpoint-complete'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const narrationId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'checkpoint', + isFirstChunk: true, + isFinal: true, + codingSessionId: sessionId, + responseId: narrationId, + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + narrationKind: 'checkpoint', + playbackId: 'playback-1', + }); + ttsPlaybackService.stopPlayback(); + + assert.deepStrictEqual(voiceClientService.playbackCompletions, [{ + sessionId, + narrationId, + playbackId: 'playback-1', + }]); + }); + + test('dropped re-narration does not preempt active checkpoint playback', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionId = 'chat-session:/checkpoint-reread'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + const recentlyRead = Reflect.get(controller, '_recentlyReadResponse') as Map<string, { transcript: string; at: number }>; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const checkpointId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'checkpoint', + isFirstChunk: true, + isFinal: false, + codingSessionId: sessionId, + responseId: checkpointId, + }); + recentlyRead.set(sessionId, { transcript: 'already heard', at: Date.now() }); + voiceClientService.fireAudioResponse({ + audio: 'duplicate', + isFirstChunk: true, + isFinal: true, + codingSessionId: sessionId, + responseId: 'duplicate-response', + transcript: 'Already heard.', + }); + + assert.deepStrictEqual({ + stopCount: ttsPlaybackService.stopCount, + playedAudio: ttsPlaybackService.playedAudio, + }, { + stopCount: 0, + playedAudio: ['checkpoint'], + }); + }); + + test('active checkpoint playback is preempted by confirmation', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionId = 'chat-session:/checkpoint-confirmation'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Validating the changes.', undefined, { + requestId: 'request-1', + checkpointId: 'validating', + sequence: 1, + }); + const checkpointId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'checkpoint', + isFirstChunk: true, + isFinal: false, + codingSessionId: sessionId, + responseId: checkpointId, + }); + narrate.call(controller, sessionId, 'confirmation', 'Approve the command.'); + const confirmationId = voiceClientService.requests[1].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'confirmation', + isFirstChunk: true, + isFinal: true, + codingSessionId: sessionId, + responseId: confirmationId, + }); + + assert.deepStrictEqual({ + stopCount: ttsPlaybackService.stopCount, + playedAudio: ttsPlaybackService.playedAudio, + }, { + stopCount: 1, + playedAudio: ['checkpoint', 'confirmation'], + }); + }); + + test('direct substantive audio preempts active checkpoint playback', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionId = 'chat-session:/checkpoint-direct-reply'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const checkpointId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'checkpoint', + isFirstChunk: true, + isFinal: false, + codingSessionId: sessionId, + responseId: checkpointId, + }); + voiceClientService.fireAudioResponse({ + audio: 'direct-reply', + isFirstChunk: true, + isFinal: true, + codingSessionId: sessionId, + responseId: 'direct-response', + transcript: 'Here is the substantive result.', + }); + + assert.deepStrictEqual({ + stopCount: ttsPlaybackService.stopCount, + playedAudio: ttsPlaybackService.playedAudio, + }, { + stopCount: 1, + playedAudio: ['checkpoint', 'direct-reply'], + }); + }); + + test('cross-session substantive audio preempts active checkpoint playback', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const checkpointSessionId = 'chat-session:/checkpoint-background'; + const responseSessionId = 'chat-session:/response-foreground'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(checkpointSessionId)); + + narrate.call(controller, checkpointSessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const checkpointId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'checkpoint', + isFirstChunk: true, + isFinal: false, + codingSessionId: checkpointSessionId, + responseId: checkpointId, + }); + controller.setActiveSessionShown(URI.parse(responseSessionId)); + voiceClientService.fireAudioResponse({ + audio: 'substantive-response', + isFirstChunk: true, + isFinal: true, + codingSessionId: responseSessionId, + responseId: 'direct-response', + transcript: 'The foreground task is complete.', + }); + + assert.deepStrictEqual({ + stopCount: ttsPlaybackService.stopCount, + playedAudio: ttsPlaybackService.playedAudio, + }, { + stopCount: 1, + playedAudio: ['checkpoint', 'substantive-response'], + }); + }); + + test('newer checkpoint preempts active older checkpoint and discards stale chunks', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionId = 'chat-session:/checkpoint-replacement'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const firstId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'editing', + isFirstChunk: true, + isFinal: false, + codingSessionId: sessionId, + responseId: firstId, + }); + narrate.call(controller, sessionId, 'checkpoint', 'Validating the result.', undefined, { + requestId: 'request-1', + checkpointId: 'validating', + sequence: 2, + }); + const secondId = voiceClientService.requests[1].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'stale-editing', + isFirstChunk: false, + isFinal: true, + codingSessionId: sessionId, + responseId: firstId, + }); + voiceClientService.fireAudioResponse({ + audio: 'validating', + isFirstChunk: true, + isFinal: true, + codingSessionId: sessionId, + responseId: secondId, + }); + + assert.deepStrictEqual(ttsPlaybackService.playedAudio, ['editing', 'validating']); + }); + + test('cross-session checkpoint replaces active checkpoint', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const firstSessionId = 'chat-session:/checkpoint-first-session'; + const secondSessionId = 'chat-session:/checkpoint-second-session'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(firstSessionId)); + + narrate.call(controller, firstSessionId, 'checkpoint', 'Updating the first task.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const firstId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'first-checkpoint', + isFirstChunk: true, + isFinal: false, + codingSessionId: firstSessionId, + responseId: firstId, + }); + narrate.call(controller, secondSessionId, 'checkpoint', 'Validating the second task.', undefined, { + requestId: 'request-2', + checkpointId: 'validating', + sequence: 1, + }); + const secondId = voiceClientService.requests[1].narrationId; + controller.setActiveSessionShown(URI.parse(secondSessionId)); + voiceClientService.fireAudioResponse({ + audio: 'second-checkpoint', + isFirstChunk: true, + isFinal: true, + codingSessionId: secondSessionId, + responseId: secondId, + }); + + assert.deepStrictEqual({ + stopCount: ttsPlaybackService.stopCount, + playedAudio: ttsPlaybackService.playedAudio, + }, { + stopCount: 1, + playedAudio: ['first-checkpoint', 'second-checkpoint'], + }); + }); + + test('pre-decode checkpoint preemption does not poison replacement completion', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new DeferredFirstTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionId = 'chat-session:/checkpoint-predecode'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const firstId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'decoding-checkpoint', + isFirstChunk: true, + isFinal: true, + codingSessionId: sessionId, + responseId: firstId, + narrationKind: 'checkpoint', + playbackId: 'playback-1', + }); + narrate.call(controller, sessionId, 'checkpoint', 'Validating the result.', undefined, { + requestId: 'request-1', + checkpointId: 'validating', + sequence: 2, + }); + const secondId = voiceClientService.requests[1].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'replacement-checkpoint', + isFirstChunk: true, + isFinal: true, + codingSessionId: sessionId, + responseId: secondId, + narrationKind: 'checkpoint', + playbackId: 'playback-2', + }); + ttsPlaybackService.stopPlayback(); + + assert.deepStrictEqual({ + stopCount: ttsPlaybackService.stopCount, + playbackCompletions: voiceClientService.playbackCompletions, + }, { + stopCount: 2, + playbackCompletions: [{ sessionId, narrationId: secondId, playbackId: 'playback-2' }], + }); + }); + + test('scheduled newer checkpoint replaces active checkpoint at the cadence boundary', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionResource = URI.parse('chat-session:/scheduled-checkpoint-replacement'); + const { changeEmitter, parts, response } = createVoiceProgressResponse('response-scheduled-replacement'); + const isConnected = Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const watchVoiceProgress = Reflect.get(controller, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + + await controller.connect(mainWindow); + isConnected.set(true, undefined); + controller.setActiveSessionShown(sessionResource); + watchVoiceProgress.call(controller, sessionResource, response); + parts.push({ kind: 'voiceProgress', id: 'editing', value: 'Updating the code.' }); + changeEmitter.fire({ reason: 'other' }); + clock.tick(5_000); + const firstId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'editing', + isFirstChunk: true, + isFinal: false, + codingSessionId: sessionResource.toString(), + responseId: firstId, + }); + + parts.push({ kind: 'voiceProgress', id: 'validating', value: 'Validating the result.' }); + changeEmitter.fire({ reason: 'other' }); + clock.tick(10_000); + const secondId = voiceClientService.requests[1].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'stale-editing', + isFirstChunk: false, + isFinal: true, + codingSessionId: sessionResource.toString(), + responseId: firstId, + }); + voiceClientService.fireAudioResponse({ + audio: 'validating', + isFirstChunk: true, + isFinal: true, + codingSessionId: sessionResource.toString(), + responseId: secondId, + }); + + assert.deepStrictEqual({ + checkpoints: voiceClientService.requests.map(request => request.checkpoint), + stopCount: ttsPlaybackService.stopCount, + playedAudio: ttsPlaybackService.playedAudio, + }, { + checkpoints: [ + { requestId: 'request-response-scheduled-replacement', checkpointId: 'editing', sequence: 1 }, + { requestId: 'request-response-scheduled-replacement', checkpointId: 'validating', sequence: 2 }, + ], + stopCount: 1, + playedAudio: ['editing', 'validating'], + }); + }); + + test('request cancellation preempts active checkpoint playback and discards trailing chunks', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionId = 'chat-session:/active-checkpoint-cancellation'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const narrationId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'checkpoint', + isFirstChunk: true, + isFinal: false, + codingSessionId: sessionId, + responseId: narrationId, + }); + controller.markUserCancelled(sessionId); + voiceClientService.fireAudioResponse({ + audio: 'stale-checkpoint', + isFirstChunk: false, + isFinal: true, + codingSessionId: sessionId, + responseId: narrationId, + }); + + assert.deepStrictEqual({ + stopCount: ttsPlaybackService.stopCount, + playedAudio: ttsPlaybackService.playedAudio, + }, { + stopCount: 1, + playedAudio: ['checkpoint'], + }); + }); + + test('explicit PTT retires checkpoint tracking before clearing playback correlation', async () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionId = 'chat-session:/checkpoint-ptt-tracking'; + const isConnected = Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + isConnected.set(true, undefined); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const firstId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'checkpoint', + isFirstChunk: true, + isFinal: false, + codingSessionId: sessionId, + responseId: firstId, + }); + controller.pttDown('explicit'); + const sentNextCheckpoint = narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-2', + checkpointId: 'editing', + sequence: 1, + }); + + assert.deepStrictEqual({ + sentNextCheckpoint, + requestIds: voiceClientService.requests.map(request => request.checkpoint?.requestId), + }, { + sentNextCheckpoint: true, + requestIds: ['request-1', 'request-2'], + }); + }); + + test('barge-in stops active checkpoint playback and discards trailing chunks', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionId = 'chat-session:/checkpoint-barge'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + const handleBargeIn = Reflect.get(controller, '_handleBargeIn') as (event: IVoiceBargeIn) => void; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const checkpointId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'checkpoint', + isFirstChunk: true, + isFinal: false, + codingSessionId: sessionId, + responseId: checkpointId, + turnId: 'checkpoint-turn', + }); + handleBargeIn.call(controller, { turnId: 'user-turn', interruptedTurnId: checkpointId }); + voiceClientService.fireAudioResponse({ + audio: 'stale-checkpoint', + isFirstChunk: false, + isFinal: true, + codingSessionId: sessionId, + responseId: checkpointId, + turnId: 'checkpoint-turn', + }); + + assert.deepStrictEqual({ + stopCount: ttsPlaybackService.stopCount, + playedAudio: ttsPlaybackService.playedAudio, + }, { + stopCount: 1, + playedAudio: ['checkpoint'], + }); + }); + + test('backend interruption stops only the matching active checkpoint', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionId = 'chat-session:/checkpoint-server-interruption'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const narrationId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'checkpoint', + isFirstChunk: true, + isFinal: false, + codingSessionId: sessionId, + responseId: narrationId, + }); + voiceClientService.fireNarrationInterrupted({ + narrationId, + codingSessionId: sessionId, + retryable: false, + reason: 'superseded_by_response', + }); + voiceClientService.fireAudioResponse({ + audio: 'stale-checkpoint', + isFirstChunk: false, + isFinal: true, + codingSessionId: sessionId, + responseId: narrationId, + }); + + assert.deepStrictEqual({ + stopCount: ttsPlaybackService.stopCount, + playedAudio: ttsPlaybackService.playedAudio, + playbackCompletions: voiceClientService.playbackCompletions, + }, { + stopCount: 1, + playedAudio: ['checkpoint'], + playbackCompletions: [], + }); + }); + + test('late backend interruption does not stop a replacement checkpoint', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionId = 'chat-session:/checkpoint-late-server-interruption'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const firstId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'first-checkpoint', + isFirstChunk: true, + isFinal: false, + codingSessionId: sessionId, + responseId: firstId, + }); + narrate.call(controller, sessionId, 'checkpoint', 'Validating the result.', undefined, { + requestId: 'request-1', + checkpointId: 'validating', + sequence: 2, + }); + const secondId = voiceClientService.requests[1].narrationId; + voiceClientService.fireAudioResponse({ + audio: 'second-checkpoint', + isFirstChunk: true, + isFinal: true, + codingSessionId: sessionId, + responseId: secondId, + narrationKind: 'checkpoint', + playbackId: 'playback-2', + }); + voiceClientService.fireNarrationInterrupted({ + narrationId: firstId, + codingSessionId: sessionId, + retryable: false, + reason: 'superseded_by_checkpoint', + }); + ttsPlaybackService.stopPlayback(); + + assert.deepStrictEqual({ + stopCount: ttsPlaybackService.stopCount, + playbackCompletions: voiceClientService.playbackCompletions, + }, { + stopCount: 2, + playbackCompletions: [{ sessionId, narrationId: secondId, playbackId: 'playback-2' }], + }); + }); + + test('checkpoint sequence restarts for the next chat request', () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionResource = URI.parse('chat-session:/sequence-reset'); + const first = createVoiceProgressResponse('response-sequence-1', 'request-1'); + const second = createVoiceProgressResponse('response-sequence-2', 'request-2'); + const isConnected = Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }; + const watchVoiceProgress = Reflect.get(controller, '_watchVoiceProgress') as (resource: URI, response: IChatResponseModel) => void; + const handleAck = Reflect.get(controller, '_handleNarrationAck') as (event: IVoiceNarrationAck) => void; + + isConnected.set(true, undefined); + controller.setActiveSessionShown(sessionResource); + watchVoiceProgress.call(controller, sessionResource, first.response); + first.parts.push({ kind: 'voiceProgress', id: 'editing', value: 'Updating the first request.' }); + first.changeEmitter.fire({ reason: 'other' }); + clock.tick(5_000); + handleAck.call(controller, { + narrationId: voiceClientService.requests[0].narrationId, + codingSessionId: sessionResource.toString(), + disposition: 'suppressed', + }); + first.state.isComplete = true; + first.changeEmitter.fire({ reason: 'other' }); + + watchVoiceProgress.call(controller, sessionResource, second.response); + second.parts.push({ kind: 'voiceProgress', id: 'validating', value: 'Validating the second request.' }); + second.changeEmitter.fire({ reason: 'other' }); + clock.tick(5_000); + + assert.deepStrictEqual(voiceClientService.requests.map(request => request.checkpoint), [ + { requestId: 'request-1', checkpointId: 'editing', sequence: 1 }, + { requestId: 'request-2', checkpointId: 'validating', sequence: 1 }, + ]); + }); + + test('first-and-final empty checkpoint clears without acknowledging playback', async () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionId = 'chat-session:/checkpoint-empty-final'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const narrationId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: '', + isFirstChunk: true, + isFinal: true, + codingSessionId: sessionId, + responseId: narrationId, + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + narrationKind: 'checkpoint', + playbackId: 'playback-empty', + }); + + assert.deepStrictEqual({ + pending: [...(Reflect.get(controller, '_pendingSolicitedNarrations') as Map<string, unknown>).keys()], + deferred: [...(Reflect.get(controller, '_deferredNarrations') as Map<string, unknown>).keys()], + playbackCompletions: voiceClientService.playbackCompletions, + }, { + pending: [], + deferred: [], + playbackCompletions: [], + }); + }); + + test('empty checkpoint terminal without playback id clears without acknowledgement', async () => { + const voiceClientService = new TestVoiceClientService(); + const controller = createController(voiceClientService); + const sessionId = 'chat-session:/checkpoint-empty-final-no-playback'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const narrationId = voiceClientService.requests[0].narrationId; + voiceClientService.fireAudioResponse({ + audio: '', + isFirstChunk: true, + isFinal: true, + codingSessionId: sessionId, + responseId: narrationId, + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + narrationKind: 'checkpoint', + }); + + assert.deepStrictEqual({ + pending: [...(Reflect.get(controller, '_pendingSolicitedNarrations') as Map<string, unknown>).keys()], + playbackCompletions: voiceClientService.playbackCompletions, + }, { + pending: [], + playbackCompletions: [], + }); + }); + + test('checkpoint audio prefix followed by empty failure final acknowledges after playback drains', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + const sessionId = 'chat-session:/checkpoint-partial-failure'; + const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata) => boolean; + await controller.connect(mainWindow); + controller.setActiveSessionShown(URI.parse(sessionId)); + + narrate.call(controller, sessionId, 'checkpoint', 'Updating the code.', undefined, { + requestId: 'request-1', + checkpointId: 'editing', + sequence: 1, + }); + const narrationId = voiceClientService.requests[0].narrationId; + const correlation = { + codingSessionId: sessionId, + responseId: narrationId, + requestId: 'request-1', + checkpointId: 'editing' as const, + sequence: 1, + narrationKind: 'checkpoint' as const, + playbackId: 'playback-partial', + }; + voiceClientService.fireAudioResponse({ + ...correlation, + audio: 'checkpoint-prefix', + isFirstChunk: true, + isFinal: false, + }); + voiceClientService.fireAudioResponse({ + ...correlation, + audio: '', + isFirstChunk: false, + isFinal: true, + }); + + assert.deepStrictEqual(voiceClientService.playbackCompletions, []); + ttsPlaybackService.stopPlayback(); + assert.deepStrictEqual(voiceClientService.playbackCompletions, [{ + sessionId, + narrationId, + playbackId: 'playback-partial', + }]); + }); + test('explicit disconnect clears routing target and pending confirmations and the tracker cannot repopulate them before reconnect', () => { const voiceClientService = new TestVoiceClientService(); const chatService = new ControllableChatService(); @@ -511,7 +3417,7 @@ suite('VoiceSessionController', () => { }], }; - const payload = buildPendingPayload.call(controller, questionCarouselModel(part)); + const payload = buildPendingPayload.call(controller, pendingPartsModel(part)); assert.deepStrictEqual(payload, { type: 'questions', @@ -538,9 +3444,193 @@ suite('VoiceSessionController', () => { const buildPendingPayload = Reflect.get(controller, '_buildPendingPayload') as (model: IChatModel) => unknown; const questions = [{ id: 'region', type: 'singleSelect', title: 'Which region?', options: [{ id: 'west', label: 'West US', value: 'westus' }] }]; - assert.strictEqual(buildPendingPayload.call(controller, questionCarouselModel({ kind: 'questionCarousel', isUsed: true, questions })), undefined); - assert.strictEqual(buildPendingPayload.call(controller, questionCarouselModel({ kind: 'questionCarousel', answeredExternally: true, questions })), undefined); - assert.strictEqual(buildPendingPayload.call(controller, questionCarouselModel({ kind: 'questionCarousel', questions: [] })), undefined); + assert.strictEqual(buildPendingPayload.call(controller, pendingPartsModel({ kind: 'questionCarousel', isUsed: true, questions })), undefined); + assert.strictEqual(buildPendingPayload.call(controller, pendingPartsModel({ kind: 'questionCarousel', answeredExternally: true, questions })), undefined); + assert.strictEqual(buildPendingPayload.call(controller, pendingPartsModel({ kind: 'questionCarousel', questions: [] })), undefined); + }); + + test('selects the oldest still-open pending part, not the newest', () => { + // Voice is a serial channel: a second form arriving must not take the turn + // from the one the user was just read out and is part-way through + // answering. Oldest-first is also what the chat model itself does when it + // decides what a response is waiting on. + const controller = createController(new TestVoiceClientService()); + const selectPendingPart = Reflect.get(controller, '_selectPendingPart') as (model: IChatModel) => { requestId: string; part: { kind: string } } | undefined; + const older = { kind: 'questionCarousel', questions: [{ id: 'a', type: 'singleSelect', title: 'A?', options: [] }] }; + const newer = { kind: 'questionCarousel', questions: [{ id: 'b', type: 'singleSelect', title: 'B?', options: [] }] }; + + const selected = selectPendingPart.call(controller, pendingPartsModel([older, newer])); + + assert.strictEqual(selected?.part, older); + assert.strictEqual(selected?.requestId, 'req-1'); + }); + + test('moves on once the oldest pending part is resolved', () => { + const controller = createController(new TestVoiceClientService()); + const selectPendingPart = Reflect.get(controller, '_selectPendingPart') as (model: IChatModel) => { part: { kind: string } } | undefined; + const answered = { kind: 'questionCarousel', isUsed: true, questions: [{ id: 'a', type: 'singleSelect', title: 'A?', options: [] }] }; + const newer = { kind: 'questionCarousel', questions: [{ id: 'b', type: 'singleSelect', title: 'B?', options: [] }] }; + + assert.strictEqual(selectPendingPart.call(controller, pendingPartsModel([answered, newer]))?.part, newer); + assert.strictEqual(selectPendingPart.call(controller, pendingPartsModel([answered]))?.part, undefined); + }); + + test('an executing tool does not shadow the form it opened', () => { + // askQuestions appends its carousel from inside invoke(), so its own tool + // part is always earlier in the list. It declares no confirmationMessages + // and therefore sits in Executing, not WaitingForConfirmation - if that + // ever changed, oldest-first would publish an approval for a question form + // and the form would never reach voice. + const controller = createController(new TestVoiceClientService()); + const selectPendingPart = Reflect.get(controller, '_selectPendingPart') as (model: IChatModel) => { part: { kind: string } } | undefined; + const executingTool = { + kind: 'toolInvocation', + state: observableValue('state', { type: IChatToolInvocation.StateKind.Executing }), + }; + const carousel = { kind: 'questionCarousel', questions: [{ id: 'a', type: 'singleSelect', title: 'A?', options: [] }] }; + + assert.strictEqual(selectPendingPart.call(controller, pendingPartsModel([executingTool, carousel]))?.part, carousel); + }); + + test('keeps publishing the older form when a second one arrives', () => { + // Without this the payload flips to the newest form with no narration, so + // an answer meant for the first form is applied to the second. + const controller = createController(new TestVoiceClientService()); + const buildPendingPayload = Reflect.get(controller, '_buildPendingPayload') as (model: IChatModel) => { pending_id?: string; questions?: { id: string }[] } | undefined; + const older = { kind: 'questionCarousel', questions: [{ id: 'region', type: 'singleSelect', title: 'Which region?', options: [{ id: 'w', label: 'West US', value: 'westus' }] }] }; + const newer = { kind: 'questionCarousel', questions: [{ id: 'tier', type: 'singleSelect', title: 'Which tier?', options: [{ id: 'p', label: 'Premium', value: 'premium' }] }] }; + + const payload = buildPendingPayload.call(controller, pendingPartsModel([older, newer])); + + assert.deepStrictEqual(payload?.questions?.map(question => question.id), ['region']); + assert.strictEqual(payload?.pending_id, derivePendingId('req-1', older)); + }); + + test('payload and spoken detail name the same form when two are open', () => { + // If these two disagree, the newer form flips the detail, that counts as a + // transition, and the narration path then reads the OLDER form aloud again. + const controller = createController(new TestVoiceClientService()); + const buildPendingPayload = Reflect.get(controller, '_buildPendingPayload') as (model: IChatModel) => { questions?: { title: string }[] } | undefined; + const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { state: string; detail?: string }; + const older = { kind: 'questionCarousel', questions: [{ id: 'region', type: 'singleSelect', title: 'Which region?', options: [] }] }; + const newer = { kind: 'questionCarousel', questions: [{ id: 'tier', type: 'singleSelect', title: 'Which tier?', options: [] }] }; + const model = pendingPartsModel([older, newer], 'req-1', 'Answer questions to continue...'); + + const info = getAgentStateInfo.call(controller, model); + + assert.strictEqual(info.state, 'waiting_for_confirmation'); + assert.ok(info.detail?.includes('Which region?')); + assert.ok(!info.detail?.includes('Which tier?')); + assert.deepStrictEqual(buildPendingPayload.call(controller, model)?.questions?.map(question => question.title), ['Which region?']); + }); + + test('sends each agent session label so two waiting sessions can be told apart', () => { + // The label is the only human-readable handle the backend has. Without it + // every session is "Untitled" and naming one out loud cannot disambiguate + // which of two open forms an answer is for. + const controller = createController( + new TestVoiceClientService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, + new TestAgentSessionsService([ + agentSessionEntry('vscode-chat://a', 'Auth fix', AgentSessionStatus.NeedsInput), + agentSessionEntry('vscode-chat://b', 'Billing refactor', AgentSessionStatus.InProgress), + ]), + ); + const buildSessionContext = Reflect.get(controller, '_buildSessionContext') as () => { sessions: { id: string; label?: string }[] }; + + const labels = buildSessionContext.call(controller).sessions.map(session => session.label); + + assert.deepStrictEqual(labels, ['Auth fix', 'Billing refactor']); + }); + + test('omits the label for an unlabelled agent session rather than sending an empty one', () => { + // An empty string would render as a nameless label the model might try to + // quote back at the user; absent lets the backend fall back to "Untitled". + const controller = createController( + new TestVoiceClientService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, + new TestAgentSessionsService([agentSessionEntry('vscode-chat://a', undefined, AgentSessionStatus.NeedsInput)]), + ); + const buildSessionContext = Reflect.get(controller, '_buildSessionContext') as () => { sessions: { id: string; label?: string }[] }; + + const [session] = buildSessionContext.call(controller).sessions; + + assert.strictEqual(session.id, 'vscode-chat://a'); + assert.ok(!Object.hasOwn(session, 'label')); + }); + + test('sends the agent session label once its model is resident too', () => { + // The label is emitted from two branches - model resident or not - and a + // session flips between them as VS Code loads and disposes models. Only + // covering the unloaded branch would let the loaded one lose the label + // silently, which is exactly when a form is on screen to disambiguate. + const chatService = new ControllableChatService(); + const resource = URI.parse('vscode-chat://a'); + chatService.setModels([pendingConfirmationModel(resource)]); + const controller = createController( + new TestVoiceClientService(), undefined, undefined, undefined, undefined, undefined, chatService, undefined, + new TestAgentSessionsService([agentSessionEntry(resource.toString(), 'Auth fix', AgentSessionStatus.NeedsInput)]), + ); + const buildSessionContext = Reflect.get(controller, '_buildSessionContext') as () => { sessions: { id: string; label?: string; agent_state: string }[] }; + // Make it the active session: a background confirmation is deliberately + // downgraded to `thinking`, which would hide whether the resident branch + // ran at all. + controller.setTargetSession(resource); + + const [session] = buildSessionContext.call(controller).sessions; + + assert.strictEqual(session.agent_state, 'waiting_for_confirmation'); + assert.strictEqual(session.label, 'Auth fix'); + }); + + test('an older tool confirmation holds the turn ahead of a newer form', () => { + // Queue semantics applied uniformly: approve the command you were asked + // about, then answer the questions. + const controller = createController(new TestVoiceClientService()); + const buildPendingPayload = Reflect.get(controller, '_buildPendingPayload') as (model: IChatModel) => { type?: string } | undefined; + const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { detail?: string }; + const approval = { + kind: 'toolInvocation', + invocationMessage: 'Run a command', + state: observableValue('state', { + type: IChatToolInvocation.StateKind.WaitingForConfirmation, + parameters: { command: 'docker push myapp:latest' }, + }), + }; + const form = { kind: 'questionCarousel', questions: [{ id: 'tier', type: 'singleSelect', title: 'Which tier?', options: [] }] }; + const model = pendingPartsModel([approval, form], 'req-1', 'Run command?'); + + assert.strictEqual(buildPendingPayload.call(controller, model)?.type, 'approval'); + assert.ok(getAgentStateInfo.call(controller, model).detail?.includes('Run a command')); + }); + + test('an older confirmation suppresses a newer form payload but still speaks', () => { + // `confirmation` has no typed wire shape, so the queue costs the newer form + // its structured payload until the confirmation is resolved. Deliberate. + const controller = createController(new TestVoiceClientService()); + const buildPendingPayload = Reflect.get(controller, '_buildPendingPayload') as (model: IChatModel) => unknown; + const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { detail?: string }; + const confirmation = { kind: 'confirmation', title: 'Delete the branch?' }; + const form = { kind: 'questionCarousel', questions: [{ id: 'tier', type: 'singleSelect', title: 'Which tier?', options: [] }] }; + const model = pendingPartsModel([confirmation, form], 'req-1', 'Delete the branch?'); + + assert.strictEqual(buildPendingPayload.call(controller, model), undefined); + assert.ok(getAgentStateInfo.call(controller, model).detail?.includes('Delete the branch?')); + }); + + test('a newer form answered by mouse leaves the focused form untouched', () => { + // Resolving B out of order must not move the turn, and must not change the + // detail either - a detail change alone counts as a transition and would + // read A aloud a second time. + const controller = createController(new TestVoiceClientService()); + const buildPendingPayload = Reflect.get(controller, '_buildPendingPayload') as (model: IChatModel) => { questions?: { id: string }[] } | undefined; + const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { detail?: string }; + const older = { kind: 'questionCarousel', questions: [{ id: 'region', type: 'singleSelect', title: 'Which region?', options: [] }] }; + const newerAnswered = { kind: 'questionCarousel', isUsed: true, questions: [{ id: 'tier', type: 'singleSelect', title: 'Which tier?', options: [] }] }; + const model = pendingPartsModel([older, newerAnswered], 'req-1', 'Answer questions to continue...'); + + assert.deepStrictEqual(buildPendingPayload.call(controller, model)?.questions?.map(question => question.id), ['region']); + const detail = getAgentStateInfo.call(controller, model).detail; + assert.ok(detail?.includes('Which region?')); + assert.ok(!detail?.includes('Which tier?')); }); test('fatal disconnect clears routing target and pending confirmations and the tracker cannot repopulate them before reconnect', () => { @@ -644,6 +3734,57 @@ suite('VoiceSessionController', () => { }); }); + test('speech-started alone interrupts playback and accepts the scoped passive turn', async () => { + const voiceClientService = new TestVoiceClientService(); + const ttsPlaybackService = new TestTtsPlaybackService(); + const controller = createController(voiceClientService, ttsPlaybackService); + await controller.connect(mainWindow); + + voiceClientService.fireAudioResponse({ + audio: 'story-start', + isFirstChunk: true, + isFinal: false, + turnId: 'story-turn', + responseId: 'story-response', + }); + voiceClientService.fireSpeechStarted('follow-up-turn'); + voiceClientService.fireTranscription({ + text: 'check the repository instead', + status: 'final', + turnId: 'follow-up-turn', + revision: 1, + }); + voiceClientService.fireAudioResponse({ + audio: 'stale-story', + isFirstChunk: false, + isFinal: true, + turnId: 'story-turn', + responseId: 'story-response', + }); + voiceClientService.fireAudioResponse({ + audio: 'follow-up', + isFirstChunk: true, + isFinal: false, + turnId: 'follow-up-turn', + responseId: 'follow-up-response', + }); + + assert.deepStrictEqual({ + playedAudio: ttsPlaybackService.playedAudio, + stopCount: ttsPlaybackService.stopCount, + transcript: controller.transcriptTurns.get().at(-1), + }, { + playedAudio: ['story-start', 'follow-up'], + stopCount: 1, + transcript: { + speaker: 'user', + text: 'check the repository instead', + committed: '', + isPartial: false, + }, + }); + }); + test('stale interrupted audio does not consume follow-up latency telemetry', async () => { const voiceClientService = new TestVoiceClientService(); const telemetryService = new TestTelemetryService(); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceToolDispatchService.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceToolDispatchService.test.ts index 83c82feb83c..e17ba5ba5e2 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceToolDispatchService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceToolDispatchService.test.ts @@ -4,16 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentSessionsModel } from '../../../browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../browser/agentSessions/agentSessionsService.js'; import { VoiceToolDispatchService } from '../../../browser/voiceClient/voiceToolDispatchService.js'; -import { IChatQuestionAnswers, IChatService } from '../../../common/chatService/chatService.js'; +import { IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IChatModel } from '../../../common/model/chatModel.js'; +import { ChatPlanReviewData } from '../../../common/model/chatProgressTypes/chatPlanReviewData.js'; import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; +import { AskQuestionsToolId } from '../../../common/tools/builtinTools/askQuestionsTool.js'; import { derivePendingId, IVoiceToolCall } from '../../../common/voiceClient/voiceClientService.js'; suite('VoiceToolDispatchService - respondToSession', () => { @@ -120,6 +123,75 @@ suite('VoiceToolDispatchService - respondToSession', () => { assert.strictEqual(part.isUsed, undefined); }); + test('an approval spoken at the ask-questions tool is refused rather than applied', async () => { + const confirmations: ToolConfirmKind[] = []; + const part = new class extends mock<IChatToolInvocation>() { + override readonly kind = 'toolInvocation' as const; + override readonly toolId = AskQuestionsToolId; + override readonly state = observableValue<IChatToolInvocation.State>('state', { + type: IChatToolInvocation.StateKind.WaitingForConfirmation, + parameters: { questions: [{ question: 'Which region?', options: [{ label: 'West US' }] }] }, + confirmationMessages: { + title: 'Answer questions?', + message: 'The questionnaire is open.', + }, + confirm: reason => confirmations.push(reason.type), + }); + }(); + + const result = await serviceFor(part).respondToSession(approvalCall(part, 'approve')); + + assert.deepStrictEqual({ result, confirmations }, { + result: { ok: false, reason: 'unsupported' }, + confirmations: [], + }); + }); + + test('tool and plan confirmations remain voice-approvable', async () => { + const confirmations: ToolConfirmKind[] = []; + const tool = new class extends mock<IChatToolInvocation>() { + override readonly kind = 'toolInvocation' as const; + override readonly toolId = 'testTool'; + override readonly state = observableValue<IChatToolInvocation.State>('state', { + type: IChatToolInvocation.StateKind.WaitingForConfirmation, + parameters: {}, + confirmationMessages: { + title: 'Run the build?', + message: 'Runs the visible build task.', + }, + confirm: reason => confirmations.push(reason.type), + }); + }(); + const plan = new ChatPlanReviewData('Review plan', 'Plan body', [ + { id: 'implement', label: 'Implement Plan', default: true }, + ], true); + + const toolResult = await serviceFor(tool).respondToSession(approvalCall(tool, 'approve')); + const planResult = await serviceFor(plan).respondToSession(approvalCall(plan, 'approve')); + + assert.deepStrictEqual({ + toolResult, + confirmations, + planResult, + planData: plan.data, + planCompletion: await plan.completion.p, + }, { + toolResult: { ok: true }, + confirmations: [ToolConfirmKind.UserAction], + planResult: { ok: true }, + planData: { + action: 'Implement Plan', + actionId: 'implement', + rejected: false, + }, + planCompletion: { + action: 'Implement Plan', + actionId: 'implement', + rejected: false, + }, + }); + }); + test('a skip is refused when the form forbids it', async () => { const part = carousel(); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 84f9219555d..4f24ca2fa9a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -12,6 +12,7 @@ import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../../.. import { IChatQuestionAnswerValue, IChatQuestionCarousel } from '../../../../common/chatService/chatService.js'; import { IChatContentPartRenderContext } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { ChatQuestionCarouselData } from '../../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; +import { AgentHostAutoReplyAnswer } from '../../../../../../../platform/agentHost/common/agentHostSchema.js'; function createMockCarousel(questions: IChatQuestionCarousel['questions'], allowSkip: boolean = true): IChatQuestionCarousel { return { @@ -22,7 +23,8 @@ function createMockCarousel(questions: IChatQuestionCarousel['questions'], allow } function createMockContext(): IChatContentPartRenderContext { - return {} as IChatContentPartRenderContext; + const context: Partial<IChatContentPartRenderContext> = { content: [], contentIndex: 0 }; + return context as IChatContentPartRenderContext; } suite('ChatQuestionCarouselPart', () => { @@ -31,11 +33,12 @@ suite('ChatQuestionCarouselPart', () => { let widget: ChatQuestionCarouselPart; let submittedAnswers: Map<string, IChatQuestionAnswerValue> | undefined | null = null; - function createWidget(carousel: IChatQuestionCarousel): ChatQuestionCarouselPart { + function createWidget(carousel: IChatQuestionCarousel, onSubmit?: () => void): ChatQuestionCarouselPart { const instantiationService = workbenchInstantiationService(undefined, store); const options: IChatQuestionCarouselOptions = { onSubmit: (answers) => { submittedAnswers = answers; + onSubmit?.(); } }; widget = store.add(instantiationService.createInstance(ChatQuestionCarouselPart, carousel, createMockContext(), options)); @@ -450,6 +453,18 @@ suite('ChatQuestionCarouselPart', () => { assert.strictEqual(answer.selectedValues.length, 2); assert.strictEqual(answer.freeformValue, undefined); }); + + test('does not render a summary after onSubmit disposes the part', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Question', defaultValue: 'answer' } + ]); + createWidget(carousel, () => widget.dispose()); + + const submitButton = widget.domNode.querySelector('.chat-question-submit-button') as HTMLButtonElement; + submitButton.click(); + + assert.strictEqual(widget.domNode.querySelector('.chat-question-carousel-summary'), null); + }); }); suite('Navigation', () => { @@ -910,6 +925,78 @@ suite('ChatQuestionCarouselPart', () => { assert.ok(summaryValue?.textContent?.includes('saved answer'), 'Summary should show saved answer from data'); }); + test('renders conversational summary with expandable selected options', () => { + const carousel = new ChatQuestionCarouselData([{ + id: 'q1', + type: 'singleSelect', + title: 'What should we prioritize if the refactor affects multiple platforms and may require migration work?', + options: [ + { id: 'fix', label: 'Fix a bug', value: 'fix' }, + { id: 'feature', label: 'Implement a feature', value: 'feature' }, + ], + }], true, undefined, { q1: { selectedValue: 'fix' } }, true); + carousel.answerPresentation = 'conversation'; + createWidget(carousel.toJSON()); + + const question = widget.domNode.querySelector('.chat-question-summary-question'); + const answerButton = widget.domNode.querySelector('.chat-question-answer-collapsible .monaco-button') as HTMLElement | null; + assert.ok(question && answerButton); + assert.strictEqual(widget.domNode.querySelector('.chat-question-summary-option-list'), null); + answerButton.click(); + + assert.deepStrictEqual({ + question: question.textContent, + questionExpandable: question.hasAttribute('aria-expanded'), + answer: answerButton.textContent, + answerExpanded: answerButton.getAttribute('aria-expanded'), + answerIcon: answerButton.querySelector('.chat-question-summary-answer-icon')?.classList.contains('codicon-comment'), + hasChevron: !!answerButton.querySelector('.chat-collapsible-hover-chevron'), + optionsTitle: widget.domNode.querySelector('.chat-question-summary-options-title')?.textContent, + options: Array.from(widget.domNode.querySelectorAll('.chat-question-summary-option')).map(option => ({ + label: option.querySelector('.chat-question-summary-option-label')?.textContent, + selected: option.classList.contains('selected'), + hasCompactCheck: !!option.querySelector('.chat-question-summary-option-selected .codicon-check-compact'), + })), + }, { + question: 'Question: What should we prioritize if the refactor affects multiple platforms and may require migration work?', + answer: 'Answered: Fix a bug', + questionExpandable: false, + answerExpanded: 'true', + answerIcon: true, + hasChevron: true, + optionsTitle: 'Options', + options: [ + { label: 'Fix a bug', selected: true, hasCompactCheck: true }, + { label: 'Implement a feature', selected: false, hasCompactCheck: false }, + ], + }); + }); + + test('uses a non-interactive collapsible header for free responses', () => { + const carousel = new ChatQuestionCarouselData([{ + id: 'q1', + type: 'text', + title: 'What would you like me to help you with?', + }], true, undefined, { q1: 'Review the changes' }, true); + carousel.answerPresentation = 'conversation'; + createWidget(carousel.toJSON()); + + const answerButton = widget.domNode.querySelector('.chat-question-answer-collapsible .monaco-button') as HTMLElement | null; + assert.deepStrictEqual({ + answer: answerButton?.textContent, + disabled: answerButton?.getAttribute('aria-disabled'), + tabIndex: answerButton?.tabIndex, + expanded: answerButton?.getAttribute('aria-expanded'), + hasChevron: !!answerButton?.querySelector('.chat-collapsible-hover-chevron'), + }, { + answer: 'Answered: Review the changes', + disabled: 'true', + tabIndex: -1, + expanded: null, + hasChevron: false, + }); + }); + test('shows skipped message when constructed with isUsed but no data', () => { const carousel: IChatQuestionCarousel = { kind: 'questionCarousel', @@ -928,6 +1015,42 @@ suite('ChatQuestionCarouselPart', () => { assert.ok(skippedMessage, 'Should show skipped message when no data'); }); + test('renders a skipped conversational question with its options', () => { + const carousel: IChatQuestionCarousel = { + kind: 'questionCarousel', + questions: [{ + id: 'q1', + type: 'singleSelect', + title: 'Which environment?', + options: [ + { id: 'staging', label: 'Staging', value: 'staging' }, + { id: 'production', label: 'Production', value: 'production' }, + ], + }], + allowSkip: true, + isUsed: true, + answerPresentation: 'conversation', + }; + createWidget(carousel); + + const answerButton = widget.domNode.querySelector('.chat-question-answer-collapsible .monaco-button') as HTMLElement | null; + assert.ok(answerButton); + answerButton.click(); + assert.deepStrictEqual({ + question: widget.domNode.querySelector('.chat-question-summary-question')?.textContent, + answer: answerButton.textContent, + answerIcon: answerButton.querySelector('.chat-question-summary-answer-icon')?.classList.contains('codicon-close-compact'), + hasChevron: !!answerButton.querySelector('.chat-collapsible-hover-chevron'), + options: Array.from(widget.domNode.querySelectorAll('.chat-question-summary-option-label')).map(option => option.textContent), + }, { + question: 'Question: Which environment?', + answer: 'Skipped question', + answerIcon: true, + hasChevron: true, + options: ['Staging', 'Production'], + }); + }); + test('shows answered message when answeredExternally but no data', () => { const carousel: IChatQuestionCarousel = { kind: 'questionCarousel', @@ -936,7 +1059,8 @@ suite('ChatQuestionCarouselPart', () => { ], allowSkip: true, isUsed: true, - answeredExternally: true + answeredExternally: true, + answerPresentation: 'conversation', }; createWidget(carousel); @@ -945,6 +1069,35 @@ suite('ChatQuestionCarouselPart', () => { assert.ok(summary, 'Should show summary container'); assert.ok(!summary?.querySelector('.chat-question-summary-skipped'), 'Should not show skipped message'); assert.ok(summary?.querySelector('.chat-question-summary-answered'), 'Should show answered message when answered externally'); + assert.ok(!summary?.querySelector('.codicon-copilot-compact'), 'Should not present a generic external answer as an automatic reply'); + }); + + test('renders a Copilot icon for a structured automatic answer', () => { + const carousel: IChatQuestionCarousel = { + kind: 'questionCarousel', + questions: [ + { id: 'q1', type: 'text', title: 'What should we work on next?' } + ], + allowSkip: true, + isUsed: true, + answeredExternally: true, + autoReply: true, + answerPresentation: 'conversation', + data: { q1: AgentHostAutoReplyAnswer }, + }; + createWidget(carousel); + + assert.deepStrictEqual({ + question: widget.domNode.querySelector('.chat-question-summary-question')?.textContent, + answer: widget.domNode.querySelector('.chat-question-answer-collapsible .monaco-button')?.textContent, + answerIcon: widget.domNode.querySelector('.chat-question-summary-answer-icon')?.classList.contains('codicon-copilot-compact'), + hasGenericMessage: !!widget.domNode.querySelector('.chat-question-summary-answered'), + }, { + question: 'Question: What should we work on next?', + answer: `Answered: ${AgentHostAutoReplyAnswer}`, + answerIcon: true, + hasGenericMessage: false, + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 580b393d37b..45ce74f850b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -5,7 +5,8 @@ import assert from 'assert'; import { isHTMLElement } from '../../../../../../../base/browser/dom.js'; -import { Action } from '../../../../../../../base/common/actions.js'; +import { ActionViewItem, IActionViewItemOptions } from '../../../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { Action, IAction } from '../../../../../../../base/common/actions.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../../base/common/observable.js'; @@ -14,7 +15,7 @@ import { ThemeIcon } from '../../../../../../../base/common/themables.js'; import { BaseObservable } from '../../../../../../../base/common/observableInternal/observables/baseObservable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; -import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { TestMenuService, workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { ChatCollapsibleContentPart } from '../../../../browser/widget/chatContentParts/chatCollapsibleContentPart.js'; import { ChatSubagentContentPart } from '../../../../browser/widget/chatContentParts/chatSubagentContentPart.js'; import { IChatMarkdownContent, IChatSubagentToolInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; @@ -37,12 +38,33 @@ import { ToolDataSource } from '../../../../common/tools/languageModelToolsServi import { IAccessibilityService } from '../../../../../../../platform/accessibility/common/accessibility.js'; import { TestAccessibilityService } from '../../../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { IActionViewItemFactory, IActionViewItemService } from '../../../../../../../platform/actions/browser/actionViewItemService.js'; -import { MenuId } from '../../../../../../../platform/actions/common/actions.js'; +import { IMenuActionOptions, IMenuService, MenuId, MenuItemAction } from '../../../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; +import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; +import { CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID } from '../../../../common/constants.js'; + +class TestOpenChatActionViewItem extends ActionViewItem { + constructor(sourceAction: IAction, options: IActionViewItemOptions) { + super(undefined, new Action(sourceAction.id, sourceAction.label, sourceAction.class, true, context => sourceAction.run(context)), options); + if (this.action instanceof Action) { + this._register(this.action); + } + } +} class TestActionViewItemService implements IActionViewItemService { declare _serviceBrand: undefined; private readonly _onDidChange = new Emitter<MenuId>(); readonly onDidChange = this._onDidChange.event; + private _providerAvailable = true; + + get hasChangeListeners(): boolean { + return this._onDidChange.hasListeners(); + } + + setProviderAvailable(available: boolean): void { + this._providerAvailable = available; + } fireDidChange(menuId: MenuId): void { this._onDidChange.fire(menuId); @@ -52,8 +74,33 @@ class TestActionViewItemService implements IActionViewItemService { return { dispose: () => { } }; } - lookUp(_menu: MenuId, _commandId: string | MenuId): IActionViewItemFactory | undefined { - return undefined; + lookUp(menu: MenuId, commandId: string | MenuId): IActionViewItemFactory | undefined { + if (!this._providerAvailable || menu !== MenuId.ChatSubagentContent || commandId !== CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID) { + return undefined; + } + return (action, options) => new TestOpenChatActionViewItem(action, options); + } +} + +class TestSubagentMenuService extends TestMenuService { + createMenuCalls = 0; + getMenuActionsCalls = 0; + + constructor(private readonly openChatAction: MenuItemAction) { + super(); + } + + override createMenu(id: MenuId, contextKeyService: IContextKeyService) { + this.createMenuCalls++; + return super.createMenu(id, contextKeyService); + } + + override getMenuActions(id: MenuId, contextKeyService: IContextKeyService, options?: IMenuActionOptions): ReturnType<IMenuService['getMenuActions']> { + this.getMenuActionsCalls++; + if (id === MenuId.ChatSubagentContent) { + return [['navigation', [this.openChatAction]]]; + } + return super.getMenuActions(id, contextKeyService, options); } } @@ -71,6 +118,7 @@ suite('ChatSubagentContentPart', () => { let mockEditorPool: EditorPool; let announcedToolProgressKeys: Set<string>; let actionViewItemService: TestActionViewItemService; + let menuService: TestSubagentMenuService; function createMockRenderContext(isComplete: boolean = false): IChatContentPartRenderContext { const mockElement: Partial<IChatResponseViewModel> = { @@ -275,6 +323,16 @@ suite('ChatSubagentContentPart', () => { }()); actionViewItemService = new TestActionViewItemService(); instantiationService.stub(IActionViewItemService, actionViewItemService); + menuService = new TestSubagentMenuService(new MenuItemAction( + { id: CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID, title: 'Open Subagent' }, + undefined, + { shouldForwardArgs: true }, + undefined, + undefined, + instantiationService.get(IContextKeyService), + instantiationService.get(ICommandService), + )); + instantiationService.stub(IMenuService, menuService); // Mock list pool and editor pool mockListPool = {} as CollapsibleListPool; @@ -380,6 +438,28 @@ suite('ChatSubagentContentPart', () => { }); }); + test('should use a menu snapshot without persistent menu or action-view listeners', () => { + const part = createPart(createMockToolInvocation({ + toolSpecificData: { + kind: 'subagent', + description: 'Test subagent description', + chatResource: 'ahp-chat://subagent/test/tool-call', + } + }), createMockRenderContext(false)); + + assert.deepStrictEqual({ + hasToolbar: !!(part as unknown as { _openChatToolbar?: object })._openChatToolbar, + createMenuCalls: menuService.createMenuCalls, + getMenuActionsCalls: menuService.getMenuActionsCalls, + hasActionViewListeners: actionViewItemService.hasChangeListeners, + }, { + hasToolbar: true, + createMenuCalls: 0, + getMenuActionsCalls: 1, + hasActionViewListeners: false, + }); + }); + test('should hide the complete collapsible surface when the open-chat action is available', () => { const part = createPart(createMockToolInvocation({ toolSpecificData: { @@ -406,6 +486,7 @@ suite('ChatSubagentContentPart', () => { }); test('should hydrate open-chat-only mode when the action view registers after rendering', () => { + actionViewItemService.setProviderAvailable(false); const part = createPart(createMockToolInvocation({ toolSpecificData: { kind: 'subagent', @@ -413,22 +494,22 @@ suite('ChatSubagentContentPart', () => { chatResource: 'ahp-chat://subagent/test/tool-call', } }), createMockRenderContext(false)); - setOpenChatOnlyMode(part, false); + const listeningBeforeRegistration = actionViewItemService.hasChangeListeners; - const toolbar = (part as unknown as { _openChatToolbar?: { getItemsLength(): number; getItemAction(index: number): Action | undefined } })._openChatToolbar; - assert.ok(toolbar); - const hydratedAction = store.add(new Action('openSubagent', 'Open Subagent', '', true)); - toolbar.getItemsLength = () => 1; - toolbar.getItemAction = () => hydratedAction; + actionViewItemService.setProviderAvailable(true); actionViewItemService.fireDidChange(MenuId.ChatSubagentContent); const collapseButton = getCollapseButton(part); const animationContainer = part.domNode.querySelector<HTMLElement>('.chat-collapsible-content-animation'); assert.deepStrictEqual({ + listeningBeforeRegistration, + listeningAfterRegistration: actionViewItemService.hasChangeListeners, openChatOnlyClass: part.domNode.classList.contains('chat-subagent-open-chat-only'), collapseButtonDisplay: collapseButton?.style.display, animationDisplay: animationContainer?.style.display, }, { + listeningBeforeRegistration: true, + listeningAfterRegistration: false, openChatOnlyClass: true, collapseButtonDisplay: 'none', animationDisplay: 'none', diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts index 9a15aa2eff0..ab2f0fda274 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts @@ -25,7 +25,7 @@ import { ChatToolInvocationPart } from '../../../../browser/widget/chatContentPa import { ChatToolConfirmationCarouselPart } from '../../../../browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.js'; import { BaseChatToolInvocationSubPart } from '../../../../browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationSubPart.js'; import { ChatToolProgressSubPart } from '../../../../browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.js'; -import { isMcpToolInvocation } from '../../../../browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.js'; +import { isAskQuestionsToolInvocation, isMcpToolInvocation } from '../../../../browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.js'; import { DiffEditorPool, EditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js'; import { IChatAutomationConfiguredData, IChatTerminalToolInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; import { IChatResponseViewModel } from '../../../../common/model/chatViewModel.js'; @@ -268,6 +268,11 @@ suite('ChatToolProgressSubPart', () => { assert.deepStrictEqual(cases, [true, true, false]); }); + test('detects all ask-question tool names for top-level rendering', () => { + const toolNames = ['copilot_askQuestions', 'vscode_askQuestions', 'ask_user', 'AskUserQuestion', 'request_user_input']; + assert.deepStrictEqual(toolNames.map(toolId => isAskQuestionsToolInvocation(createToolInvocation({ toolId }))), [true, true, true, true, true]); + }); + test('renders the automation result subpart for configured automation data', () => { const invocation: IChatToolInvocationSerialized = { ...createSerializedToolInvocation({ isComplete: true }), @@ -446,6 +451,16 @@ suite('ChatToolProgressSubPart', () => { mockMarkdownRenderer, new Set<string>() )); + const waitingForAnswerTool = disposables.add(instantiationService.createInstance( + ChatToolProgressSubPart, + createToolInvocation({ + toolId: 'ask_user', + invocationMessage: 'Waiting for answer...' + }), + createRenderContext(false), + mockMarkdownRenderer, + new Set<string>() + )); assert.deepStrictEqual([ !!askQuestionsTool.domNode.querySelector('.shimmer-progress'), @@ -454,8 +469,9 @@ suite('ChatToolProgressSubPart', () => { askMultipleQuestionsTool.domNode.querySelector('.chat-progress-shimmer-text')?.textContent, askMultipleQuestionsTool.domNode.textContent, !!analyzingAnswersTool.domNode.querySelector('.shimmer-progress'), - analyzingAnswersTool.domNode.querySelector('.chat-progress-shimmer-text')?.textContent - ], [true, 'Asking a question', 'Asking a question (Target)', 'Asking 3 questions', 'Asking 3 questions (What should we work on?, Preferred area, How hands-on?)', false, undefined]); + analyzingAnswersTool.domNode.querySelector('.chat-progress-shimmer-text')?.textContent, + !!waitingForAnswerTool.domNode.querySelector('.shimmer-progress') + ], [true, 'Asking a question', 'Asking a question (Target)', 'Asking 3 questions', 'Asking 3 questions (What should we work on?, Preferred area, How hands-on?)', false, undefined, true]); }); test('does not render a loading icon for run playwright code progress', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 9a3bad3d7cd..2300ec53211 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -16,10 +16,10 @@ import { TestConfigurationService } from '../../../../../../platform/configurati import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; -import { buildPlanReviewProgressContent, ChatListItemRenderer, endsWithSubagentContent, formatCompletedResponseDisclosureLabel, getFinalResponseStartIndex, getVisibleCompletedResponseItemCount, getWorkingProgressRelevantParts, IChatListItemTemplate, isWaitingForMcpServers, reconcileChatItemHeight, renderChatRequestTimestamp, renderChatResponseDetails, shouldCreateGroupedThinkingPart, shouldHideChatUserIdentity, shouldPinToolInvocationToThinking, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange, shouldShowFileChangesSummaryForSettings, shouldShowPillsSummaryForSettings, shouldStartNewCollapsedThinkingGroup } from '../../../browser/widget/chatListRenderer.js'; +import { buildPlanReviewProgressContent, ChatListItemRenderer, endsWithCompletedQuestionInteraction, endsWithSubagentContent, formatCompletedResponseDisclosureLabel, getFinalResponseStartIndex, getVisibleCompletedResponseItemCount, getWorkingProgressRelevantParts, IChatListItemTemplate, isWaitingForMcpServers, reconcileChatItemHeight, renderChatRequestTimestamp, renderChatResponseDetails, shouldCreateGroupedThinkingPart, shouldHideChatUserIdentity, shouldPinToolInvocationToThinking, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange, shouldShowFileChangesSummaryForSettings, shouldShowPillsSummaryForSettings, shouldStartNewCollapsedThinkingGroup } from '../../../browser/widget/chatListRenderer.js'; import { ChatWidget } from '../../../browser/widget/chatWidget.js'; import { isChatTurnStatusPillsEnabled } from '../../../browser/widget/chatTurnPills.js'; -import { IChatMcpServersStartingSlow, IChatService, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { IChatMcpServersStartingSlow, IChatQuestionCarousel, IChatService, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { formatChatRequestTimestamp, formatChatResponseDetails, formatElapsedTime } from '../../../common/chatProgressFormatting.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../../common/constants.js'; import { ChatModel } from '../../../common/model/chatModel.js'; @@ -500,6 +500,36 @@ suite('ChatListRenderer', () => { executingWithMcpApp: false, streamingWithMcpApp: false, }); + + suite('endsWithCompletedQuestionInteraction', () => { + test('resumes working progress after completed ask interactions', () => { + const completedTool: IChatToolInvocationSerialized = { + kind: 'toolInvocationSerialized', + toolCallId: 'ask-1', + toolId: 'ask_user', + invocationMessage: 'Waiting for answer...', + originMessage: undefined, + pastTenseMessage: undefined, + isComplete: true, + isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, + presentation: undefined, + source: ToolDataSource.Internal, + }; + const completedQuestion: IChatQuestionCarousel = { + kind: 'questionCarousel', + questions: [], + allowSkip: true, + isUsed: true, + }; + + assert.deepStrictEqual([ + endsWithCompletedQuestionInteraction([completedTool]), + endsWithCompletedQuestionInteraction([completedTool, completedQuestion]), + endsWithCompletedQuestionInteraction([{ ...completedQuestion, isUsed: false }]), + endsWithCompletedQuestionInteraction([{ ...completedTool, toolId: 'read_file' }]), + ], [true, true, false, false]); + }); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts index ec135ee9495..b5421fa02c4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatPetWidget.test.ts @@ -5,11 +5,24 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { CHAT_PET_IDLE_SLEEP_DELAY, doesChatPetStateTrackCursor, getChatPetAnimationFrame, getChatPetBaseState, getChatPetBuddyName, getChatPetClickInteraction, getChatPetFrameDurations, getChatPetGazeDirection, getChatPetHorizontalPosition, getChatPetRenderedState, getChatPetSpeechFrameDurations, getChatPetSpriteName, isChatPetImageSource } from '../../../browser/widget/chatPetWidget.js'; +import { NullTelemetryServiceShape } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; +import { ChatPetService, getChatPetVariant } from '../../../browser/chatPetService.js'; +import { CHAT_PET_IDLE_SLEEP_DELAY, doesChatPetStateTrackCursor, getChatPetAnimationFrame, getChatPetBaseState, getChatPetBuddyName, getChatPetClickInteraction, getChatPetFrameDurations, getChatPetGazeDirection, getChatPetHorizontalPosition, getChatPetRenderedState, getChatPetSpeechFrameDurations, getChatPetSpriteName, isChatPetImageSource, isChatPetVisible } from '../../../browser/widget/chatPetWidget.js'; suite('ChatPetWidget', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + class TestTelemetryService extends NullTelemetryServiceShape { + readonly events: { readonly name: string; readonly data: unknown }[] = []; + + override publicLog2(eventName?: string, data?: unknown): void { + if (eventName) { + this.events.push({ name: eventName, data }); + } + } + } test('maps chat activity to pet states by priority', () => { assert.deepStrictEqual([ @@ -29,6 +42,20 @@ suite('ChatPetWidget', () => { ]); }); + test('only shows in the latest focused chat widget when enabled', () => { + assert.deepStrictEqual([ + isChatPetVisible(false, false), + isChatPetVisible(false, true), + isChatPetVisible(true, false), + isChatPetVisible(true, true), + ], [ + false, + false, + false, + true, + ]); + }); + test('gives dragging precedence over base and transient states', () => { assert.deepStrictEqual([ getChatPetRenderedState('rendering', undefined, false), @@ -59,19 +86,51 @@ suite('ChatPetWidget', () => { ]); }); + test('resolves configured and product pet variants', () => { + assert.deepStrictEqual([ + getChatPetVariant('stable', 'insider'), + getChatPetVariant('insiders', 'stable'), + getChatPetVariant(undefined, 'stable'), + getChatPetVariant(undefined, 'insider'), + ], [ + 'stable', + 'insiders', + 'stable', + 'insiders', + ]); + }); + + test('logs pet enablement at startup and when toggled', () => { + const telemetryService = new TestTelemetryService(); + const service = disposables.add(new ChatPetService(disposables.add(new TestStorageService()), telemetryService)); + + service.toggle(); + service.toggle(); + + assert.deepStrictEqual(telemetryService.events, [ + { name: 'chatPetEnablement', data: { enabled: false, source: 'startup' } }, + { name: 'chatPetEnablement', data: { enabled: true, source: 'change' } }, + { name: 'chatPetEnablement', data: { enabled: false, source: 'change' } }, + ]); + }); + test('maps random values to click interactions', () => { assert.deepStrictEqual([ getChatPetClickInteraction(0), - getChatPetClickInteraction(0.32), - getChatPetClickInteraction(0.34), - getChatPetClickInteraction(0.66), - getChatPetClickInteraction(0.67), + getChatPetClickInteraction(0.24), + getChatPetClickInteraction(0.26), + getChatPetClickInteraction(0.49), + getChatPetClickInteraction(0.51), + getChatPetClickInteraction(0.74), + getChatPetClickInteraction(0.76), getChatPetClickInteraction(0.99), ], [ 'love', 'love', 'jump', 'jump', + 'cool', + 'cool', 'yapping', 'yapping', ]); @@ -83,6 +142,8 @@ suite('ChatPetWidget', () => { getChatPetClickInteraction(0.99, 'love'), getChatPetClickInteraction(0, 'jump'), getChatPetClickInteraction(0.99, 'jump'), + getChatPetClickInteraction(0, 'cool'), + getChatPetClickInteraction(0.99, 'cool'), getChatPetClickInteraction(0, 'yapping'), getChatPetClickInteraction(0.99, 'yapping'), ], [ @@ -91,7 +152,9 @@ suite('ChatPetWidget', () => { 'love', 'yapping', 'love', - 'jump', + 'yapping', + 'love', + 'cool', ]); }); @@ -104,8 +167,11 @@ suite('ChatPetWidget', () => { doesChatPetStateTrackCursor('rendering'), doesChatPetStateTrackCursor('complete'), doesChatPetStateTrackCursor('love'), + doesChatPetStateTrackCursor('cool'), doesChatPetStateTrackCursor('yapping'), doesChatPetStateTrackCursor('yappingMouthOpen'), + doesChatPetStateTrackCursor('onTheRun'), + doesChatPetStateTrackCursor('searching'), ], [ true, false, @@ -114,8 +180,11 @@ suite('ChatPetWidget', () => { true, false, false, + false, true, false, + false, + false, ]); }); @@ -126,6 +195,8 @@ suite('ChatPetWidget', () => { getChatPetSpriteName('waking', 'stable'), getChatPetSpriteName('typing', 'insider'), getChatPetSpriteName('rendering', 'stable'), + getChatPetSpriteName('cool', 'stable'), + getChatPetSpriteName('searching', 'stable'), getChatPetSpriteName('yappingMouthOpen', 'insider'), ], [ 'buddy-idle-insiders', @@ -133,6 +204,8 @@ suite('ChatPetWidget', () => { 'buddy-waking-stable', 'buddy-typing-insiders', 'buddy-rendering-stable', + 'buddy-cool-stable', + 'buddy-search-stable', 'buddy-yapping-insiders', ]); }); @@ -146,6 +219,8 @@ suite('ChatPetWidget', () => { getChatPetFrameDurations('rendering'), getChatPetFrameDurations('clapping'), getChatPetFrameDurations('love'), + getChatPetFrameDurations('cool'), + getChatPetFrameDurations('searching'), getChatPetFrameDurations('yapping'), getChatPetFrameDurations('yappingMouthOpen'), getChatPetSpeechFrameDurations(), @@ -157,6 +232,8 @@ suite('ChatPetWidget', () => { Array.from({ length: 50 }, () => 40), [80, 40, 40, 40, 80, 40, 40, 40, 40, 80, 40, 40, 80], [200, 200, 380, 100, 80, 1_980], + [600, 120, 120, 120, 160, 80, 80, 80, 1_640], + [500, 500, 500, 500], [], [300, 240, 1_500, 240, 360], [220, 220, 220, 100, 160, 180], diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts new file mode 100644 index 00000000000..42fa90b8fe8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatTurnPills.test.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IOpenerService, OpenExternalOptions, OpenInternalOptions } from '../../../../../../platform/opener/common/opener.js'; +import { openChatTurnFile } from '../../../browser/widget/chatTurnPills.js'; +import { ChatConfiguration } from '../../../common/constants.js'; + +suite('ChatTurnPills', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('opens a markdown resource with its configured chat editor association', async () => { + const resource = URI.file('/workspace/README.md'); + let opened: { resource: string; options: OpenInternalOptions | OpenExternalOptions | undefined } | undefined; + const openerService = new class extends mock<IOpenerService>() { + override async open(resource: string | URI, options?: OpenInternalOptions | OpenExternalOptions): Promise<boolean> { + opened = { resource: resource.toString(), options }; + return true; + } + }; + const configurationService = new TestConfigurationService({ + [ChatConfiguration.EditorAssociations]: { + '*.md': 'vscode.markdown.editor', + }, + }); + + await openChatTurnFile({ uri: resource, kind: 'markdown', created: true }, openerService, configurationService); + + assert.deepStrictEqual(opened, { + resource: resource.toString(), + options: { + fromUserGesture: true, + editorOptions: { + override: 'vscode.markdown.editor', + }, + }, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts index 332c084c6ff..ff1c9ba59d6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { OffsetRange } from '../../../../../../editor/common/core/ranges/offsetRange.js'; import { Range } from '../../../../../../editor/common/core/range.js'; -import { getImmediateSilentSlashCommandPart, layoutChatWidgetForInputHeight } from '../../../browser/widget/chatWidget.js'; +import { acceptAndAwaitSentRequest, getImmediateSilentSlashCommandPart, layoutChatWidgetForInputHeight } from '../../../browser/widget/chatWidget.js'; +import { ChatSendResult, ChatSendResultSent, IChatSendRequestData } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { ChatRequestSlashCommandPart, ChatRequestTextPart, IParsedChatRequest } from '../../../common/requestParser/chatParserTypes.js'; @@ -84,3 +86,63 @@ suite('ChatWidget', () => { ]); }); }); + +suite('ChatWidget - acceptAndAwaitSentRequest', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function sentResult(): ChatSendResultSent { + return { kind: 'sent', data: {} as IChatSendRequestData }; + } + + test('an immediately sent request is accepted and returned', async () => { + let accepted = 0; + const result = sentResult(); + + const sent = await acceptAndAwaitSentRequest(result, () => accepted++); + + assert.deepStrictEqual({ accepted, sent }, { accepted: 1, sent: result }); + }); + + test('a queued request is accepted before the queued request settles', async () => { + const deferred = new DeferredPromise<ChatSendResult>(); + let accepted = 0; + + const pending = acceptAndAwaitSentRequest({ kind: 'queued', deferred: deferred.p }, () => accepted++); + // The queued request has not run yet, so `pending` is still unresolved here. + const acceptedWhileQueued = accepted === 1; + + const result = sentResult(); + await deferred.complete(result); + + assert.deepStrictEqual({ acceptedWhileQueued, accepted, sent: await pending }, { + acceptedWhileQueued: true, + accepted: 1, + sent: result, + }); + }); + + test('a rejected request is never accepted', async () => { + let accepted = 0; + + const sent = await acceptAndAwaitSentRequest({ kind: 'rejected', reason: 'Empty message' }, () => accepted++); + + assert.deepStrictEqual({ accepted, sent }, { accepted: 0, sent: undefined }); + }); + + test('a queued request that is rejected when it runs stays accepted but is not sent', async () => { + const deferred = new DeferredPromise<ChatSendResult>(); + let accepted = 0; + + const pending = acceptAndAwaitSentRequest({ kind: 'queued', deferred: deferred.p }, () => accepted++); + await deferred.complete({ kind: 'rejected', reason: 'Session is read-only' }); + + assert.deepStrictEqual({ accepted, sent: await pending }, { accepted: 1, sent: undefined }); + }); + + test('accepting is optional', async () => { + const result = sentResult(); + + assert.strictEqual(await acceptAndAwaitSentRequest(result), result); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelProviderIcons.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelProviderIcons.test.ts index 36f1721e6f2..6281ce23216 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelProviderIcons.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelProviderIcons.test.ts @@ -36,7 +36,11 @@ suite('ModelProviderIcons', () => { getModelProviderIcon(createModel('claude-sonnet-5', 'Claude Sonnet 5')).id, getModelProviderIcon(createModel('gemini-3.1-pro', 'Gemini 3.1 Pro')).id, getModelProviderIcon(createModel('kimi-k2.5', 'Kimi K2.5')).id, + getModelProviderIcon(createModel('grok-4.5', 'Grok 4.5')).id, + getModelProviderIcon(createModel('grok-code-fast-1', 'Grok Code Fast 1', 'agent-host-copilot')).id, + getModelProviderIcon(createModel('grok-4', 'Grok 4', 'xai', { isBYOK: true })).id, getModelProviderIcon(createModel('mai-ds-r1', 'MAI-DS-R1')).id, + getModelProviderIcon(createModel('deepseek-v4-pro', 'DeepSeek V4 Pro')).id, getModelProviderIcon(createModel('auto', 'Auto')).id, getModelProviderIcon(createModel('auto', 'Auto', 'anthropic')).id, getModelProviderIcon(createModel('custom', 'Custom Model', 'third-party')).id, @@ -48,7 +52,11 @@ suite('ModelProviderIcons', () => { 'chat-model-provider-claude', 'chat-model-provider-gemini', 'chat-model-provider-kimi', + 'chat-model-provider-xai', + 'chat-model-provider-xai', + 'chat-model-provider-xai', 'chat-model-provider-microsoft', + 'chat-model-provider-generic', 'chat-model-provider-copilot', 'chat-model-provider-copilot', 'chat-model-provider-generic', diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap index c3be6013e60..f4afd2f8f25 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap @@ -86,6 +86,7 @@ completedAt: undefined }, vote: undefined, + sessionCopilotCredits: undefined, voteDownReason: undefined, slashCommand: undefined, usedContext: { diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap index efc87326930..9907cb36091 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap @@ -86,6 +86,7 @@ completedAt: undefined }, vote: undefined, + sessionCopilotCredits: undefined, voteDownReason: undefined, slashCommand: undefined, usedContext: undefined, diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap index c7e7f976174..17129075b1c 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap @@ -95,6 +95,7 @@ completedAt: undefined }, vote: undefined, + sessionCopilotCredits: undefined, voteDownReason: undefined, slashCommand: undefined, usedContext: { @@ -175,6 +176,7 @@ completedAt: undefined }, vote: undefined, + sessionCopilotCredits: undefined, voteDownReason: undefined, slashCommand: undefined, usedContext: undefined, diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap index f1d5d9a85c5..bb56aa4596d 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap @@ -88,6 +88,7 @@ completedAt: undefined }, vote: undefined, + sessionCopilotCredits: undefined, voteDownReason: undefined, slashCommand: undefined, usedContext: undefined, diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 0f3b906f27c..215fd253afe 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -237,6 +237,26 @@ suite('ChatService', () => { }); ensureNoDisposablesAreLeakedInTestSuite(); + test('propagates Agents Voice Mode input to the participant request', async () => { + const captured = new DeferredPromise<boolean | undefined>(); + testDisposables.add(chatAgentService.registerAgent('voiceAgent', getAgentData('voiceAgent'))); + testDisposables.add(chatAgentService.registerAgentImplementation('voiceAgent', { + async invoke(request) { + captured.complete(request.isVoiceModeInput); + return {}; + }, + })); + const service = createChatService(); + const model = startSessionModel(service).object; + + await service.sendRequest(model.sessionResource, 'voice request', { + agentId: 'voiceAgent', + isVoiceModeInput: true, + }); + + assert.strictEqual(await captured.p, true); + }); + test('slash commands can share ids across non-overlapping session types', async () => { const slashCommandService = testDisposables.add(instantiationService.createInstance(ChatSlashCommandService)); const executions: string[] = []; @@ -651,6 +671,38 @@ suite('ChatService', () => { assert.strictEqual(disposed, true); }); + test('disposing a session cancels pending followups', async () => { + let followupsToken: CancellationToken | undefined; + const followupsCancelled = new DeferredPromise<IChatFollowup[]>(); + const followupsAgent: IChatAgentImplementation = { + async invoke() { + return {}; + }, + provideFollowups(request, result, history, token) { + followupsToken = token; + testDisposables.add(token.onCancellationRequested(() => followupsCancelled.complete([]))); + return followupsCancelled.p; + }, + }; + + testDisposables.add(chatAgentService.registerAgent('followupsAgent', { ...getAgentData('followupsAgent'), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation('followupsAgent', followupsAgent)); + + const testService = createChatService(); + const modelRef = testService.startNewLocalSession(ChatAgentLocation.Chat); + const response = await testService.sendRequest(modelRef.object.sessionResource, 'test request', { agentId: 'followupsAgent' }); + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; + + assert.ok(followupsToken); + assert.strictEqual(followupsToken.isCancellationRequested, false); + + modelRef.dispose(); + await testService.waitForModelDisposals(); + + assert.strictEqual(followupsToken.isCancellationRequested, true); + }); + test('steering message queued triggers setYieldRequested', async () => { const requestStarted = new DeferredPromise<void>(); const completeRequest = new DeferredPromise<void>(); diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index d5b246a404e..4c0d14c53e8 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -8,6 +8,7 @@ import * as sinon from 'sinon'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { observableValue } from '../../../../../../base/common/observable.js'; +import { hasKey } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { assertSnapshot } from '../../../../../../base/test/common/snapshot.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -26,7 +27,7 @@ import { TestExtensionService, TestStorageService } from '../../../../../test/co import { CellUri } from '../../../../notebook/common/notebookCommon.js'; import { IChatRequestImplicitVariableEntry, IChatRequestStringVariableEntry, IChatRequestFileEntry, StringChatContextValue } from '../../../common/attachments/chatVariableEntries.js'; import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { ChatModel, ChatRequestModel, ChatResponseResource, IChatRequestModeInfo, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, ISerializableChatModelInputState, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response, serializeSendOptions } from '../../../common/model/chatModel.js'; +import { ChatModel, ChatRequestModel, ChatResponseResource, IChatRequestModeInfo, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, ISerializableChatModelInputState, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response, serializeSendOptions, toChatHistoryContent } from '../../../common/model/chatModel.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; import { ChatRequestQueueKind, IChatService, IChatTerminalToolInvocationData, IChatToolInvocation, ResponseModelState } from '../../../common/chatService/chatService.js'; @@ -211,6 +212,51 @@ suite('ChatModel', () => { }); }); + test('voice progress is live-only response metadata', () => { + const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); + const text = 'hello'; + const request = model.addRequest({ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, { variables: [] }, 0); + + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('Before ') }); + model.acceptResponseProgress(request, { kind: 'voiceProgress', id: 'investigating', value: 'Investigating the relevant code.' }); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('after') }); + + const response = request.response!.response; + assert.deepStrictEqual({ + responseKinds: response.value.map(part => part.kind), + historyKinds: toChatHistoryContent(response.value).map(part => part.kind), + markdown: response.getMarkdown(), + copyText: response.toString(), + persistedKinds: model.toExport().requests[0].response?.map(part => hasKey(part, { kind: true }) ? part.kind : 'markdown'), + }, { + responseKinds: ['markdownContent', 'voiceProgress', 'markdownContent'], + historyKinds: ['markdownContent', 'markdownContent'], + markdown: 'Before after', + copyText: 'Before after', + persistedKinds: ['markdown', 'markdown'], + }); + }); + + test('a refinement of the same model call updates usage without recounting its tokens', () => { + const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); + const text = 'hello'; + const request = model.addRequest({ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, { variables: [] }, 0); + + // The agent host reports one model call several times as its context attribution + // and session cost resolve asynchronously. Those refinements must update the + // stored usage without adding the call's completion tokens again. + model.acceptResponseProgress(request, { kind: 'usage', promptTokens: 10, completionTokens: 2, copilotCredits: 1, sessionCopilotCredits: 1 }); + model.acceptResponseProgress(request, { kind: 'usage', promptTokens: 10, completionTokens: 2, copilotCredits: 1, sessionCopilotCredits: 5 }); + + assert.deepStrictEqual({ + sessionCopilotCredits: request.response?.usage?.sessionCopilotCredits, + completionTokenCount: request.response?.completionTokenCount, + }, { + sessionCopilotCredits: 5, + completionTokenCount: 2, + }); + }); + test('subagent credits are folded into parent response usage', () => { const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); const text = 'hello'; @@ -237,6 +283,33 @@ suite('ChatModel', () => { assert.strictEqual(restoredSeparateCosts.sessionCost, 11); }); + test('the session total and the summed turns each provide a floor for session cost', () => { + const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); + const addRequest = (text: string) => model.addRequest({ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, { variables: [] }, 0); + + // A turn from a backend that reports no session total (e.g. Claude) still counts. + const first = addRequest('one'); + model.acceptResponseProgress(first, { kind: 'usage', promptTokens: 10, completionTokens: 2, copilotCredits: 2 }); + // The reported session total exceeds the summed turns because it also covers work + // billed outside any turn, such as a compaction that ran between them. + const second = addRequest('two'); + model.acceptResponseProgress(second, { kind: 'usage', promptTokens: 10, completionTokens: 2, copilotCredits: 3, sessionCopilotCredits: 9 }); + + assert.strictEqual(model.sessionCost, 9); + const restored = testDisposables.add(instantiationService.createInstance( + ChatModel, + { value: JSON.parse(JSON.stringify(model.toJSON())) as ISerializableChatData3, serializer: undefined! }, + { initialLocation: ChatAgentLocation.Chat, canUseTools: true } + )); + assert.strictEqual(restored.sessionCost, 9); + + // A later turn whose cost has not yet reached the reported total must not shrink + // the session cost, and the summed turns take over once they exceed it. + const third = addRequest('three'); + model.acceptResponseProgress(third, { kind: 'usage', promptTokens: 10, completionTokens: 2, copilotCredits: 6 }); + assert.strictEqual(model.sessionCost, 11); + }); + test('response details, elapsed time, and tokens roundtrip through serialization', () => { const completedAt = 1_752_012_405_000; const serializableData: ISerializableChatData3 = { @@ -1772,16 +1845,23 @@ suite('ChatModel - Pending Requests', () => { suite('serializeSendOptions', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('preserves userSelectedModelConfiguration so per-editor config survives persist/restore (issue #320393)', () => { + test('preserves request-scoped options through persist/restore', () => { // A pending/queued request is serialized and later restored (e.g. window // reload). The editor-scoped model configuration must round-trip, otherwise // the restored request falls back to the profile-global value. const serialized = serializeSendOptions({ userSelectedModelId: 'copilot/gpt', userSelectedModelConfiguration: { thinkingEffort: 'high', contextSize: 2000 }, + isVoiceModeInput: true, }); - assert.deepStrictEqual(serialized.userSelectedModelConfiguration, { thinkingEffort: 'high', contextSize: 2000 }); + assert.deepStrictEqual({ + modelConfiguration: serialized.userSelectedModelConfiguration, + isVoiceModeInput: serialized.isVoiceModeInput, + }, { + modelConfiguration: { thinkingEffort: 'high', contextSize: 2000 }, + isVoiceModeInput: true, + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts index 5908b92fa06..5d2ee7d9e99 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatQuestionCarouselData.test.ts @@ -66,13 +66,20 @@ suite('ChatQuestionCarouselData', () => { assert.strictEqual((json as { draftCurrentIndex?: unknown }).draftCurrentIndex, undefined, 'toJSON should not include draftCurrentIndex'); }); - test('toJSON preserves answeredExternally', () => { + test('toJSON preserves external answer metadata', () => { const carousel = new ChatQuestionCarouselData(createQuestions(), true, 'test-resolve-id', {}, true); carousel.answeredExternally = true; + carousel.autoReply = true; const json = carousel.toJSON(); - assert.strictEqual(json.answeredExternally, true, 'toJSON should preserve answeredExternally'); + assert.deepStrictEqual({ + answeredExternally: json.answeredExternally, + autoReply: json.autoReply, + }, { + answeredExternally: true, + autoReply: true, + }); }); test('multiple carousels can have independent completion promises', async () => { diff --git a/src/vs/workbench/contrib/chat/test/common/widget/annotations.test.ts b/src/vs/workbench/contrib/chat/test/common/widget/annotations.test.ts index 8229a6ae779..ae6bafb092b 100644 --- a/src/vs/workbench/contrib/chat/test/common/widget/annotations.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/widget/annotations.test.ts @@ -18,6 +18,16 @@ function content(str: string): IChatMarkdownContent { suite('Annotations', function () { ensureNoDisposablesAreLeakedInTestSuite(); + test('voice progress is not renderable', () => { + assert.deepStrictEqual( + annotateSpecialMarkdownContent([ + { kind: 'voiceProgress', id: 'investigating', value: 'Investigating the relevant code.' }, + content('Visible response'), + ]), + [content('Visible response')] + ); + }); + suite('extractVulnerabilitiesFromText', () => { test('single line', async () => { const before = 'some code '; diff --git a/src/vs/workbench/contrib/customEditor/browser/customEditorDiffInput.ts b/src/vs/workbench/contrib/customEditor/browser/customEditorDiffInput.ts index 7b69f362424..16951f9d59b 100644 --- a/src/vs/workbench/contrib/customEditor/browser/customEditorDiffInput.ts +++ b/src/vs/workbench/contrib/customEditor/browser/customEditorDiffInput.ts @@ -12,7 +12,7 @@ import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs. import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IUndoRedoService } from '../../../../platform/undoRedo/common/undoRedo.js'; -import { EditorInputCapabilities, GroupIdentifier, IResourceDiffEditorInput, IRevertOptions, ISaveOptions, IUntypedEditorInput, isEditorInput, isResourceEditorInput, isResourceDiffEditorInput, Verbosity } from '../../../common/editor.js'; +import { EditorInputCapabilities, GroupIdentifier, IEditorInputWithDiffResources, IResourceDiffEditorInput, IRevertOptions, ISaveOptions, IUntypedEditorInput, isEditorInput, isResourceEditorInput, isResourceDiffEditorInput, Verbosity } from '../../../common/editor.js'; import { EditorInput, IUntypedEditorOptions } from '../../../common/editor/editorInput.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IFilesConfigurationService } from '../../../services/filesConfiguration/common/filesConfigurationService.js'; @@ -41,7 +41,7 @@ function getCustomEditorSideBySideDiffInputResource(init: CustomEditorSideBySide return init.side === 'original' ? init.originalResource : init.modifiedResource; } -export class CustomEditorDiffInput extends LazilyResolvedWebviewEditorInput { +export class CustomEditorDiffInput extends LazilyResolvedWebviewEditorInput implements IEditorInputWithDiffResources { private readonly _modelRef = this._register(new MutableDisposable<IReference<ICustomEditorModel>>()); @@ -113,6 +113,13 @@ export class CustomEditorDiffInput extends LazilyResolvedWebviewEditorInput { return this.init.modifiedResource; } + get diffResources(): IEditorInputWithDiffResources['diffResources'] { + return { + original: this.originalResource, + modified: this.modifiedResource, + }; + } + override getName(): string { return this.init.label ?? localize('customEditorDiffLabel', "{0} - {1}", basename(this.originalResource), basename(this.modifiedResource)); } diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts index 7cb8aff0c84..424b9236081 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts @@ -190,18 +190,38 @@ export class MultiDiffEditorInput extends EditorInput implements ILanguageSuppor let modified: IReference<IResolvedTextEditorModel> | undefined; const multiDiffItemStore = new DisposableStore(); + const createModelReference = async (resource: URI | undefined) => resource ? this._textModelService.createModelReference(resource) : undefined; - try { - [original, modified] = await Promise.all([ - r.originalUri ? this._textModelService.createModelReference(r.originalUri) : undefined, - r.modifiedUri ? this._textModelService.createModelReference(r.modifiedUri) : undefined, - ]); + const [originalResult, modifiedResult] = await Promise.allSettled([ + createModelReference(r.originalUri), + createModelReference(r.modifiedUri), + ]); + + if (originalResult.status === 'fulfilled') { + original = originalResult.value; if (original) { multiDiffItemStore.add(original); } + } + if (modifiedResult.status === 'fulfilled') { + modified = modifiedResult.value; if (modified) { multiDiffItemStore.add(modified); } - } catch (e) { + } + + if (store.isDisposed) { + multiDiffItemStore.dispose(); + return undefined; + } + + let errorResult: PromiseRejectedResult | undefined; + if (originalResult.status === 'rejected') { + errorResult = originalResult; + } else if (modifiedResult.status === 'rejected') { + errorResult = modifiedResult; + } + if (errorResult) { + multiDiffItemStore.dispose(); // e.g. "File seems to be binary and cannot be opened as text" - console.error(e); - onUnexpectedError(e); + console.error(errorResult.reason); + onUnexpectedError(errorResult.reason); return undefined; } diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts index 52892c1e43b..d3c7c9e8c9e 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts @@ -225,7 +225,7 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD deletedCells.forEach(cell => { this._handleToViewCellMapping.delete(cell.handle); // dispose the cell to release ref to the cell text document - cell.dispose(); + this._localStore.delete(cell); }); diff[2].forEach(cell => { diff --git a/src/vs/workbench/contrib/notebook/test/browser/notebookViewModel.test.ts b/src/vs/workbench/contrib/notebook/test/browser/notebookViewModel.test.ts index 44c613b094f..58f54d66fa2 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/notebookViewModel.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/notebookViewModel.test.ts @@ -104,6 +104,32 @@ suite('NotebookViewModel', () => { ); }); + test('deleted cells are removed from the disposable store', async function () { + const getDisposeCallCount = await withTestNotebook( + [ + ['var a = 1;', 'javascript', CellKind.Code, [], {}], + ['var b = 2;', 'javascript', CellKind.Code, [], {}] + ], + (editor, viewModel) => { + const cell = insertCellAtIndex(viewModel, 1, 'var c = 3', 'javascript', CellKind.Code, {}, [], true, true); + const originalDispose = cell.dispose.bind(cell); + let disposeCallCount = 0; + cell.dispose = () => { + disposeCallCount++; + originalDispose(); + }; + + runDeleteAction(editor, cell); + assert.strictEqual(disposeCallCount, 1); + cell.model.dispose(); + + return () => disposeCallCount; + } + ); + + assert.strictEqual(getDisposeCallCount(), 1); + }); + test('index', async function () { await withTestNotebook( [ diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts index 1760fe70176..99458e8b36d 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTree.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTree.ts @@ -950,7 +950,7 @@ export abstract class AbstractSettingRenderer extends Disposable implements ITre const deprecationWarningElement = DOM.append(container, $('.setting-item-deprecation-message')); const toolbarContainer = DOM.append(container, $('.setting-toolbar-container')); - const toolbar = this.renderSettingToolbar(toolbarContainer); + const toolbar = toDispose.add(this.renderSettingToolbar(toolbarContainer)); const template: ISettingItemTemplate = { toDispose, diff --git a/src/vs/workbench/contrib/preferences/test/browser/settingsTree.test.ts b/src/vs/workbench/contrib/preferences/test/browser/settingsTree.test.ts new file mode 100644 index 00000000000..5f686ae95ad --- /dev/null +++ b/src/vs/workbench/contrib/preferences/test/browser/settingsTree.test.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ITreeNode } from '../../../../../base/browser/ui/tree/tree.js'; +import { ToolBar } from '../../../../../base/browser/ui/toolbar/toolbar.js'; +import { IAction } from '../../../../../base/common/actions.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ISetting } from '../../../../services/preferences/common/preferences.js'; +import { SettingsTarget } from '../../browser/preferencesWidgets.js'; +import { AbstractSettingRenderer } from '../../browser/settingsTree.js'; +import { SettingsTreeSettingElement } from '../../browser/settingsTreeModels.js'; + +class TestSettingRenderer extends AbstractSettingRenderer { + readonly templateId = 'test'; + toolbarDisposed = false; + + constructor() { + super( + [], + (_setting: ISetting, _settingTarget: SettingsTarget): IAction[] => [], + undefined!, + undefined!, + undefined!, + { createInstance: () => ({ dispose() { } }) } as never, + undefined!, + undefined!, + undefined!, + new TestConfigurationService(), + undefined!, + undefined!, + undefined!, + undefined!, + { setupDelayedHover: () => Disposable.None } as never, + undefined!, + ); + } + + renderTemplate(container: HTMLElement) { + return this.renderCommonTemplate(undefined, container, 'test'); + } + + renderElement(_element: ITreeNode<SettingsTreeSettingElement, never>, _index: number, _templateData: unknown): void { + } + + protected override renderSettingToolbar(_container: HTMLElement): ToolBar { + return { + dispose: () => this.toolbarDisposed = true + } as unknown as ToolBar; + } + + protected renderValue(): void { + } +} + +suite('SettingsTree renderer', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('disposes the setting toolbar with its template', () => { + const renderer = new TestSettingRenderer(); + const template = renderer.renderTemplate(document.createElement('div')); + + assert.strictEqual(renderer.toolbarDisposed, false); + renderer.disposeTemplate(template); + assert.strictEqual(renderer.toolbarDisposed, true); + + renderer.dispose(); + }); +}); diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css b/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css index 01ea5973ba7..545cd679c66 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css @@ -99,6 +99,24 @@ height: calc(var(--activity-bar-action-height, 28px) - 4px); } +:is(.hc-black, .hc-light).style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator { + border-radius: var(--vscode-cornerRadius-small); + background-color: transparent; + outline: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked:not(:focus) .active-item-indicator { + display: block; +} + +:is(.hc-black, .hc-light).style-override .activitybar > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover::before { + border-radius: var(--vscode-cornerRadius-small); + background-color: transparent; + outline: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + /* * Horizontal Activity Bar — top / bottom position on the primary sidebar, panel * (bottom) and auxiliary bar. When the activity bar is moved to the top or @@ -145,15 +163,16 @@ } /* Active item: inset, rounded background box behind the icon. */ -.style-override .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon.checked .active-item-indicator, -.style-override .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon.checked .active-item-indicator { +.style-override .pane-composite-part > .title > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon.checked:not(:active) .active-item-indicator, +.style-override .pane-composite-part > .header-or-footer > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon.checked:not(:active) .active-item-indicator { z-index: 0; - top: 4px; + top: 50%; left: 0; width: 24px; height: 24px; border-radius: var(--vscode-cornerRadius-small); background-color: var(--vscode-activityBar-activeBackground, var(--vscode-list-inactiveSelectionBackground)); + transform: translateY(-50%); } /* @@ -161,15 +180,16 @@ * shows the box) and while dragging (the action-item `::before`/`::after` are * reused for the drop-line indicators). */ -.style-override .pane-composite-part > .title > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon:not(.checked):hover .active-item-indicator, -.style-override .pane-composite-part > .header-or-footer > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon:not(.checked):hover .active-item-indicator { +.style-override .pane-composite-part > .title > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon:not(.checked):not(:active):hover .active-item-indicator, +.style-override .pane-composite-part > .header-or-footer > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon:not(.checked):not(:active):hover .active-item-indicator { z-index: 0; - top: 4px; + top: 50%; left: 0; width: 24px; height: 24px; border-radius: var(--vscode-cornerRadius-small); background-color: var(--vscode-list-hoverBackground); + transform: translateY(-50%); } /* @@ -264,6 +284,7 @@ .style-override.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon, .style-override.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon { padding: 0 4px; + border-radius: var(--vscode-cornerRadius-small); } /* @@ -282,15 +303,11 @@ .style-override.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label::before, .style-override.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .action-label::before { + position: relative; left: 0px; top: 0px; } -.style-override.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before, -.style-override.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before { - position: relative; -} - /* * Auxiliary bar only: give its composite icon items a shorter 24px height so the * single Chat item reads as a compact, balanced chip. Scoped to the auxiliary diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css index 882529256d6..dea920b0e60 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css @@ -16,26 +16,41 @@ * src/vs/sessions/browser/media/style.css. */ -.style-override .part.editor .title.tabs { +.style-override.monaco-workbench { + --modern-ui-tab-active-background: color-mix(in srgb, var(--vscode-foreground) 22%, transparent); + --modern-ui-tab-hover-background: color-mix(in srgb, var(--vscode-foreground) 8%, transparent); + --modern-ui-editor-tab-action-active-background: color-mix(in srgb, var(--vscode-foreground) 22%, var(--vscode-editor-background)); + --modern-ui-editor-tab-action-hover-background: color-mix(in srgb, var(--vscode-foreground) 8%, var(--vscode-editor-background)); +} + +.style-override.monaco-workbench.vs { + --modern-ui-tab-active-background: color-mix(in srgb, var(--vscode-foreground) 16%, transparent); + --modern-ui-tab-hover-background: color-mix(in srgb, var(--vscode-foreground) 6%, transparent); + --modern-ui-editor-tab-action-active-background: color-mix(in srgb, var(--vscode-foreground) 16%, var(--vscode-editor-background)); + --modern-ui-editor-tab-action-hover-background: color-mix(in srgb, var(--vscode-foreground) 6%, var(--vscode-editor-background)); +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs { background-color: transparent !important; + cursor: default; --editor-group-tab-height: 24px !important; } /* Compact tab height: 20px tab + 4px top + 4px bottom padding = 28px total. * 20px is the minimum to fit the tab action icons (16px codicon + 2px padding on each side). */ -.style-override .part.editor .title.tabs.compact-height { +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs.compact-height { --editor-group-tab-height: 20px !important; } .style-override .part.editor .tabs-container > .tab { - background-color: color-mix(in srgb, var(--vscode-foreground) 5%, transparent) !important; + background-color: transparent !important; border-right: none !important; border-radius: var(--vscode-cornerRadius-small); font-size: var(--vscode-fontSize-body1) !important; font-weight: var(--vscode-fontWeight-regular); box-shadow: none !important; margin-right: var(--vscode-spacing-size40) !important; - padding: 0 0 0 4px !important; + padding: 0 var(--vscode-spacing-size40) !important; --tab-border-top-color: transparent !important; } @@ -43,6 +58,10 @@ margin-right: calc(var(--last-tab-margin-right) + var(--vscode-spacing-size40)) !important; } +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container.wrapping .tabs-container > .tab { + border-bottom: none; +} + .style-override .part.editor .tabs-and-actions-container.wrapping .tabs-container { row-gap: var(--vscode-spacing-size40); } @@ -86,11 +105,23 @@ } .style-override .part.editor .tabs-container > .tab.tab-actions-left:not(.sticky-compact) { - padding: 0 10px 0 0 !important; + flex-direction: row; + padding: 0 var(--vscode-spacing-size40) !important; } -.style-override .part.editor .tabs-container > .tab.close-action-off:not(.dirty):not(.sticky-compact) { - padding: 0 8px 0 4px !important; +.style-override .part.editor .tabs-container > .tab.close-action-off:not(.sticky-compact) { + padding: 0 var(--vscode-spacing-size40) !important; +} + +.style-override .part.editor .tabs-container > .tab.dirty:not(.sticky-compact):not(.tab-actions-left):not(.close-action-off.dirty-border-top), +.style-override .part.editor .tabs-container > .tab.sticky:not(.sticky-compact):not(.pinned-action-off):not(.tab-actions-left) { + padding-right: var(--vscode-spacing-size240) !important; +} + +.style-override .part.editor .tabs-container > .tab.dirty.tab-actions-left:not(.sticky-compact), +.style-override .part.editor .tabs-container > .tab.sticky.tab-actions-left:not(.sticky-compact):not(.pinned-action-off) { + padding-left: var(--vscode-spacing-size240) !important; + padding-right: var(--vscode-spacing-size40) !important; } .style-override .part.editor .tabs-container > .tab.sizing-fit:not(.sticky-compact) { @@ -107,7 +138,11 @@ } .style-override .part.editor .tabs-container > .tab.active { - background-color: color-mix(in srgb, var(--vscode-foreground) 18%, transparent) !important; + background-color: var(--modern-ui-tab-active-background) !important; +} + +.style-override .part.editor .tabs-container > .tab .tab-border-bottom-container { + display: none !important; } .style-override .part.editor .tabs-container > .tab.selected:not(.active) { @@ -137,25 +172,8 @@ --tab-border-top-color: var(--vscode-tab-activeBorderTop, var(--vscode-tab-selectedBorderTop)) !important; } -.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:not(.active):hover { - background-color: color-mix(in srgb, var(--vscode-foreground) 8%, transparent) !important; -} - -/* - * Light themes: the foreground is dark, so the same foreground-based mixes read - * much heavier on a light surface than on a dark one. Tone the tab backgrounds - * down so the pills stay subtle. - */ -.style-override.monaco-workbench.vs .part.editor .tabs-container > .tab { - background-color: color-mix(in srgb, var(--vscode-foreground) 4%, transparent) !important; -} - -.style-override.monaco-workbench.vs .part.editor .tabs-container > .tab.active { - background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent) !important; -} - -.style-override.monaco-workbench.vs .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:not(.active):hover { - background-color: color-mix(in srgb, var(--vscode-foreground) 6%, transparent) !important; +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:not(.active):not(.selected):hover { + background-color: var(--modern-ui-tab-hover-background) !important; } .style-override.monaco-workbench .part.editor .tabs-container > .tab:is(.sizing-shrink, .sizing-fixed) > .tab-label > .monaco-icon-label-container { @@ -168,9 +186,116 @@ display: none; } +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:is(.sizing-shrink, .sizing-fixed) > .tab-label { + padding-right: 0; +} + +.style-override.monaco-workbench .part.editor .tabs-container > .tab > .tab-fade-hider { + display: none; +} + +/* + * Overlay tab actions on the label instead of reserving a trailing/leading + * column. The action surface inherits the tab background while revealed so + * label text and icons beneath it do not compete with the action glyph. + */ +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab > .tab-actions { + position: absolute; + z-index: 7; + top: 0; + right: 0; + bottom: 0; + margin: 0; + width: 24px; + overflow: visible; + border-radius: 0 var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0; + pointer-events: none; +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab > .tab-actions > .monaco-action-bar { + width: 24px; +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.tab-actions-left > .tab-actions { + right: auto; + left: 0; + border-radius: var(--vscode-cornerRadius-small) 0 0 var(--vscode-cornerRadius-small); +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty-border-top > .tab-actions { + top: var(--vscode-spacing-size20); +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty-border-top > .tab-actions { + top: 0; +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.close-action-off):hover > .tab-actions, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.close-action-off) > .tab-actions:focus-within { + background-color: var(--modern-ui-editor-tab-action-hover-background); + pointer-events: auto; +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active:not(.close-action-off):hover > .tab-actions, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active:not(.close-action-off) > .tab-actions:focus-within { + background-color: var(--modern-ui-editor-tab-action-active-background); +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.selected:not(.active):not(.close-action-off):hover > .tab-actions, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.selected:not(.active):not(.close-action-off) > .tab-actions:focus-within { + --tab-border-top-color: var(--vscode-tab-selectedBorderTop); + background-color: var(--vscode-tab-selectedBackground); +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active.multi-selected:not(.close-action-off):hover > .tab-actions, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active.multi-selected:not(.close-action-off) > .tab-actions:focus-within { + --tab-border-top-color: var(--vscode-tab-activeBorderTop, var(--vscode-tab-selectedBorderTop)); +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.selected:not(.active).tab-border-top:not(.close-action-off):hover > .tab-actions, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.selected:not(.active).tab-border-top:not(.close-action-off) > .tab-actions:focus-within, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active.multi-selected.tab-border-top:not(.close-action-off):hover > .tab-actions, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active.multi-selected.tab-border-top:not(.close-action-off) > .tab-actions:focus-within { + border-top: var(--vscode-strokeThickness) solid var(--tab-border-top-color); + border-right: var(--vscode-strokeThickness) solid var(--tab-border-top-color); + border-bottom: var(--vscode-strokeThickness) solid var(--tab-border-top-color); + box-sizing: border-box; +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.selected:not(.active).tab-border-top.tab-actions-left:not(.close-action-off):hover > .tab-actions, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.selected:not(.active).tab-border-top.tab-actions-left:not(.close-action-off) > .tab-actions:focus-within, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active.multi-selected.tab-border-top.tab-actions-left:not(.close-action-off):hover > .tab-actions, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active.multi-selected.tab-border-top.tab-actions-left:not(.close-action-off) > .tab-actions:focus-within { + border-right: 0; + border-left: var(--vscode-strokeThickness) solid var(--tab-border-top-color); +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.dirty):not(.sticky):not(:hover) > .tab-actions .action-label:not(:focus) { + opacity: 0; +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty > .tab-actions .action-label, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.sticky:not(.pinned-action-off) > .tab-actions .action-label, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.close-action-off):hover > .tab-actions .action-label, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab > .tab-actions .action-label:focus { + opacity: 1; +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty:not(.close-action-off):hover > .tab-actions .action-label.codicon-close::before, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty > .tab-actions .action-label.codicon-close:focus::before { + content: var(--vscode-icon-close-content); + font-family: var(--vscode-icon-close-font-family); +} + +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty:not(.close-action-off):hover > .tab-actions .action-label.codicon-pinned::before, +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty > .tab-actions .action-label.codicon-pinned:focus::before { + content: var(--vscode-icon-pinned-content); + font-family: var(--vscode-icon-pinned-font-family); +} + /* Keep scrolling tabs from showing through compact pinned pills and their spacing. */ .style-override .part.editor .tabs-container > .tab.sticky-compact { - background-color: color-mix(in srgb, var(--vscode-foreground) 5%, var(--vscode-editor-background)) !important; + background-color: transparent !important; } .style-override .part.editor .tabs-and-actions-container > .monaco-scrollable-element > .sticky-tabs-background { @@ -185,34 +310,35 @@ } .style-override.monaco-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container > .monaco-scrollable-element .scrollbar { - z-index: 6; + z-index: 11; } .style-override .part.editor .tabs-container > .tab.sticky-compact.active { - background-color: color-mix(in srgb, var(--vscode-foreground) 18%, var(--vscode-editor-background)) !important; + background-color: var(--modern-ui-tab-active-background) !important; } -.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab.sticky-compact:not(.active):hover { - background-color: color-mix(in srgb, var(--vscode-foreground) 8%, var(--vscode-editor-background)) !important; +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab.sticky-compact:not(.active):not(.selected):hover { + background-color: var(--modern-ui-tab-hover-background) !important; } -.style-override.monaco-workbench.vs .part.editor .tabs-container > .tab.sticky-compact { - background-color: color-mix(in srgb, var(--vscode-foreground) 4%, var(--vscode-editor-background)) !important; -} - -.style-override.monaco-workbench.vs .part.editor .tabs-container > .tab.sticky-compact.active { - background-color: color-mix(in srgb, var(--vscode-foreground) 10%, var(--vscode-editor-background)) !important; -} - -.style-override.monaco-workbench.vs .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab.sticky-compact:not(.active):hover { - background-color: color-mix(in srgb, var(--vscode-foreground) 6%, var(--vscode-editor-background)) !important; -} - -.style-override .part.editor .tabs-container > .tab .tab-border-top-container, -.style-override .part.editor .tabs-container > .tab .tab-border-bottom-container { +.style-override .part.editor .tabs-container > .tab .tab-border-top-container { display: none !important; } +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty.dirty-border-top > .tab-border-top-container { + display: block !important; + position: absolute; + z-index: 8; + top: 0; + left: 0; + width: 100% !important; + height: 2px !important; + border: none; + border-radius: var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0 0; + background-color: var(--tab-dirty-border-top-color) !important; + pointer-events: none; +} + .style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.drop-target-left:not(.last-in-row):not(:last-child)::after { display: none; } @@ -223,96 +349,194 @@ } /* - * Panel tabs (TERMINAL, PROBLEMS, OUTPUT, DEBUG CONSOLE ...). These are - * composite-bar action items rather than editor `.tab` elements, with an - * underline `.active-item-indicator`. Give them the same rounded-pill look as - * the editor tabs: normal case, Body 1 / 600 type, a subtle background on the - * active tab and no underline. + * Pane tabs (primary side bar, panel and auxiliary side bar) are text composite + * actions. Keep icon-only activity items on their existing treatment. */ -.style-override .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { +.style-override .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon), +.style-override .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) { text-transform: none !important; padding: 0 8px; border-radius: var(--vscode-cornerRadius-small); } -/* - * The text lives in `.action-label`, which carries its own `font-size: 11px` - * from the base action bar styles. Setting the size on `.action-item` alone - * does not reach the label (an explicit font-size on the element wins over the - * inherited value), so the type must be set on the label itself. - */ -.style-override .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { +.style-override .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) .action-label, +.style-override .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) .action-label { font-size: var(--vscode-fontSize-body1); font-weight: var(--vscode-fontWeight-semiBold); line-height: 22px; /* keep consistent with other 22px title/control heights */ } -.style-override .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { - background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent) !important; +.style-override .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon).checked, +.style-override .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon).checked, +.style-override.monaco-workbench:not(.hc-black):not(.hc-light) .part.auxiliarybar > .header-or-footer > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon.checked:not(:active), +.style-override.monaco-workbench:not(.hc-black):not(.hc-light) .pane-composite-part.basepanel > .title > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon.checked:not(:active) { + background-color: var(--modern-ui-tab-active-background) !important; +} + +.style-override .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon):not(.checked):hover, +.style-override .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon):not(.checked):hover { + background-color: var(--modern-ui-tab-hover-background); +} + +.style-override.monaco-workbench:not(.hc-black):not(.hc-light) .part.auxiliarybar > .header-or-footer > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon:not(.checked):not(:active):hover, +.style-override.monaco-workbench:not(.hc-black):not(.hc-light) .pane-composite-part.basepanel > .title > .composite-bar-container:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) > .composite-bar > .monaco-action-bar .action-item.icon:not(.checked):not(:active):hover { + background-color: var(--vscode-list-hoverBackground); +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab, +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab.active, +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab.selected, +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:hover { + background-color: transparent !important; + box-shadow: none !important; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:hover > .tab-actions, +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab > .tab-actions:focus-within { + background-color: var(--vscode-editorGroupHeader-tabsBackground, var(--vscode-editor-background)) !important; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab > .tab-actions .action-label { + background-color: transparent !important; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:is(.active, .selected):hover > .tab-actions { + border-top: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); + border-right: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); + box-sizing: border-box; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:not(.active):not(.selected):hover > .tab-actions { + border-top: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder); + border-right: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder); + border-bottom: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder); + box-sizing: border-box; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab > .tab-actions:focus-within { + border-top: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + border-right: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + box-sizing: border-box; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:is(.active, .selected).tab-actions-left:hover > .tab-actions { + border-right: 0; + border-left: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:not(.active):not(.selected).tab-actions-left:hover > .tab-actions { + border-right: 0; + border-left: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder); +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab.tab-actions-left > .tab-actions:focus-within { + border-right: 0; + border-left: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:is(.active, .selected):not(:focus) { + outline: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder) !important; + outline-offset: calc(-1 * var(--vscode-strokeThickness)) !important; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:not(.active):not(.selected):not(:focus):hover { + background-color: transparent !important; + outline: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder) !important; + outline-offset: calc(-1 * var(--vscode-strokeThickness)) !important; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:focus { + background-color: transparent !important; + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder) !important; + outline-offset: calc(-1 * var(--vscode-strokeThickness)) !important; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:not(.active):not(.selected):focus:hover { + background-color: transparent !important; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab .tab-label, +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab .tab-label a { + outline: none !important; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab.active > .tab-border-bottom-container { + display: none; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:hover > .tab-border-bottom-container { + display: none; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:not(.dirty):not(.sticky):not(:hover) > .tab-actions .action-label:not(:focus) { + opacity: 0 !important; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab.dirty > .tab-actions .action-label, +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab.sticky:not(.pinned-action-off) > .tab-actions .action-label, +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:hover > .tab-actions .action-label, +:is(.hc-black, .hc-light).style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab > .tab-actions .action-label:focus { + opacity: 1 !important; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item, +:is(.hc-black, .hc-light).style-override.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item, +:is(.hc-black, .hc-light).style-override.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator, +:is(.hc-black, .hc-light).style-override.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator { + background-color: transparent !important; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon, +:is(.hc-black, .hc-light).style-override.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon { + box-sizing: border-box; + flex: 0 0 24px; + width: 24px; + min-width: 24px; + max-width: 24px; + height: 24px; + min-height: 24px; + max-height: 24px; + padding: 0; +} + +:is(.hc-black, .hc-light).style-override.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .action-label, +:is(.hc-black, .hc-light).style-override.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label, +:is(.hc-black, .hc-light).style-override.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .action-label, +:is(.hc-black, .hc-light).style-override.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:hover .action-label { + outline: none !important; +} + +:is(.hc-black, .hc-light).style-override .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator:before, +:is(.hc-black, .hc-light).style-override .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator:before { + display: none !important; +} + +:is(.hc-black, .hc-light).style-override .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus), +:is(.hc-black, .hc-light).style-override .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked:not(:focus) { + outline: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +:is(.hc-black, .hc-light).style-override .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.checked):not(:focus):hover, +:is(.hc-black, .hc-light).style-override .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.checked):not(:focus):hover { + outline: var(--vscode-strokeThickness) dashed var(--vscode-contrastActiveBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +:is(.hc-black, .hc-light).style-override .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus, +:is(.hc-black, .hc-light).style-override .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder) !important; + outline-offset: calc(-1 * var(--vscode-strokeThickness)); } /* - * Drop the underline active indicator in favour of the pill background. The - * default rules set the border with `!important`, so match their specificity - * (with the extra `.style-override` class winning) for the checked and - * focused states. + * Drop the checked underline in favour of the rounded background while retaining + * the base keyboard-focus indicator. */ -.style-override .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before, -.style-override .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { - border-top-color: transparent !important; - border-top-width: 0 !important; - border-bottom-color: transparent !important; - border-bottom-width: 0 !important; -} - -/* - * Auxiliary bar (secondary side bar) composite tabs (e.g. CHAT, EXTENSIONS). - * Same composite-bar action items as the panel tabs, but the active view - * switcher can live under `.title` or the activity-bar `.header-or-footer` - * depending on the activity bar position, so both locations are covered. Give - * them the rounded-pill look: normal case, Body 1 / 600 type, a subtle background - * on the active tab and no underline indicator. - */ -.style-override .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item, -.style-override .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { - text-transform: none !important; - padding: 0 8px; - border-radius: var(--vscode-cornerRadius-small); -} - -/* - * The text lives in `.action-label`, which carries its own `font-size: 11px` - * from the base action bar styles, so the type must be set on the label itself. - */ -.style-override .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, -.style-override .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { - font-size: var(--vscode-fontSize-body1); - font-weight: var(--vscode-fontWeight-semiBold); - line-height: 22px; /* keep consistent with other 22px title/control heights */ -} - -.style-override .part.sidebar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, -.style-override .part.sidebar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { - font-size: var(--vscode-fontSize-body1); - font-weight: var(--vscode-fontWeight-semiBold); -} - -.style-override .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked, -.style-override .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { - background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent) !important; -} - -/* - * Drop the underline active indicator in favour of the pill background. The - * base rules (auxiliaryBarPart.css / paneCompositePart.css) paint the indicator - * with `!important` from `.part.auxiliarybar` selectors, so match their - * specificity (with the extra `.style-override` class winning) across the - * `.title` and `.header-or-footer` checked and focused states. - */ -.style-override .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before, -.style-override .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before, -.style-override .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before, -.style-override .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before { +.style-override .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon).checked:not(:focus) .active-item-indicator:before, +.style-override .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon).checked:not(:focus) .active-item-indicator:before { border-top-color: transparent !important; border-top-width: 0 !important; border-bottom-color: transparent !important; diff --git a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts index 327e9f6e2b6..5515eb691ab 100644 --- a/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts +++ b/src/vs/workbench/contrib/terminal/browser/chatTerminalCommandMirror.ts @@ -3,13 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { getWindow } from '../../../../base/browser/dom.js'; import { Sequencer } from '../../../../base/common/async.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import type { IMarker as IXtermMarker, Terminal as RawXtermTerminal } from '@xterm/xterm'; import type { ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js'; -import { ITerminalService, type IDetachedTerminalInstance } from './terminal.js'; +import { ITerminalService, type IDetachedTerminalInstance, type IDetachedXtermTerminal } from './terminal.js'; import { DetachedProcessInfo } from './detachedTerminal.js'; import { XtermTerminal } from './xterm/xtermTerminal.js'; import { TERMINAL_BACKGROUND_COLOR } from '../common/terminalColorRegistry.js'; @@ -21,6 +22,7 @@ import { Color } from '../../../../base/common/color.js'; import type { IChatTerminalToolInvocationData } from '../../chat/common/chatService/chatService.js'; import type { IColorTheme } from '../../../../platform/theme/common/themeService.js'; import { ICurrentPartialCommand } from '../../../../platform/terminal/common/capabilities/commandDetection/terminalCommand.js'; +import type { ITerminalFont } from '../common/terminal.js'; function getChatTerminalBackgroundColor(theme: IColorTheme, contextKeyService: IContextKeyService, storedBackground?: string): Color | undefined { if (storedBackground) { @@ -99,6 +101,7 @@ export interface IDetachedTerminalCommandMirrorRenderResult { interface IDetachedTerminalCommandMirror { attach(container: HTMLElement): Promise<void>; renderCommand(): Promise<IDetachedTerminalCommandMirrorRenderResult | undefined>; + layout(widthPx: number): Promise<IDetachedTerminalCommandMirrorRenderResult | undefined>; onDidUpdate: Event<IDetachedTerminalCommandMirrorRenderResult>; onDidInput: Event<string>; } @@ -106,6 +109,12 @@ interface IDetachedTerminalCommandMirror { const enum ChatTerminalMirrorMetrics { MirrorRowCount = 10, MirrorColCountFallback = 80, + /** + * Pre-attach estimate of the horizontal space the mirror content cannot use: the gutter + * every workbench xterm gets via `.monaco-workbench .xterm { padding-left: 20px }` + * (terminal.css). Once attached, the real value is measured from computed styles. + */ + MirrorHorizontalPaddingPx = 20, /** * Maximum number of lines for which we compute the max column width. * Computing max column width iterates the entire buffer, so we skip it @@ -114,6 +123,64 @@ const enum ChatTerminalMirrorMetrics { MaxLinesForColumnWidthComputation = 100 } +/** + * Computes the number of columns a chat terminal mirror should use to fill the available width + * of its container, using the same cell math as {@link getXtermScaledDimensions}. + * + * @param availableWidthPx The container width in CSS pixels. + * @param font The terminal font with measured char metrics. + * @param devicePixelRatio The window's device pixel ratio. + * @param horizontalChromePx Horizontal space the DOM chrome takes from the container width, + * measured from computed styles when available; defaults to the static estimate. + * @returns The column count, or the default fallback when the width or font is unmeasurable. + */ +export function computeChatTerminalMirrorCols(availableWidthPx: number, font: ITerminalFont, devicePixelRatio: number, horizontalChromePx: number = ChatTerminalMirrorMetrics.MirrorHorizontalPaddingPx): number { + if (!isFinite(availableWidthPx) || availableWidthPx <= 0 || !font.charWidth) { + return ChatTerminalMirrorMetrics.MirrorColCountFallback; + } + const dpr = isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1; + const scaledWidthAvailable = (availableWidthPx - horizontalChromePx) * dpr; + const scaledCharWidth = font.charWidth * dpr + font.letterSpacing; + return Math.max(Math.floor(scaledWidthAvailable / scaledCharWidth), 1); +} + +function getMirrorRaw(detached: IDetachedTerminalInstance): RawXtermTerminal { + return (detached.xterm as IDetachedXtermTerminal & { raw: RawXtermTerminal }).raw; +} + +/** + * Enables cursor line reflow on a mirror's terminal. The mirror is a readonly output preview + * with no prompt line to protect, so resize reflow should re-wrap the cursor line like any + * other line (xterm skips it by default). + */ +function enableCursorLineReflow(detached: IDetachedTerminalInstance): void { + getMirrorRaw(detached).options.reflowCursorLine = true; +} + +/** + * Gets the device pixel ratio of the window the mirror's terminal is rendered in, so cell + * math stays correct in auxiliary windows on monitors with different scaling. + */ +function getMirrorDevicePixelRatio(detached: IDetachedTerminalInstance): number { + return getWindow(getMirrorRaw(detached).element).devicePixelRatio; +} + +/** + * Measures the horizontal space the mirror's DOM chrome takes from the container width by + * reading the xterm element's computed padding, the same way the panel terminal does. xterm's + * own scrollbar is hidden in the chat preview, so unlike the panel terminal it takes no + * space. Returns undefined before the terminal is attached. + */ +function measureMirrorHorizontalChrome(detached: IDetachedTerminalInstance): number | undefined { + const element = getMirrorRaw(detached).element; + if (!element) { + return undefined; + } + const style = getWindow(element).getComputedStyle(element); + const chrome = parseInt(style.paddingLeft) + parseInt(style.paddingRight); + return isNaN(chrome) ? undefined : Math.max(chrome, 0); +} + /** * Computes the line count for terminal output between start and end lines. * The end line is exclusive (points to the line after output ends). @@ -367,6 +434,51 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach return { lineCount: this._lineCount, maxColumnWidth: this._maxColumnWidth }; } + /** + * Resizes the mirror to fill the given width, relying on xterm's native resize reflow to + * re-wrap soft-wrapped lines. No-op when the resulting cols are unchanged. The column + * count derives from the mirror's own xterm font metrics, which reflect the actual + * renderer cell size rather than a configuration-based estimate. + */ + async layout(widthPx: number): Promise<IDetachedTerminalCommandMirrorRenderResult | undefined> { + if (this._store.isDisposed || widthPx <= 0) { + return undefined; + } + let detached: IDetachedTerminalInstance; + try { + detached = await this._getOrCreateTerminal(); + } catch (error) { + if (error instanceof CancellationError) { + return undefined; + } + throw error; + } + if (this._store.isDisposed) { + return undefined; + } + const cols = computeChatTerminalMirrorCols(widthPx, detached.xterm.getFont(), getMirrorDevicePixelRatio(detached), measureMirrorHorizontalChrome(detached)); + if (detached.xterm.cols === cols) { + return undefined; + } + // Wait for any in-flight streaming flush so the resize does not interleave with it + await this._flushPromise; + if (this._store.isDisposed || detached.xterm.cols === cols) { + return undefined; + } + // Native resize reflow re-wraps the buffer in place; rewriting the cached VT here + // instead would flash a cleared frame on every resize + detached.xterm.resize(cols, ChatTerminalMirrorMetrics.MirrorRowCount); + if (!this._lastVT) { + return undefined; + } + this._lineCount = this._getRenderedLineCount(); + const commandFinished = this._command.endMarker && !this._command.endMarker.isDisposed; + if (commandFinished && this._lineCount <= ChatTerminalMirrorMetrics.MaxLinesForColumnWidthComputation) { + this._maxColumnWidth = this._computeMaxColumnWidth(); + } + return { lineCount: this._lineCount, maxColumnWidth: this._maxColumnWidth }; + } + private async _getCommandOutputAsVT(source: XtermTerminal): Promise<{ text: string } | undefined> { if (this._store.isDisposed) { return undefined; @@ -389,6 +501,13 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach } private _getRenderedLineCount(): number { + // Prefer counting the mirror's own rendered rows: they reflect the mirror's column + // count, which can differ from the source terminal's after a width layout + const detachedBuffer = this._detachedTerminal?.xterm.buffer.active; + if (detachedBuffer) { + return computeSnapshotLineCount(detachedBuffer); + } + // Calculate line count from the command's markers when available const endMarker = this._command.endMarker; if (this._command.executedMarker && endMarker && !endMarker.isDisposed) { @@ -444,6 +563,7 @@ export class DetachedTerminalCommandMirror extends Disposable implements IDetach detached.dispose(); throw new CancellationError(); } + enableCursorLineReflow(detached); this._detachedTerminal = detached; this._register(processInfo); this._register(detached); @@ -650,6 +770,7 @@ export class DetachedTerminalSnapshotMirror extends Disposable { terminal.dispose(); return terminal; } + enableCursorLineReflow(terminal); return this._register(terminal); }); } @@ -686,6 +807,42 @@ export class DetachedTerminalSnapshotMirror extends Disposable { return this._renderSequencer.queue(() => this._render()); } + /** + * Resizes the mirror to fill the given width, relying on xterm's native resize reflow to + * re-wrap soft-wrapped lines. No-op when the resulting cols are unchanged. The column + * count derives from the mirror's own xterm font metrics, which reflect the actual + * renderer cell size rather than a configuration-based estimate. + */ + public async layout(widthPx: number): Promise<{ lineCount?: number; maxColumnWidth?: number } | undefined> { + if (widthPx <= 0) { + return undefined; + } + return this._renderSequencer.queue(async () => { + const terminal = await this._getTerminal(); + if (this._store.isDisposed) { + return undefined; + } + const cols = computeChatTerminalMirrorCols(widthPx, terminal.xterm.getFont(), getMirrorDevicePixelRatio(terminal), measureMirrorHorizontalChrome(terminal)); + if (terminal.xterm.cols === cols) { + return undefined; + } + // Native resize reflow re-wraps the rendered content in place; rewriting the + // snapshot here instead would flash a cleared frame on every resize + terminal.xterm.resize(cols, ChatTerminalMirrorMetrics.MirrorRowCount); + if (!this._lastRenderedText) { + return undefined; + } + // Same rule as _render: a truncated snapshot's buffer under-represents the real + // output, so its explicit lineCount must survive the resize + const lineCount = computeSnapshotLineCount(terminal.xterm.buffer.active, this._output?.truncated ? this._output.lineCount : undefined); + this._lastRenderedLineCount = lineCount; + if (this._shouldComputeMaxColumnWidth(lineCount)) { + this._lastRenderedMaxColumnWidth = this._computeMaxColumnWidth(terminal); + } + return { lineCount, maxColumnWidth: this._lastRenderedMaxColumnWidth }; + }); + } + private async _render(): Promise<{ lineCount?: number; maxColumnWidth?: number } | undefined> { const output = this._output; const outputVersion = this._outputVersion; @@ -723,7 +880,10 @@ export class DetachedTerminalSnapshotMirror extends Disposable { if (this._store.isDisposed) { return undefined; } - const lineCount = computeSnapshotLineCount(terminal.xterm.buffer.active, output.lineCount); + // A persisted lineCount reflects the wrap width of the source terminal, which can differ + // from this mirror's cols after a width layout. Only trust it for truncated output, + // where the text under-represents the real row count. + const lineCount = computeSnapshotLineCount(terminal.xterm.buffer.active, output.truncated ? output.lineCount : undefined); this._renderedVersion = outputVersion; this._lastRenderedText = text; this._lastRenderedLineCount = lineCount; diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts b/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts index 68e2c80e2ca..84bbf8c0cdd 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/decorationAddon.ts @@ -311,7 +311,13 @@ export class DecorationAddon extends Disposable implements ITerminalAddon, IDeco return; } if (!this._decorations.get(decoration.marker.id)) { - decoration.onDispose(() => this._decorations.delete(decoration.marker.id)); + decoration.onDispose(() => { + const disposableDecoration = this._decorations.get(decoration.marker.id); + if (disposableDecoration) { + dispose(disposableDecoration.disposables); + this._decorations.delete(decoration.marker.id); + } + }); this._decorations.set(decoration.marker.id, { decoration, diff --git a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts index 07bed4582c5..1340e363b54 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts @@ -6,15 +6,19 @@ import type { Terminal } from '@xterm/xterm'; import { deepStrictEqual, strictEqual } from 'assert'; import { importAMDNodeModule } from '../../../../../amdX.js'; +import { Event } from '../../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import type { IEditorOptions } from '../../../../../editor/common/config/editorOptions.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import type { ITerminalCommand } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { TerminalCapabilityStore } from '../../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; +import type { ITerminalFont } from '../../common/terminal.js'; +import { ITerminalService, type IDetachedTerminalInstance, type IDetachedXTermOptions } from '../../browser/terminal.js'; import { XtermTerminal } from '../../browser/xterm/xtermTerminal.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; import { TestXtermAddonImporter } from './xterm/xtermTestUtils.js'; -import { computeMaxBufferColumnWidth, computeSnapshotLineCount, vtBoundaryMatches } from '../../browser/chatTerminalCommandMirror.js'; +import { computeChatTerminalMirrorCols, computeMaxBufferColumnWidth, computeSnapshotLineCount, DetachedTerminalCommandMirror, DetachedTerminalSnapshotMirror, vtBoundaryMatches } from '../../browser/chatTerminalCommandMirror.js'; const defaultTerminalConfig = { fontFamily: 'monospace', @@ -27,6 +31,38 @@ const defaultTerminalConfig = { unicodeVersion: '6' }; +/** + * Creates a fake detached terminal instance backed by a real raw xterm.js terminal so mirror + * tests can inspect the resulting buffer and count resize/write calls. The fixed font metrics + * (charWidth 10, letterSpacing 0) make width-to-cols math deterministic on any machine. + */ +function createFakeDetachedTerminal(RawCtor: typeof Terminal, options: IDetachedXTermOptions) { + const raw = new RawCtor({ cols: options.cols, rows: options.rows }); + const counters = { resizeCalls: 0, writeCalls: 0 }; + const font: ITerminalFont = { fontFamily: 'monospace', fontSize: 12, letterSpacing: 0, lineHeight: 1, charWidth: 10, charHeight: 14 }; + const instance = { + xterm: { + raw, + get cols() { return raw.cols; }, + get rows() { return raw.rows; }, + get buffer() { return raw.buffer; }, + getFont: () => font, + write: (data: string, callback?: () => void) => { + counters.writeCalls++; + raw.write(data, callback); + }, + resize: (columns: number, rows: number) => { + counters.resizeCalls++; + raw.resize(columns, rows); + } + }, + onData: Event.None, + attachToElement: () => { }, + dispose: () => raw.dispose() + } as unknown as IDetachedTerminalInstance; + return { raw, counters, instance }; +} + suite('Workbench - ChatTerminalCommandMirror', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -619,4 +655,321 @@ suite('Workbench - ChatTerminalCommandMirror', () => { strictEqual(vtBoundaryMatches(newVT, oldVT, oldVT.length), true); }); }); + + suite('computeChatTerminalMirrorCols', () => { + + function makeFont(charWidth?: number, letterSpacing = 0): ITerminalFont { + return { fontFamily: 'monospace', fontSize: 12, letterSpacing, lineHeight: 1, charWidth, charHeight: 14 }; + } + + test('fills the available width minus the gutter', () => { + deepStrictEqual({ + wide: computeChatTerminalMirrorCols(1224, makeFont(10), 1), + floored: computeChatTerminalMirrorCols(1200, makeFont(10), 1), + }, { + wide: 120, // floor((1224 - 20) / 10) + floored: 118, // (1200 - 20) / 10 + }); + }); + + test('is stable across device pixel ratios when letter spacing is zero', () => { + strictEqual(computeChatTerminalMirrorCols(1224, makeFont(10), 2), 120); + }); + + test('accounts for letter spacing in device pixels', () => { + // floor((1224 - 24) * 2 / (10 * 2 + 1)) + strictEqual(computeChatTerminalMirrorCols(1224, makeFont(10, 1), 2), 114); + }); + + test('falls back to the default cols when width or font is unmeasurable', () => { + deepStrictEqual({ + zeroWidth: computeChatTerminalMirrorCols(0, makeFont(10), 1), + nanWidth: computeChatTerminalMirrorCols(NaN, makeFont(10), 1), + missingCharWidth: computeChatTerminalMirrorCols(1224, makeFont(undefined), 1), + zeroCharWidth: computeChatTerminalMirrorCols(1224, makeFont(0), 1), + }, { + zeroWidth: 80, + nanWidth: 80, + missingCharWidth: 80, + zeroCharWidth: 80, + }); + }); + + test('treats an invalid device pixel ratio as 1', () => { + strictEqual(computeChatTerminalMirrorCols(1224, makeFont(10), 0), 120); + }); + + test('uses an explicitly measured horizontal chrome over the default', () => { + deepStrictEqual({ + none: computeChatTerminalMirrorCols(1200, makeFont(10), 1, 0), + measured: computeChatTerminalMirrorCols(1224, makeFont(10), 1, 24), + }, { + none: 120, + measured: 120, + }); + }); + + test('narrow widths wrap to the fitting column count, minimum one column', () => { + deepStrictEqual({ + narrow: computeChatTerminalMirrorCols(100, makeFont(10), 1), // (100 - 20) / 10 + tiny: computeChatTerminalMirrorCols(25, makeFont(10), 1), + }, { + narrow: 8, + tiny: 1, + }); + }); + }); + + suite('DetachedTerminalSnapshotMirror.layout', () => { + let instantiationService: TestInstantiationService; + let XTermBaseCtor: typeof Terminal; + let fakes: ReturnType<typeof createFakeDetachedTerminal>[]; + + setup(async () => { + instantiationService = workbenchInstantiationService(undefined, store); + XTermBaseCtor = (await importAMDNodeModule<typeof import('@xterm/xterm')>('@xterm/xterm', 'lib/xterm.js')).Terminal; + fakes = []; + instantiationService.stub(ITerminalService, { + createDetachedTerminal: async (options: IDetachedXTermOptions) => { + const fake = createFakeDetachedTerminal(XTermBaseCtor, options); + fakes.push(fake); + return fake.instance; + } + } as Partial<ITerminalService>); + }); + + function createSnapshotMirror(output: { text: string; truncated?: boolean; lineCount?: number } | undefined): DetachedTerminalSnapshotMirror { + return store.add(instantiationService.createInstance(DetachedTerminalSnapshotMirror, output, () => undefined)); + } + + test('resizes the detached terminal to cols computed from the width', async () => { + const mirror = createSnapshotMirror({ text: 'hello' }); + await mirror.layout(1224); // floor((1224 - 20) / 10) = 120 cols + strictEqual(fakes.length, 1); + strictEqual(fakes[0].raw.cols, 120); + }); + + test('first render after layout wraps at the new cols', async () => { + const mirror = createSnapshotMirror({ text: 'x'.repeat(100) }); + await mirror.layout(1224); + await mirror.render(); + deepStrictEqual({ + cols: fakes[0].raw.cols, + lineCount: computeSnapshotLineCount(fakes[0].raw.buffer.active), + maxColumnWidth: computeMaxBufferColumnWidth(fakes[0].raw.buffer.active, fakes[0].raw.cols), + }, { + cols: 120, + lineCount: 1, + maxColumnWidth: 100, + }); + }); + + test('re-wraps already rendered output at the new cols without rewriting', async () => { + const mirror = createSnapshotMirror({ text: 'x'.repeat(100) }); + await mirror.render(); + strictEqual(computeSnapshotLineCount(fakes[0].raw.buffer.active), 2); + const writeCallsBeforeLayout = fakes[0].counters.writeCalls; + await mirror.layout(1224); + deepStrictEqual({ + cols: fakes[0].raw.cols, + lineCount: computeSnapshotLineCount(fakes[0].raw.buffer.active), + maxColumnWidth: computeMaxBufferColumnWidth(fakes[0].raw.buffer.active, fakes[0].raw.cols), + // Re-wrapping must come from xterm's native resize reflow, not a buffer + // rewrite, which would flash a cleared frame on every resize + writeCalls: fakes[0].counters.writeCalls, + }, { + cols: 120, + lineCount: 1, + maxColumnWidth: 100, + writeCalls: writeCallsBeforeLayout, + }); + }); + + test('repeated layout with the same width does not resize or rewrite', async () => { + const mirror = createSnapshotMirror({ text: 'x'.repeat(100) }); + await mirror.render(); + await mirror.layout(1224); + const { resizeCalls, writeCalls } = { ...fakes[0].counters }; + await mirror.layout(1224); + deepStrictEqual(fakes[0].counters, { resizeCalls, writeCalls }); + }); + + test('ignores non-positive widths', async () => { + const mirror = createSnapshotMirror({ text: 'hello' }); + await mirror.layout(0); + await mirror.layout(-10); + strictEqual(fakes[0].raw.cols, 80); + }); + + test('drops a persisted lineCount that reflects the old wrap width', async () => { + // Producers persist lineCount wrapped at the source terminal's cols; after a + // width layout the rendered row count is the ground truth for the box height + const mirror = createSnapshotMirror({ text: 'x'.repeat(100), lineCount: 2 }); + await mirror.layout(1224); + const result = await mirror.render(); + strictEqual(result?.lineCount, 1); + }); + + test('keeps an explicit lineCount for truncated output', async () => { + // Truncated snapshots under-represent the real output, so the persisted count wins + const mirror = createSnapshotMirror({ text: 'short', truncated: true, lineCount: 42 }); + const result = await mirror.render(); + strictEqual(result?.lineCount, 42); + }); + + test('keeps a truncated snapshot height across layout', async () => { + const mirror = createSnapshotMirror({ text: 'x'.repeat(100), truncated: true, lineCount: 42 }); + const first = await mirror.render(); + const laidOut = await mirror.layout(1224); + const cached = await mirror.render(); + deepStrictEqual({ + first: first?.lineCount, + laidOut: laidOut?.lineCount, + cached: cached?.lineCount, + }, { + first: 42, + laidOut: 42, + cached: 42, + }); + }); + + test('measures horizontal chrome from the attached element computed padding', async () => { + const mirror = createSnapshotMirror({ text: 'hello' }); + const container = document.createElement('div'); + document.body.appendChild(container); + try { + fakes[0].raw.open(container); + fakes[0].raw.element!.style.paddingLeft = '4px'; + fakes[0].raw.element!.style.paddingRight = '0px'; + await mirror.layout(1224); + strictEqual(fakes[0].raw.cols, 122); // floor((1224 - 4) / 10) + } finally { + container.remove(); + } + }); + }); + + suite('DetachedTerminalCommandMirror.layout', () => { + let instantiationService: TestInstantiationService; + let XTermBaseCtor: typeof Terminal; + let fakes: ReturnType<typeof createFakeDetachedTerminal>[]; + + setup(async () => { + const configurationService = new TestConfigurationService({ + editor: { + fastScrollSensitivity: 2, + mouseWheelScrollSensitivity: 1 + } as Partial<IEditorOptions>, + files: {}, + terminal: { + integrated: defaultTerminalConfig + }, + }); + instantiationService = workbenchInstantiationService({ + configurationService: () => configurationService + }, store); + XTermBaseCtor = (await importAMDNodeModule<typeof import('@xterm/xterm')>('@xterm/xterm', 'lib/xterm.js')).Terminal; + fakes = []; + instantiationService.stub(ITerminalService, { + createDetachedTerminal: async (options: IDetachedXTermOptions) => { + const fake = createFakeDetachedTerminal(XTermBaseCtor, options); + fakes.push(fake); + return fake.instance; + } + } as Partial<ITerminalService>); + }); + + async function createXterm(cols = 80, rows = 10): Promise<XtermTerminal> { + const capabilities = store.add(new TerminalCapabilityStore()); + return store.add(instantiationService.createInstance(XtermTerminal, undefined, XTermBaseCtor, { + cols, + rows, + xtermColorProvider: { getBackgroundColor: () => undefined }, + capabilities, + disableShellIntegrationReporting: true, + xtermAddonImporter: new TestXtermAddonImporter(), + }, undefined)); + } + + function write(xterm: XtermTerminal, data: string): Promise<void> { + return new Promise<void>(resolve => xterm.write(data, resolve)); + } + + function lineText(raw: Terminal, y: number): string { + return raw.buffer.active.getLine(y)?.translateToString(true) ?? ''; + } + + /** + * Writes a finished command whose output is a single 100 character line, which soft-wraps + * onto two rows in the 80 column source terminal. + */ + async function createWrappedCommand(source: XtermTerminal): Promise<ITerminalCommand> { + const executedMarker = source.raw.registerMarker(0)!; + await write(source, 'x'.repeat(100) + '\r\n'); + const endMarker = source.raw.registerMarker(0)!; + return { executedMarker, endMarker } as unknown as ITerminalCommand; + } + + function createCommandMirror(source: XtermTerminal, command: ITerminalCommand): DetachedTerminalCommandMirror { + return store.add(instantiationService.createInstance(DetachedTerminalCommandMirror, source, command)); + } + + test('resizes before any render without writing content', async () => { + const source = await createXterm(); + const command = await createWrappedCommand(source); + const mirror = createCommandMirror(source, command); + await mirror.layout(1224); // floor((1224 - 20) / 10) = 120 cols + deepStrictEqual({ + cols: fakes[0].raw.cols, + writeCalls: fakes[0].counters.writeCalls, + }, { + cols: 120, + writeCalls: 0, + }); + }); + + test('re-wraps rendered command output at the new cols without rewriting', async () => { + const source = await createXterm(); + const command = await createWrappedCommand(source); + const mirror = createCommandMirror(source, command); + await mirror.renderCommand(); + deepStrictEqual({ + line0: lineText(fakes[0].raw, 0), + line1: lineText(fakes[0].raw, 1), + }, { + line0: 'x'.repeat(80), + line1: 'x'.repeat(20), + }); + const writeCallsBeforeLayout = fakes[0].counters.writeCalls; + const result = await mirror.layout(1224); + deepStrictEqual({ + cols: fakes[0].raw.cols, + line0: lineText(fakes[0].raw, 0), + maxColumnWidth: computeMaxBufferColumnWidth(fakes[0].raw.buffer.active, fakes[0].raw.cols), + // The reported line count must reflect the re-wrapped mirror rows, not the + // source terminal's wrap at its own cols, so the box height matches + lineCount: result?.lineCount, + // Re-wrapping must come from xterm's native resize reflow, not a buffer + // rewrite, which would flash a cleared frame on every resize + writeCalls: fakes[0].counters.writeCalls, + }, { + cols: 120, + line0: 'x'.repeat(100), + maxColumnWidth: 100, + lineCount: 1, + writeCalls: writeCallsBeforeLayout, + }); + }); + + test('repeated layout with the same width does not resize or rewrite', async () => { + const source = await createXterm(); + const command = await createWrappedCommand(source); + const mirror = createCommandMirror(source, command); + await mirror.renderCommand(); + await mirror.layout(1224); + const { resizeCalls, writeCalls } = { ...fakes[0].counters }; + await mirror.layout(1224); + deepStrictEqual(fakes[0].counters, { resizeCalls, writeCalls }); + }); + }); }); diff --git a/src/vs/workbench/contrib/terminal/test/browser/xterm/decorationAddon.test.ts b/src/vs/workbench/contrib/terminal/test/browser/xterm/decorationAddon.test.ts index 62d3ec2455e..f40f8a88486 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/xterm/decorationAddon.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/xterm/decorationAddon.test.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import type { IDecoration, IDecorationOptions, Terminal as RawXtermTerminal } from '@xterm/xterm'; -import { notEqual, strictEqual, throws } from 'assert'; +import { deepStrictEqual, notEqual, strictEqual, throws } from 'assert'; import { importAMDNodeModule } from '../../../../../../amdX.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { ITerminalCommand, TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; import { CommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/commandDetectionCapability.js'; import { TerminalCapabilityStore } from '../../../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; @@ -20,8 +21,12 @@ suite('DecorationAddon', () => { let decorationAddon: DecorationAddon; let xterm: RawXtermTerminal; + let hoverDisposed: boolean; + let removedEventListeners: string[]; setup(async () => { + hoverDisposed = false; + removedEventListeners = []; const TerminalCtor = (await importAMDNodeModule<typeof import('@xterm/xterm')>('@xterm/xterm', 'lib/xterm.js')).Terminal; class TestTerminal extends TerminalCtor { override registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined { @@ -29,7 +34,33 @@ suite('DecorationAddon', () => { return undefined; } const element = document.createElement('div'); - return { marker: decorationOptions.marker, element, onDispose: () => { }, isDisposed: false, dispose: () => { }, onRender: (element: HTMLElement) => { return element; } } as unknown as IDecoration; + const removeEventListener = element.removeEventListener.bind(element); + element.removeEventListener = ((...args: Parameters<typeof element.removeEventListener>) => { + removedEventListeners.push(args[0]); + removeEventListener(...args); + }) as typeof element.removeEventListener; + const disposeListeners = new Set<() => void>(); + let isDisposed = false; + return { + marker: decorationOptions.marker, + element, + onDispose: (listener: () => void) => { + disposeListeners.add(listener); + return { dispose: () => disposeListeners.delete(listener) }; + }, + get isDisposed() { return isDisposed; }, + dispose: () => { + isDisposed = true; + for (const listener of disposeListeners) { + listener(); + } + disposeListeners.clear(); + }, + onRender: (listener: (element: HTMLElement) => void) => { + listener(element); + return { dispose: () => { } }; + } + } as unknown as IDecoration; } } @@ -48,6 +79,9 @@ suite('DecorationAddon', () => { } }) }, store); + instantiationService.stub(IHoverService, { + setupDelayedHover: () => ({ dispose: () => hoverDisposed = true }) + } as unknown as IHoverService); xterm = store.add(new TestTerminal({ allowProposedApi: true, cols: 80, @@ -77,5 +111,16 @@ suite('DecorationAddon', () => { const marker = xterm.registerMarker(2); notEqual(decorationAddon.registerCommandDecoration(undefined, undefined, { marker }), undefined); }); + test('should dispose decoration resources when the decoration is disposed', () => { + const marker = xterm.registerMarker(2)!; + const decoration = decorationAddon.registerCommandDecoration({ command: 'cd src', marker, exitCode: 0, timestamp: Date.now(), hasOutput: () => false } as ITerminalCommand)!; + const decorations = (decorationAddon as unknown as { _decorations: Map<number, unknown> })._decorations; + + decoration.dispose(); + + strictEqual(hoverDisposed, true); + deepStrictEqual(removedEventListeners.sort(), ['click', 'contextmenu', 'mousedown']); + strictEqual(decorations.has(marker.id), false); + }); }); }); diff --git a/src/vs/workbench/services/editor/common/editorResolverService.ts b/src/vs/workbench/services/editor/common/editorResolverService.ts index b6bc4ac8eca..ec0a6eb950c 100644 --- a/src/vs/workbench/services/editor/common/editorResolverService.ts +++ b/src/vs/workbench/services/editor/common/editorResolverService.ts @@ -59,6 +59,10 @@ export function editorsAssociationsAgentsWindowDefault(options?: { markdownDefau }; } +export function diffEditorsAssociationsAgentsWindowDefault(options?: { markdownDefaultEditor?: boolean }): Record<string, string> { + return editorsAssociationsAgentsWindowDefault(options); +} + const configurationRegistry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration); const editorAssociationsConfigurationNode: IConfigurationNode = { @@ -86,6 +90,9 @@ const editorAssociationsConfigurationNode: IConfigurationNode = { markdownDescription: localize('editor.diffEditorAssociations', "Configure [glob patterns](https://aka.ms/vscode-glob-patterns) to editors for diff views (for example `\"*.md\": \"vscode.markdown.preview.editor\"`). These override `workbench.editorAssociations` for diffs."), additionalProperties: { type: 'string' + }, + agentsWindow: { + default: diffEditorsAssociationsAgentsWindowDefault() } } } diff --git a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts index 0883de52895..4a350275cf7 100644 --- a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts @@ -12,11 +12,21 @@ import { EditorPart } from '../../../../browser/parts/editor/editorPart.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; import { EditorResolverService } from '../../browser/editorResolverService.js'; import { IEditorGroupsService } from '../../common/editorGroupsService.js'; -import { IEditorResolverService, ResolvedStatus, RegisteredEditorPriority, diffEditorsAssociationsSettingId, editorsAssociationsSettingId } from '../../common/editorResolverService.js'; +import { diffEditorsAssociationsAgentsWindowDefault, IEditorResolverService, ResolvedStatus, RegisteredEditorPriority, diffEditorsAssociationsSettingId, editorsAssociationsSettingId } from '../../common/editorResolverService.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { createEditorPart, ITestInstantiationService, TestFileEditorInput, TestServiceAccessor, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; suite('EditorResolverService', () => { + test('Agents window diff editor default follows the Markdown editor setting', () => { + assert.deepStrictEqual({ + enabled: diffEditorsAssociationsAgentsWindowDefault({ markdownDefaultEditor: true }), + disabled: diffEditorsAssociationsAgentsWindowDefault({ markdownDefaultEditor: false }), + }, { + enabled: { '*.md': 'vscode.markdown.editor' }, + disabled: { '*.md': 'vscode.markdown.preview.editor' }, + }); + }); + const TEST_EDITOR_INPUT_ID = 'testEditorInputForEditorResolverService'; const disposables = new DisposableStore(); diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index c860606b45f..c9e81066784 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -26,6 +26,7 @@ export const enum Parts { PANEL_PART = 'workbench.parts.panel', AUXILIARYBAR_PART = 'workbench.parts.auxiliarybar', SESSIONS_PART = 'workbench.parts.sessions', + CUSTOM_VIEW_GRID_PART = 'workbench.parts.customViewGrid', EDITOR_PART = 'workbench.parts.editor', STATUSBAR_PART = 'workbench.parts.statusbar' } diff --git a/src/vs/workbench/services/localTranscription/browser/localTranscriptionService.ts b/src/vs/workbench/services/localTranscription/browser/localTranscriptionService.ts index 98e8e13fd09..e4d230d7674 100644 --- a/src/vs/workbench/services/localTranscription/browser/localTranscriptionService.ts +++ b/src/vs/workbench/services/localTranscription/browser/localTranscriptionService.ts @@ -26,6 +26,10 @@ export class NullLocalTranscriptionService implements ILocalTranscriptionService return { state: LocalTranscriptionModelState.Error, error: 'unsupported' }; } + async importModel(): Promise<never> { + throw new Error('On-device transcription is not supported in this environment.'); + } + async start(): Promise<void> { throw new Error('On-device transcription is not supported in this environment.'); } diff --git a/src/vs/workbench/services/localTranscription/electron-browser/localTranscriptionService.ts b/src/vs/workbench/services/localTranscription/electron-browser/localTranscriptionService.ts index 3c9484d9604..c15ef4b2e18 100644 --- a/src/vs/workbench/services/localTranscription/electron-browser/localTranscriptionService.ts +++ b/src/vs/workbench/services/localTranscription/electron-browser/localTranscriptionService.ts @@ -82,6 +82,7 @@ export class LocalTranscriptionService { get onDidTranscribe() { return this._getProxy().onDidTranscribe; } getModelStatus() { return this._getProxy().getModelStatus(); } + importModel(options: Parameters<ILocalTranscriptionService['importModel']>[0]) { return this._getProxy().importModel(options); } start(options: { cacheDir: string; model?: string; language?: string }) { const { proxyUrl, noProxy, proxyStrictSSL, proxyAuthorization } = this._resolveProxyConfig(); const runtime = this.productService.dictationRuntime; diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index bdddbc140e3..e25eb3feb57 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -173,7 +173,11 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I reg.define(IChatService, MockChatService); reg.defineInstance(IChatPetService, new class extends mock<IChatPetService>() { override readonly enabled = observableValue('chatPetEnabled', false); + override readonly variant = observableValue('chatPetVariant', 'stable' as const); + override readonly onTheRun = observableValue('chatPetOnTheRun', false); override toggle() { return false; } + override setVariant() { } + override setOnTheRun() { } }()); reg.defineInstance(IChatWidgetService, new class extends mock<IChatWidgetService>() { override readonly lastFocusedWidget = undefined; @@ -263,7 +267,10 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I override announceRendered() { } }()); reg.defineInstance(IChatSubmitRequestHandlerService, new ChatSubmitRequestHandlerService()); - reg.defineInstance(IAgentSessionsService, new class extends mock<IAgentSessionsService>() { override readonly model = new class extends mock<IAgentSessionsService['model']>() { override readonly onDidChangeSessions = Event.None; }(); }()); + reg.defineInstance(IAgentSessionsService, new class extends mock<IAgentSessionsService>() { + override readonly model = new class extends mock<IAgentSessionsService['model']>() { override readonly onDidChangeSessions = Event.None; }(); + override getSession() { return undefined; } + }()); // Agent-host chat widgets (e.g. the turn changes summary fixtures) create the // generic config chips lane, which opens a session subscription. Return an // inert, never-hydrating subscription (value `undefined`) so no config chips diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index cb46bff2ef2..e358c8f11f4 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -297,6 +297,7 @@ interface IRenderOptions { readonly filePath?: 'on' | 'off' | 'last'; readonly icons?: boolean; }; + readonly breadcrumbsRightInset?: number; readonly width?: number; /** Whether this group is the active group. Inactive groups exercise the * `alwaysShowEditorActions` filtering and unfocused tab styling. */ @@ -470,7 +471,7 @@ function renderTabBar(ctx: ComponentFixtureContext, options: IRenderOptions): vo titleControl.layout({ container: new Dimension(width, titleControl.getHeight().total), available: new Dimension(width, 200), - }); + }, options.breadcrumbsRightInset); }; groupView.relayoutFn = layout; @@ -505,6 +506,7 @@ function createFixtures(modernUI: boolean, additionalThemes: readonly ComponentF // breadcrumbs BreadcrumbsFilePathLast: defineComponentFixture({ render: render(modernUI, { breadcrumbs: { filePath: 'last' }, editors: nestedActiveEditorSpecs() }) }), BreadcrumbsIconsOff: defineComponentFixture({ render: render(modernUI, { breadcrumbs: { icons: false } }) }), + BreadcrumbsWithRightInset: defineComponentFixture({ render: render(modernUI, { breadcrumbs: {}, breadcrumbsRightInset: 300 }) }), // tabSizing TabSizingShrink: defineComponentFixture({ render: render(modernUI, { partOptions: { tabSizing: 'shrink' }, editors: manyEditorSpecs() }) }), diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/customViewNode.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/customViewNode.fixture.ts new file mode 100644 index 00000000000..058dfd9f925 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/customViewNode.fixture.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../../../base/browser/dom.js'; +import { constObservable, IObservable } from '../../../../../base/common/observable.js'; +import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; +// eslint-disable-next-line local/code-import-patterns +import { AbstractCustomView, ICustomViewDescriptor } from '../../../../../sessions/services/customView/browser/customView.js'; +// eslint-disable-next-line local/code-import-patterns +import { CustomViewNode } from '../../../../../sessions/browser/parts/customViewNode.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; + +const NODE_WIDTH = 720; +const NODE_HEIGHT = 320; + +class FixtureCustomView extends AbstractCustomView { + + readonly title: IObservable<string>; + override readonly description: IObservable<string | undefined>; + override readonly maxWidth: number | undefined; + + constructor( + title: string, + description: string | undefined, + private readonly _itemCount: number, + maxWidth?: number, + ) { + super(); + this.title = constObservable(title); + this.description = constObservable(description); + this.maxWidth = maxWidth; + } + + render(container: HTMLElement): void { + for (let i = 0; i < this._itemCount; i++) { + container.appendChild($('div', undefined, `Item ${i + 1}`)); + } + } + + layout(): void { } +} + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + CustomViewNodeTitleOnly: defineComponentFixture({ + render: ctx => renderNode(ctx, { title: 'Automations', itemCount: 4 }), + }), + CustomViewNodeWithDescription: defineComponentFixture({ + render: ctx => renderNode(ctx, { + title: 'Automations', + description: 'Scheduled agents that run on a trigger, defined in this workspace.', + itemCount: 40, + }), + }), + CustomViewNodeNarrowMaxWidth: defineComponentFixture({ + render: ctx => renderNode(ctx, { title: 'Automations', itemCount: 4, maxWidth: 360 }), + }), +}); + +interface IFixtureOptions { + readonly title: string; + readonly description?: string; + readonly itemCount: number; + readonly maxWidth?: number; +} + +function renderNode(ctx: ComponentFixtureContext, options: IFixtureOptions): void { + const { container, disposableStore } = ctx; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: ctx.theme, + additionalServices: reg => registerWorkbenchServices(reg), + }); + + // The node reads the session-view surface colors that the hosting part sets. + container.style.width = `${NODE_WIDTH}px`; + container.style.height = `${NODE_HEIGHT}px`; + container.style.setProperty('--session-view-background', 'var(--vscode-agentsPanel-background, var(--vscode-sideBar-background))'); + container.style.setProperty('--session-view-foreground', 'var(--vscode-agentsPanel-foreground, var(--vscode-sideBar-foreground))'); + container.style.backgroundColor = 'var(--session-view-background)'; + + const descriptor: ICustomViewDescriptor = { + id: 'fixture.customView', + ctor: new SyncDescriptor(FixtureCustomView, [options.title, options.description, options.itemCount, options.maxWidth]), + }; + + const node = disposableStore.add(instantiationService.createInstance(CustomViewNode, descriptor)); + node.element.style.height = '100%'; + container.appendChild(node.element); + node.layout(NODE_WIDTH, NODE_HEIGHT); +} diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/githubFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/githubFixtureUtils.ts index 537f0fb2984..cc600799697 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/githubFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/githubFixtureUtils.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IReference, ReferenceCollection } from '../../../../../base/common/lifecycle.js'; +import { Disposable, IDisposable, IReference, ReferenceCollection } from '../../../../../base/common/lifecycle.js'; import { constObservable, IObservable } from '../../../../../base/common/observable.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; @@ -16,9 +16,13 @@ import { GitHubPullRequestCIModel } from '../../../../../sessions/contrib/github // eslint-disable-next-line local/code-import-patterns import { GitHubPullRequestReviewThreadsModel } from '../../../../../sessions/contrib/github/browser/models/githubPullRequestReviewThreadsModel.js'; // eslint-disable-next-line local/code-import-patterns +import { GitHubIssueModel } from '../../../../../sessions/contrib/github/browser/models/githubIssueModel.js'; +// eslint-disable-next-line local/code-import-patterns +import { GitHubIssueFetcher } from '../../../../../sessions/contrib/github/browser/fetchers/githubIssueFetcher.js'; +// eslint-disable-next-line local/code-import-patterns import { IGitHubService } from '../../../../../sessions/contrib/github/browser/githubService.js'; // eslint-disable-next-line local/code-import-patterns -import { IGitHubPullRequest } from '../../../../../sessions/contrib/github/common/types.js'; +import { IGitHubIssue, IGitHubPullRequest } from '../../../../../sessions/contrib/github/common/types.js'; interface IFixturePullRequestEntry { readonly owner: string; @@ -26,6 +30,12 @@ interface IFixturePullRequestEntry { readonly pullRequest: IGitHubPullRequest; } +interface IFixtureIssueEntry { + readonly owner: string; + readonly repo: string; + readonly issue: IGitHubIssue; +} + class FixtureGitHubPRFetcher extends mock<GitHubPRFetcher>() { } class FixtureGitHubPullRequestModel extends GitHubPullRequestModel { @@ -53,8 +63,44 @@ class FixtureGitHubPullRequestModelReferenceCollection extends ReferenceCollecti } } -export function createFixtureGitHubService(entries: readonly IFixturePullRequestEntry[]): IGitHubService { +class FixtureGitHubIssueFetcher extends mock<GitHubIssueFetcher>() { } + +class FixtureGitHubIssueModel extends GitHubIssueModel { + + override readonly issue: IObservable<IGitHubIssue | undefined>; + + constructor(owner: string, repo: string, issueNumber: number, issue: IGitHubIssue | undefined) { + super(owner, repo, issueNumber, new FixtureGitHubIssueFetcher(), new NullLogService()); + this.issue = constObservable(issue); + } + + override refresh(): Promise<void> { + return Promise.resolve(); + } + + override startPolling(): IDisposable { + return Disposable.None; + } +} + +class FixtureGitHubIssueModelReferenceCollection extends ReferenceCollection<GitHubIssueModel> { + + constructor(private readonly _issues: Map<string, IGitHubIssue>) { + super(); + } + + protected override createReferencedObject(key: string, owner: string, repo: string, issueNumber: number): GitHubIssueModel { + return new FixtureGitHubIssueModel(owner, repo, issueNumber, this._issues.get(key)); + } + + protected override destroyReferencedObject(key: string, object: GitHubIssueModel): void { + object.dispose(); + } +} + +export function createFixtureGitHubService(entries: readonly IFixturePullRequestEntry[], issueEntries: readonly IFixtureIssueEntry[] = []): IGitHubService { const pullRequests = new Map(entries.map(entry => [toPullRequestKey(entry.owner, entry.repo, entry.pullRequest.number), entry.pullRequest])); + const issues = new Map(issueEntries.map(entry => [toIssueKey(entry.owner, entry.repo, entry.issue.number), entry.issue])); return new class extends mock<IGitHubService>() { override readonly activeSessionPullRequestObs = constObservable<GitHubPullRequestModel | undefined>(undefined); @@ -62,13 +108,22 @@ export function createFixtureGitHubService(entries: readonly IFixturePullRequest override readonly activeSessionPullRequestReviewThreadsObs = constObservable<GitHubPullRequestReviewThreadsModel | undefined>(undefined); private readonly _references = new FixtureGitHubPullRequestModelReferenceCollection(pullRequests); + private readonly _issueReferences = new FixtureGitHubIssueModelReferenceCollection(issues); override createPullRequestModelReference(owner: string, repo: string, prNumber: number): IReference<GitHubPullRequestModel> { return this._references.acquire(toPullRequestKey(owner, repo, prNumber), owner, repo, prNumber); } + + override createIssueModelReference(owner: string, repo: string, issueNumber: number): IReference<GitHubIssueModel> { + return this._issueReferences.acquire(toIssueKey(owner, repo, issueNumber), owner, repo, issueNumber); + } }(); } function toPullRequestKey(owner: string, repo: string, prNumber: number): string { return `${owner}/${repo}/${prNumber}`; } + +function toIssueKey(owner: string, repo: string, issueNumber: number): string { + return `${owner}/${repo}/issues/${issueNumber}`; +} diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/openIssue.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/openIssue.fixture.ts new file mode 100644 index 00000000000..6b791f0b7f7 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/openIssue.fixture.ts @@ -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 { URI } from '../../../../../base/common/uri.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IObservable, constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; +// eslint-disable-next-line local/code-import-patterns +import { IGitHubInfo, IGitHubIssueRef, ISessionFolder, ISessionGitRepository, ISessionWorkspace } from '../../../../../sessions/services/sessions/common/session.js'; +// eslint-disable-next-line local/code-import-patterns +import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionContext, SessionContext } from '../../../../../sessions/services/sessions/browser/sessionContext.js'; +// eslint-disable-next-line local/code-import-patterns +import { computeIssueIcon, GitHubIssueState, GitHubIssueStateReason, IGitHubIssue } from '../../../../../sessions/contrib/github/common/types.js'; +// eslint-disable-next-line local/code-import-patterns +import { IGitHubService } from '../../../../../sessions/contrib/github/browser/githubService.js'; +// eslint-disable-next-line local/code-import-patterns +import { createIssueHoverElement, createIssueListElement } from '../../../../../sessions/contrib/github/browser/issueHover.js'; +// eslint-disable-next-line local/code-import-patterns +import { OpenIssueActionViewItem } from '../../../../../sessions/contrib/github/browser/issueActions.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { createFixtureGitHubService } from './githubFixtureUtils.js'; + +// eslint-disable-next-line local/code-import-patterns +import '../../../../../sessions/browser/parts/media/chatCompositeBar.css'; +import '../../../../../base/browser/ui/hover/hoverWidget.css'; +import '../../../../../platform/hover/browser/hover.css'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +function createMockWorkspace(issues: readonly IGitHubIssueRef[]): ISessionWorkspace { + const root = URI.file('/home/user/projects/vscode'); + const gitHubInfo: IGitHubInfo = { owner: 'microsoft', repo: 'vscode', issues }; + + const gitRepository: ISessionGitRepository = { + uri: root, + workTreeUri: undefined, + baseBranchName: 'main', + gitHubInfo: constObservable(gitHubInfo), + }; + + const folder: ISessionFolder = { + root, + workingDirectory: root, + name: 'vscode', + description: undefined, + gitRepository, + }; + + return { + uri: root, + label: 'vscode', + icon: Codicon.folder, + folders: [folder], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; +} + +function createMockSession(issues: readonly IGitHubIssueRef[]): IActiveSession { + return new class extends mock<IActiveSession>() { + override readonly resource = URI.parse('session:1'); + override readonly workspace: IObservable<ISessionWorkspace | undefined> = observableValue('workspace', createMockWorkspace(issues)); + }(); +} + +function toIssueRef(issue: IGitHubIssue): IGitHubIssueRef { + return { + owner: 'microsoft', + repo: 'vscode', + number: issue.number, + uri: URI.parse(`https://github.com/microsoft/vscode/issues/${issue.number}`), + }; +} + +// ============================================================================ +// Render helpers +// ============================================================================ + +function renderIssuePill(ctx: ComponentFixtureContext, issues: readonly IGitHubIssue[]): void { + const { container, disposableStore } = ctx; + + const session = observableValue<IActiveSession | undefined>('session', createMockSession(issues.map(toIssueRef))); + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + reg.defineInstance(ISessionContext, new SessionContext(session)); + reg.defineInstance(IGitHubService, createFixtureGitHubService([], issues.map(issue => ({ owner: 'microsoft', repo: 'vscode', issue })))); + }, + }); + + // Build the real menu item action the session header contributes, then + // render the production action view item against it. + const action = instantiationService.createInstance( + MenuItemAction, + { id: 'workbench.agentSessions.action.openIssue', title: 'Open Issue' }, + undefined, + undefined, + undefined, + undefined, + ); + + const item = disposableStore.add(instantiationService.createInstance(OpenIssueActionViewItem, action, {})); + + // Recreate the session header meta toolbar host so the inline-label styling + // (.chat-composite-bar-meta-toolbar) applies as in production. + const toolbar = document.createElement('div'); + toolbar.classList.add('chat-composite-bar-meta-toolbar'); + container.appendChild(toolbar); + item.render(toolbar); + + container.style.padding = '8px'; + container.style.backgroundColor = 'var(--vscode-sideBar-background)'; +} + +function renderInHoverWidget(ctx: ComponentFixtureContext, content: HTMLElement, width: string): void { + const { container } = ctx; + + container.style.padding = '24px'; + container.style.width = width; + container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + + const hover = document.createElement('div'); + hover.classList.add('monaco-hover', 'workbench-hover'); + hover.style.position = 'static'; + hover.style.display = 'inline-block'; + + const row = document.createElement('div'); + row.classList.add('hover-row', 'markdown-hover'); + hover.appendChild(row); + + const contents = document.createElement('div'); + contents.classList.add('hover-contents', 'html-hover-contents'); + contents.appendChild(content); + row.appendChild(contents); + + container.appendChild(hover); +} + +function renderIssueHover(ctx: ComponentFixtureContext, issue: IGitHubIssue): void { + renderInHoverWidget(ctx, createIssueHoverElement({ + owner: 'microsoft', + repo: 'vscode', + number: issue.number, + repositoryHref: 'https://github.com/microsoft/vscode', + issue, + }), '580px'); +} + +function renderIssueList(ctx: ComponentFixtureContext, issues: readonly IGitHubIssue[]): void { + renderInHoverWidget(ctx, createIssueListElement(issues.map(issue => ({ + number: issue.number, + title: issue.title, + icon: computeIssueIcon(issue.state, issue.stateReason), + })), () => { }), '480px'); +} + +// ============================================================================ +// Data +// ============================================================================ + +const openIssue: IGitHubIssue = { + number: 12345, + title: 'Terminal hangs when running a long build task in a detached worktree', + body: 'Steps to reproduce: open a session on a worktree, start `npm run watch`, then switch to another session. The terminal stops streaming output and the task never reports completion.', + state: GitHubIssueState.Open, + stateReason: undefined, + author: { login: 'hariharjeevan', avatarUrl: '' }, + createdAt: '2026-06-22T10:00:00Z', + updatedAt: '2026-06-24T12:00:00Z', + closedAt: undefined, +}; + +const completedIssue: IGitHubIssue = { + number: 678, + title: 'Session header pill should show the referenced issue', + body: 'The session header already surfaces the pull request. It should do the same for the GitHub issues the user referenced in their messages.', + state: GitHubIssueState.Closed, + stateReason: GitHubIssueStateReason.Completed, + author: { login: 'alex', avatarUrl: '' }, + createdAt: '2026-06-05T10:00:00Z', + updatedAt: '2026-06-18T09:30:00Z', + closedAt: '2026-06-18T09:30:00Z', +}; + +const notPlannedIssue: IGitHubIssue = { + number: 42, + title: 'Add a setting to disable issue detection entirely, including for cross-repository references', + body: 'Not planned — the pill is already scoped to explicit references.', + state: GitHubIssueState.Closed, + stateReason: GitHubIssueStateReason.NotPlanned, + author: { login: 'alex', avatarUrl: '' }, + createdAt: '2026-05-30T10:00:00Z', + updatedAt: '2026-06-02T08:00:00Z', + closedAt: '2026-06-02T08:00:00Z', +}; + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + + OpenIssue_Single: defineComponentFixture({ + render: (ctx) => renderIssuePill(ctx, [openIssue]), + }), + + OpenIssue_Closed: defineComponentFixture({ + render: (ctx) => renderIssuePill(ctx, [completedIssue]), + }), + + OpenIssue_Multiple: defineComponentFixture({ + render: (ctx) => renderIssuePill(ctx, [openIssue, completedIssue, notPlannedIssue]), + }), + + OpenIssue_Hover: defineComponentFixture({ + render: (ctx) => renderIssueHover(ctx, openIssue), + }), + + OpenIssue_List: defineComponentFixture({ + render: (ctx) => renderIssueList(ctx, [openIssue, completedIssue, notPlannedIssue]), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts index 71897ecc5b4..90068f31d24 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts @@ -245,7 +245,7 @@ export default defineThemedFixtureGroup({ path: 'sessions/' }, { })), }), - // --- Background activity pill ------------------------------------------ + // --- Browser and background activity pills ------------------------------ SessionChatPills_BackgroundBrowser: defineComponentFixture({ render: (ctx) => renderPills(ctx, createMockSession({ browsers: [{ title: 'Visual Studio Code' }] })), diff --git a/src/vs/workbench/test/browser/notificationsToasts.test.ts b/src/vs/workbench/test/browser/notificationsToasts.test.ts new file mode 100644 index 00000000000..7d7768b0e2e --- /dev/null +++ b/src/vs/workbench/test/browser/notificationsToasts.test.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Dimension, getWindow } from '../../../base/browser/dom.js'; +import { Event } from '../../../base/common/event.js'; +import { DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { Severity } from '../../../platform/notification/common/notification.js'; +import { NotificationsToasts } from '../../browser/parts/notifications/notificationsToasts.js'; +import { NotificationsModel } from '../../common/notifications.js'; +import { workbenchInstantiationService } from './workbenchTestServices.js'; + +suite('NotificationsToasts', () => { + + suiteSetup(async () => { + const warmupDisposables = new DisposableStore(); + try { + const { model, toasts } = await createToasts(warmupDisposables); + const toastVisible = Event.toPromise(toasts.onDidChangeVisibility); + model.addNotification({ severity: Severity.Error, message: 'Warmup' }); + await toastVisible; + } finally { + warmupDisposables.dispose(); + } + }); + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + async function createToasts(testDisposables: Pick<DisposableStore, 'add'> = disposables): Promise<{ + readonly container: HTMLElement; + readonly model: NotificationsModel; + readonly toasts: NotificationsToasts; + readonly flushAnimationFrame: () => Promise<void>; + }> { + const container = document.createElement('div'); + const targetWindow = getWindow(container); + targetWindow.document.body.appendChild(container); + testDisposables.add(toDisposable(() => container.remove())); + + const instantiationService = workbenchInstantiationService(undefined, testDisposables); + const model = testDisposables.add(new NotificationsModel()); + testDisposables.add(toDisposable(() => { + for (const notification of [...model.notifications]) { + notification.close(); + } + })); + + const toasts = testDisposables.add(instantiationService.createInstance(NotificationsToasts, container, model)); + // Avoid viewport-dependent hiding because these tests assert scheduled toast counts. + toasts.layout(new Dimension(1024, Number.MAX_SAFE_INTEGER)); + await Promise.resolve(); + + return { + container, + model, + toasts, + flushAnimationFrame: () => new Promise(resolve => targetWindow.requestAnimationFrame(() => resolve())) + }; + } + + test('shows one toast for rapidly added duplicate notifications', async () => { + const { container, model, toasts } = await createToasts(); + const toastVisible = Event.toPromise(toasts.onDidChangeVisibility); + + for (let i = 0; i < 15; i++) { + model.addNotification({ severity: Severity.Error, message: 'Hello!' }); + } + await Promise.resolve(); + + const beforeAnimationFrame = { + notifications: model.notifications.length, + toasts: container.querySelectorAll('.notification-toast-container').length + }; + + await toastVisible; + assert.deepStrictEqual({ + beforeAnimationFrame, + notifications: model.notifications.length, + toasts: container.querySelectorAll('.notification-toast-container').length, + visible: toasts.isVisible + }, { + beforeAnimationFrame: { + notifications: 1, + toasts: 0 + }, + notifications: 1, + toasts: 1, + visible: true + }); + }); + + test('limits rapidly added distinct notification toasts', async () => { + const { container, model, toasts } = await createToasts(); + const toastVisible = Event.toPromise(toasts.onDidChangeVisibility); + + for (let i = 0; i < 15; i++) { + model.addNotification({ severity: Severity.Error, message: `Message ${i}` }); + } + + await toastVisible; + + assert.deepStrictEqual({ + notifications: model.notifications.length, + toasts: container.querySelectorAll('.notification-toast-container').length, + visible: toasts.isVisible + }, { + notifications: 15, + toasts: 3, + visible: true + }); + }); + + test('does not show a pending notification removed before rendering', async () => { + const { container, model, toasts, flushAnimationFrame } = await createToasts(); + const handle = model.addNotification({ severity: Severity.Error, message: 'Hello!' }); + + handle.close(); + await Promise.resolve(); + await flushAnimationFrame(); + + assert.deepStrictEqual({ + notifications: model.notifications.length, + toasts: container.querySelectorAll('.notification-toast-container').length, + visible: toasts.isVisible + }, { + notifications: 0, + toasts: 0, + visible: false + }); + }); +}); diff --git a/src/vs/workbench/test/browser/parts/editor/editorTypePicker.test.ts b/src/vs/workbench/test/browser/parts/editor/editorTypePicker.test.ts index fe90c8dd8f7..0a89b1fc19e 100644 --- a/src/vs/workbench/test/browser/parts/editor/editorTypePicker.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/editorTypePicker.test.ts @@ -4,15 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { mock } from '../../../../../base/test/common/mock.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { DEFAULT_EDITOR_ASSOCIATION } from '../../../../common/editor.js'; -import { IAvailableEditorTypes, hasDefaultEditorAssociation } from '../../../../browser/parts/editor/editorTypePicker.js'; -import { RegisteredEditorInfo, RegisteredEditorPriority } from '../../../../services/editor/common/editorResolverService.js'; +import { DEFAULT_EDITOR_ASSOCIATION, IEditorInputWithDiffResources } from '../../../../common/editor.js'; +import { EditorInput } from '../../../../common/editor/editorInput.js'; +import { getAvailableEditorTypes, IAvailableEditorTypes, hasDefaultEditorAssociation } from '../../../../browser/parts/editor/editorTypePicker.js'; +import { IEditorResolverService, RegisteredEditorInfo, RegisteredEditorPriority } from '../../../../services/editor/common/editorResolverService.js'; suite('Editor Type Picker', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); function editor(id: string, editorPriority: RegisteredEditorPriority, diffPriority = editorPriority): RegisteredEditorInfo { return { @@ -62,4 +64,43 @@ suite('Editor Type Picker', () => { diffDefaultEditor: true, }); }); + + test('inline custom diff editor is classified as a diff editor', () => { + const original = URI.file('/original/test.md'); + const modified = URI.file('/modified/test.md'); + const registeredEditors = [ + editor(DEFAULT_EDITOR_ASSOCIATION.id, RegisteredEditorPriority.builtin), + editor('test.markdownEditor', RegisteredEditorPriority.option, RegisteredEditorPriority.never), + ]; + const input = disposables.add(new class extends EditorInput implements IEditorInputWithDiffResources { + override get typeId(): string { return 'test.inlineCustomDiffEditor'; } + override get editorId(): string { return 'test.markdownEditor'; } + override get resource(): URI { return modified; } + get diffResources(): IEditorInputWithDiffResources['diffResources'] { return { original, modified }; } + override getName(): string { return 'test'; } + }()); + const requestedResources: URI[] = []; + const editorResolverService = new class extends mock<IEditorResolverService>() { + override getEditors(resource?: URI): RegisteredEditorInfo[] { + if (resource) { + requestedResources.push(resource); + } + return registeredEditors; + } + }; + + const result = getAvailableEditorTypes(input, editorResolverService); + + assert.deepStrictEqual({ requestedResources, result }, { + requestedResources: [modified], + result: { + resource: modified, + isDiffEditor: true, + originalResource: original, + modifiedResource: modified, + currentId: 'test.markdownEditor', + editors: registeredEditors, + } + }); + }); }); \ No newline at end of file diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index f0fad8b9533..85f68a55699 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -126,6 +126,11 @@ declare module 'vscode' { */ readonly hasHooksEnabled: boolean; + /** + * Whether this request was submitted through Agents Voice Mode. + */ + readonly isVoiceModeInput?: boolean; + /** * When true, this request was initiated by the system (e.g. a terminal * command completion notification) rather than by the user typing a @@ -135,6 +140,41 @@ declare module 'vscode' { readonly isSystemInitiated?: boolean; } + /** + * A transient progress update intended for Voice Mode narration. + */ + export type ChatResponseVoiceProgressStage = 'investigating' | 'planning' | 'editing' | 'validating' | 'recovering'; + + export class ChatResponseVoiceProgressPart { + /** + * A stable identifier used to de-duplicate the progress update. + */ + readonly id: ChatResponseVoiceProgressStage; + /** + * The concise text to narrate. + */ + readonly value: string; + /** + * Creates a Voice Mode progress update. + * @param id A stable identifier used to de-duplicate the update. + * @param value The concise text to narrate. + */ + constructor(id: ChatResponseVoiceProgressStage, value: string); + } + + export interface ExtendedChatResponseParts { + ChatResponseVoiceProgressPart: ChatResponseVoiceProgressPart; + } + + export interface ChatResponseStream { + /** + * Reports transient progress for Voice Mode narration. + * @param id A stable identifier used to de-duplicate the update. + * @param value The concise text to narrate. + */ + voiceProgress(id: ChatResponseVoiceProgressStage, value: string): void; + } + export enum ChatRequestEditedFileEventKind { Keep = 1, Undo = 2, diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index c200f22f1fb..653dec5cb33 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -24,6 +24,11 @@ declare module 'vscode' { readonly modelConfiguration?: { readonly [key: string]: any; }; + + /** + * Whether encrypted thinking state should be included in the response. + */ + readonly includeEncryptedThinking?: boolean; } /**