diff --git a/.github/skills/add-policy/SKILL.md b/.github/skills/add-policy/SKILL.md index 38ae812787639..ef8c7a9f50f7c 100644 --- a/.github/skills/add-policy/SKILL.md +++ b/.github/skills/add-policy/SKILL.md @@ -15,6 +15,7 @@ Policies allow enterprise administrators to lock configuration settings via OS-l - Adding account-based policy support via `IPolicyData` - Wiring an enterprise **managed setting** (native MDM / GitHub server) — see **[github-managed-settings.md](./github-managed-settings.md)** - Having one policy govern **multiple** settings via `policyReference` +- **Testing** account/managed-settings policies locally without the real backend — see **[local-testing.md](./local-testing.md)** ## Architecture Overview @@ -24,7 +25,7 @@ Policies allow enterprise administrators to lock configuration settings via OS-l |--------|---------------|----------------------| | **OS-level** (Windows registry, macOS plist) | `NativePolicyService` via `@vscode/policy-watcher` | Watches `Software\Policies\Microsoft\{productName}` (Windows) or bundle identifier prefs (macOS) | | **Linux file** | `FilePolicyService` | Reads `/etc/vscode/policy.json` | -| **Account/GitHub** | `AccountPolicyService` | Reads `IPolicyData` from `IDefaultAccountService.policyData`, applies `value()` function. Server-delivered managed settings arrive on `policyData.managedSettings`; native MDM is a **separate** input (`ICopilotManagedSettingsService`) that `AccountPolicyService` merges in `getPolicyData()` | +| **Account/GitHub** | `AccountPolicyService` | Reads `IPolicyData` from `IDefaultAccountService.policyData`, applies `value()` function. Server-delivered managed settings arrive on `policyData.managedSettings`; native MDM is a **separate** input (`ICopilotManagedSettingsService`) that `AccountPolicyService` selects between in `getPolicyData()` (server wins when present; no merging between layers) | | **Copilot managed settings (native MDM)** | `CopilotManagedSettingsService` via `@vscode/policy-watcher` | Watches `SOFTWARE\Policies\GitHubCopilot` (Windows) / `com.github.copilot` prefs (macOS); feeds the canonical `managedSettings` bag — see [github-managed-settings.md](./github-managed-settings.md) | | **Multiplex** | `MultiplexPolicyService` | In the main process, combines multiple OS/file policy readers; in desktop and Agents-window renderers, combines the main-process `PolicyChannelClient` with `AccountPolicyService` | @@ -38,7 +39,7 @@ Policies allow enterprise administrators to lock configuration settings via OS-l | `src/vs/platform/policy/node/copilotManagedSettingsService.ts` | Native MDM watcher (`@vscode/policy-watcher`) for Copilot managed settings | | `src/vs/platform/configuration/common/configurations.ts` | `PolicyConfiguration` — bridges policies to configuration values; parses JSON-string managed settings back to typed values; applies values to `policyReference` settings | | `src/vs/platform/configuration/common/configurationRegistry.ts` | `policy` / `policyReference` registration; `getPolicyReferenceConfigurations()` (name → subordinate settings) | -| `src/vs/workbench/services/policies/common/accountPolicyService.ts` | Account/GitHub-based policy evaluation; merges + projects managed settings (MDM over server) | +| `src/vs/workbench/services/policies/common/accountPolicyService.ts` | Account/GitHub-based policy evaluation; selects + projects managed settings (server over MDM; single authoritative layer) | | `src/vs/workbench/services/accounts/browser/managedSettings.ts` | `adaptManagedSettings` — normalizes the server `managed_settings` response into the canonical bag | | `src/vs/workbench/services/policies/common/multiplexPolicyService.ts` | Combines multiple policy services | | `src/vs/workbench/contrib/policyExport/electron-browser/policyExport.contribution.ts` | `--export-policy-data` CLI handler | @@ -269,6 +270,10 @@ policy: { the new-key checklist are in [github-managed-settings.md](./github-managed-settings.md).** Read it before adding or reviewing any managed-settings key. +**Testing locally:** to exercise the account/managed-settings flow without the real +GitHub backend, use the mock policy server — see +**[local-testing.md](./local-testing.md)**. + ## One Policy for Many Settings (`policyReference`) A single policy can govern multiple settings (e.g. gate an agent in both the editor @@ -321,3 +326,4 @@ Search the codebase for `policy:` to find all the examples of different policy c ## Learnings * Never hand-edit `build/lib/policies/policyData.jsonc` (its header explicitly forbids it). If `npm run export-policy-data` is failing, fix the script — don't patch the JSON. Common cause: running it in the wrong working directory (e.g. main repo instead of a worktree), which silently exports the wrong source tree. +* Document **behavior and business-logic expectations**, not copy-pasted implementation. Reproducing internal code (e.g. the `getPolicyData()` merge body) in the skill rots the moment the source changes and adds no information beyond the source itself. State the contract in prose (e.g. "server-delivered managed settings win over native MDM; the two layers are never merged") and point to the source for the implementation. Reserve code blocks for the **author-facing API contract** a contributor must follow — how to *declare* a `policy` / `managedSettings` / `value` callback — not for restating runtime plumbing. diff --git a/.github/skills/add-policy/github-managed-settings.md b/.github/skills/add-policy/github-managed-settings.md index 0451ae3a268fb..379a806e6501d 100644 --- a/.github/skills/add-policy/github-managed-settings.md +++ b/.github/skills/add-policy/github-managed-settings.md @@ -51,21 +51,16 @@ does **not** read today (no `managed-settings.json` reader exists in `src/`). | **Server-managed** (`/copilot_internal/managed_settings`) | GitHub endpoint; per the code comment in `managedSettings.ts`, it returns the enterprise's `.github/copilot/settings.json` content | `adaptManagedSettings` (`src/vs/workbench/services/accounts/browser/managedSettings.ts`) → `DefaultAccountService.policyData` | `accountPolicyData.managedSettings` | | **File-based** (`managed-settings.json`) | external schema only | *not implemented in VS Code* | — | -Both VS Code channels converge in `AccountPolicyService.getPolicyData()`: +Both VS Code channels converge in `AccountPolicyService.getPolicyData()`. -```ts -// MDM overrides server; then project onto the declared schema. -const managedSettingsData = projectManagedSettings( - { ...accountPolicyData?.managedSettings, ...managedPolicyData }, - collectManagedSettingsDefinitions(this.policyDefinitions), - msg => this.logService.warn(`[AccountPolicy] ${msg}`) -); -return { ...accountPolicyData, managedSettings: managedSettingsData }; -``` - -**Merge order: native MDM (`managedPolicyData`) wins over server-delivered -(`accountPolicyData?.managedSettings`)** — it is spread last. Then everything is -projected onto the declared schema (see below). +**Precedence: server-delivered managed settings win over native MDM.** There is a +single authoritative source at any point in time — the two layers are **not** merged. +When the server delivers managed settings, native MDM (`nativeManagedSettings`) is +ignored entirely; native MDM applies only when the server provides no managed settings. +Rationale: the server is harder to bypass than local MDM/file policies, and admins need +one authoritative source to reason about. The winning layer is then projected onto the +declared schema (see below). Client-side merging still happens *within* the winning +layer (e.g. `enabledPlugins`, `extraKnownMarketplaces`). ## Schema source of truth @@ -242,6 +237,10 @@ Reference tests: (includes an end-to-end equivalence test: a server JSON string and a native MDM JSON string resolve to the **identical** typed object). +**Manual/local testing:** use the mock policy server to serve arbitrary +`managed_settings` (and entitlement/token) responses and apply them via +**Developer: Sync Account Policy** — see [local-testing.md](./local-testing.md). + ## Related: one policy governing many settings (`policyReference`) A single enterprise policy can lock **more than one setting** — e.g. gate an agent in diff --git a/.github/skills/add-policy/local-testing.md b/.github/skills/add-policy/local-testing.md new file mode 100644 index 0000000000000..2fef52979d637 --- /dev/null +++ b/.github/skills/add-policy/local-testing.md @@ -0,0 +1,23 @@ +# Local Testing: Mock Policy Server + +Use the mock policy server to serve arbitrary Copilot policy responses locally. +It mocks the four `defaultChatAgent` endpoints that `DefaultAccountService` calls. + +## Quick start + +```sh +npm run mock-policy-server # http://127.0.0.1:3000 +``` + +Open the GUI, edit the JSON response for any endpoint, **Save**, then click +**Wire all endpoints** (writes `product.overrides.json`). Reload Code OSS, +sign in, and run **Developer: Sync Account Policy** to pull the mocked data. + +Click **Unwire** when done — it restores the original `product.overrides.json` +from a backup. Use **Copy overrides JSON** if you prefer to paste manually. + +## Schema validation (Managed Settings tab) + +Expand the **Schema** section, point the path at a local +`managed-settings-schema.json`, and click **Load**. The path is saved in +localStorage across reloads. Click **Validate** to check for unknown keys. diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index e45e5d0c581a1..764e3e8966585 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -357,21 +357,6 @@ "default": [], "included": true }, - { - "key": "chat.editMode.hidden", - "name": "DeprecatedEditModeHidden", - "category": "InteractiveSession", - "minimumVersion": "1.112", - "localization": { - "description": { - "key": "chat.editMode.hidden", - "value": "When enabled, hides the Edit mode from the chat mode picker." - } - }, - "type": "boolean", - "default": true, - "included": true - }, { "key": "chat.useHooks", "name": "ChatHooks", @@ -495,21 +480,6 @@ "default": true, "included": true }, - { - "key": "chat.agent.sandbox.autoApproveUnsandboxedCommands", - "name": "ChatAgentSandboxAutoApproveUnsandboxedCommands", - "category": "IntegratedTerminal", - "minimumVersion": "1.116", - "localization": { - "description": { - "key": "agentSandbox.autoApproveUnsandboxedCommands", - "value": "Controls whether agent mode terminal commands that run outside the sandbox are auto-approved. This applies only when both `#chat.agent.sandbox.enabled#` and `#chat.agent.sandbox.allowUnsandboxedCommands#` are enabled." - } - }, - "type": "boolean", - "default": false, - "included": true - }, { "key": "chat.agent.sandbox.allowAutoApprove", "name": "ChatAgentSandboxAllowAutoApprove", diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 6420b8cca2009..bfd8a7f005dfc 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -999,6 +999,8 @@ "--reveal-button-size", "--part-background", "--part-border-color", + "--pane-header-size", + "--scroll-shadow-surface", "--vscode-chat-list-background", "--vscode-editorCodeLens-fontFamily", "--vscode-editorCodeLens-fontFamilyDefault", diff --git a/extensions/copilot/src/extension/agents/vscode-node/editModeAgentProvider.ts b/extensions/copilot/src/extension/agents/vscode-node/editModeAgentProvider.ts deleted file mode 100644 index b3f24e4fde37c..0000000000000 --- a/extensions/copilot/src/extension/agents/vscode-node/editModeAgentProvider.ts +++ /dev/null @@ -1,89 +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 * as vscode from 'vscode'; -import { AGENT_FILE_EXTENSION } from '../../../platform/customInstructions/common/promptTypes'; -import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; -import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; -import { ILogService } from '../../../platform/log/common/logService'; -import { Disposable } from '../../../util/vs/base/common/lifecycle'; -import { AgentConfig, buildAgentMarkdown } from './agentTypes'; - -const BASE_EDIT_MODE_AGENT_CONFIG: AgentConfig = { - name: 'Edit', - description: 'Edit-only mode restricted to the currently active file and any files explicitly attached in the request context.', - argumentHint: 'Describe the edit to apply in the active or attached files', - target: 'vscode', - disableModelInvocation: true, - userInvocable: true, - tools: ['read', 'edit'], - agents: [], - handoffs: [ - { - label: 'Continue with Agent Mode', - agent: 'agent', - prompt: 'You are now switching to Agent Mode, where you can read and edit any file in the codebase. Continue with the task without the previous restrictions of Edit Mode.', - send: true, - }, - ], - body: `You are a focused allowlist editing agent. - -## Rules -- Allowed files are strictly: (1) the currently active file and (2) files explicitly attached in the request context. -- Only read and edit files in that allowlist. -- Create a new file only when the user explicitly asks to create that file. -- Never create, delete, rename, or modify any file outside that allowlist. -- Never propose or use terminal commands. -- If a request requires touching files outside the allowlist, stop and explain that Edit Mode is restricted to the active file plus attached files. - -## Workflow -1. Build the allowed-file set from context: active file + attached files. -2. Confirm every requested edit target is in that allowed-file set before editing, unless it is an explicitly user-requested new file creation. -3. Make the minimum required edits only within allowed files. -4. Summarize exactly what changed and list touched files. -5. If further changes are needed outside the allowlist, suggest switching to Agent Mode to complete the task without restrictions.` -}; - -export class EditModeAgentProvider extends Disposable implements vscode.ChatCustomAgentProvider { - readonly label = vscode.l10n.t('Edit Mode Agent'); - - private static readonly CACHE_DIR = 'edit-mode-agent'; - private static readonly AGENT_FILENAME = `EditMode${AGENT_FILE_EXTENSION}`; - - constructor( - @IVSCodeExtensionContext private readonly _extensionContext: IVSCodeExtensionContext, - @IFileSystemService private readonly _fileSystemService: IFileSystemService, - @ILogService private readonly _logService: ILogService, - ) { - super(); - } - - async provideCustomAgents( - _context: unknown, - _token: vscode.CancellationToken - ): Promise { - const content = buildAgentMarkdown(BASE_EDIT_MODE_AGENT_CONFIG); - const fileUri = await this._writeCacheFile(content); - return [{ uri: fileUri, sessionTypes: ['local'] }]; - } - - private async _writeCacheFile(content: string): Promise { - const cacheDir = vscode.Uri.joinPath( - this._extensionContext.globalStorageUri, - EditModeAgentProvider.CACHE_DIR - ); - - try { - await this._fileSystemService.stat(cacheDir); - } catch { - await this._fileSystemService.createDirectory(cacheDir); - } - - const fileUri = vscode.Uri.joinPath(cacheDir, EditModeAgentProvider.AGENT_FILENAME); - await this._fileSystemService.writeFile(fileUri, new TextEncoder().encode(content)); - this._logService.trace(`[EditModeAgentProvider] Wrote agent file: ${fileUri.toString()}`); - return fileUri; - } -} diff --git a/extensions/copilot/src/extension/agents/vscode-node/promptFileContrib.ts b/extensions/copilot/src/extension/agents/vscode-node/promptFileContrib.ts index e51bd41fd2662..8bf982e3112d6 100644 --- a/extensions/copilot/src/extension/agents/vscode-node/promptFileContrib.ts +++ b/extensions/copilot/src/extension/agents/vscode-node/promptFileContrib.ts @@ -11,7 +11,6 @@ import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/d import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { IExtensionContribution } from '../../common/contributions'; import { AskAgentProvider } from './askAgentProvider'; -import { EditModeAgentProvider } from './editModeAgentProvider'; import { ExploreAgentProvider } from './exploreAgentProvider'; import { GitHubOrgCustomAgentProvider } from './githubOrgCustomAgentProvider'; import { GitHubOrgInstructionsProvider } from './githubOrgInstructionsProvider'; @@ -29,26 +28,6 @@ export class PromptFileContribution extends Disposable implements IExtensionCont // Register custom agent provider if ('registerCustomAgentProvider' in vscode.chat) { - const editModeProviderRegistration = this._register(new MutableDisposable()); - const editModeHiddenSetting = 'chat.editMode.hidden'; - const updateEditModeProvider = () => { - const isEditModeHidden = configurationService.getNonExtensionConfig(editModeHiddenSetting); - if (!isEditModeHidden) { - if (!editModeProviderRegistration.value) { - editModeProviderRegistration.value = vscode.chat.registerCustomAgentProvider(instantiationService.createInstance(EditModeAgentProvider)); - } - } else { - editModeProviderRegistration.clear(); - } - }; - - updateEditModeProvider(); - this._register(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(editModeHiddenSetting)) { - updateEditModeProvider(); - } - })); - // Only register the provider if the setting is enabled if (configurationService.getConfig(ConfigKey.EnableOrganizationCustomAgents)) { const githubOrgAgentProvider: vscode.ChatCustomAgentProvider = instantiationService.createInstance(new SyncDescriptor(GitHubOrgCustomAgentProvider)); diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 92db4f4fa1b7d..3d1311851f272 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -82,6 +82,34 @@ function isResponsesCompactionContextManagementEnabled(endpoint: IChatEndpoint, && !modelsWithoutResponsesContextManagement.has(endpoint.family); } +/** + * Applies the user's "Context Size" model-picker selection to the endpoint used + * for the agent's model requests. + * + * The picker offers two tiers — the model's default context max and its full + * native window (see `getContextSizeOptions`). For server-managed context (the + * Responses-API compaction path) the request endpoint's `modelMaxPromptTokens` + * is what drives the `compact_threshold` sent to the server. If the default + * tier is not propagated to the request endpoint, the server compacts against + * the model's full window and the stateful conversation grows far past the + * user's selection — billing them for the larger context. Mirrors the override + * applied on the `vscode.lm` path in `languageModelAccess.ts`. + * + * Only clamps when the selection is strictly smaller than the model window so + * the full tier ("Longer sessions without compaction") stays uncompacted. + * + * @internal - exported for testing + */ +export function applyContextSizeOverride(endpoint: IChatEndpoint, request: vscode.ChatRequest): IChatEndpoint { + const contextSize = request.modelConfiguration?.contextSize; + // Guard against non-positive / non-finite selections (e.g. 0, -1, NaN, Infinity): + // a non-positive token budget would produce an invalid endpoint configuration. + if (typeof contextSize === 'number' && Number.isFinite(contextSize) && contextSize > 0 && contextSize < endpoint.modelMaxPromptTokens) { + return endpoint.cloneWithTokenOverride(contextSize); + } + return endpoint; +} + /** * Returns true when the user explicitly referenced the todo tool (e.g. typed * `#todo` in their message) or a custom agent configuration includes it as a @@ -613,7 +641,11 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I @IAutomaticInstructionsCollector private readonly _automaticInstructionsCollector: IAutomaticInstructionsCollector, @IAuthenticationService private readonly authenticationService: IAuthenticationService, ) { - super(intent, location, endpoint, request, intentOptions, instantiationService, codeMapperService, envService, promptPathRepresentationService, _endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, otelService); + // Apply the user's "Context Size" picker selection to the request endpoint + // so the server-managed compaction threshold (Responses API) is keyed to the + // selected tier rather than the model's full native window. See + // applyContextSizeOverride for the cost rationale. + super(intent, location, applyContextSizeOverride(endpoint, request), request, intentOptions, instantiationService, codeMapperService, envService, promptPathRepresentationService, _endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, otelService); } public override getAvailableTools(): Promise { diff --git a/extensions/copilot/src/extension/intents/node/test/contextSizeOverride.spec.ts b/extensions/copilot/src/extension/intents/node/test/contextSizeOverride.spec.ts new file mode 100644 index 0000000000000..0bdcf1ff53f1f --- /dev/null +++ b/extensions/copilot/src/extension/intents/node/test/contextSizeOverride.spec.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ChatRequest } from 'vscode'; +import { describe, expect, test } from 'vitest'; +import { IChatEndpoint } from '../../../../platform/networking/common/networking'; +import { applyContextSizeOverride } from '../agentIntent'; + +describe('applyContextSizeOverride', () => { + function createEndpoint(modelMaxPromptTokens: number): { endpoint: IChatEndpoint; clonedWith: number[] } { + const clonedWith: number[] = []; + const endpoint = { + modelMaxPromptTokens, + cloneWithTokenOverride(tokens: number): IChatEndpoint { + clonedWith.push(tokens); + return createEndpoint(tokens).endpoint; + }, + } as unknown as IChatEndpoint; + return { endpoint, clonedWith }; + } + + function createRequest(contextSize?: unknown): ChatRequest { + return { modelConfiguration: contextSize === undefined ? undefined : { contextSize } } as unknown as ChatRequest; + } + + test('clamps to the picked size when below the model window (default tier)', () => { + const { endpoint, clonedWith } = createEndpoint(400_000); + const result = applyContextSizeOverride(endpoint, createRequest(272_000)); + expect(clonedWith).toEqual([272_000]); + expect(result.modelMaxPromptTokens).toBe(272_000); + }); + + test('leaves the endpoint untouched on the full tier (selection >= model window)', () => { + const { endpoint, clonedWith } = createEndpoint(400_000); + expect(applyContextSizeOverride(endpoint, createRequest(400_000))).toBe(endpoint); + expect(applyContextSizeOverride(endpoint, createRequest(500_000))).toBe(endpoint); + expect(clonedWith).toEqual([]); + }); + + test('does not clamp when context size is unset or non-numeric', () => { + const { endpoint, clonedWith } = createEndpoint(400_000); + expect(applyContextSizeOverride(endpoint, createRequest(undefined))).toBe(endpoint); + expect(applyContextSizeOverride(endpoint, createRequest('big'))).toBe(endpoint); + expect(clonedWith).toEqual([]); + }); + + test('does not clamp for non-positive or non-finite selections', () => { + const { endpoint, clonedWith } = createEndpoint(400_000); + expect(applyContextSizeOverride(endpoint, createRequest(0))).toBe(endpoint); + expect(applyContextSizeOverride(endpoint, createRequest(-1))).toBe(endpoint); + expect(applyContextSizeOverride(endpoint, createRequest(Number.NaN))).toBe(endpoint); + expect(applyContextSizeOverride(endpoint, createRequest(Number.POSITIVE_INFINITY))).toBe(endpoint); + expect(clonedWith).toEqual([]); + }); +}); diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index ec990ee8ff4b8..c84474b37e0f2 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -180,6 +180,16 @@ export abstract class ToolCallingLoop { + // The first iteration of a turn starts a fresh credit total. Resetting here + // (rather than only in run()) keeps runOne() correct when called standalone. + if (iterationNumber === 0) { + this._accumulatedCopilotCredits = undefined; + } let availableTools = await this.getAvailableTools(outputStream, token); // Emit tools_available on the agent span once, before the first CHAT span @@ -1637,11 +1652,18 @@ export abstract class ToolCallingLoop; + public readonly usages: Array<{ promptTokens: number; completionTokens: number; copilotCredits: number | undefined }>; constructor() { - const usages: Array<{ promptTokens: number; completionTokens: number }> = []; + const usages: Array<{ promptTokens: number; completionTokens: number; copilotCredits: number | undefined }> = []; super( () => { }, () => { }, @@ -35,7 +35,8 @@ class UsageCapturingStream extends ChatResponseStreamImpl { (usage) => { usages.push({ promptTokens: usage.promptTokens, - completionTokens: usage.completionTokens + completionTokens: usage.completionTokens, + copilotCredits: usage.copilotCredits }); } ); @@ -71,6 +72,36 @@ class UsageTestToolCallingLoop extends ToolCallingLoop } } +class CreditsTestToolCallingLoop extends ToolCallingLoop { + protected override async buildPrompt(_buildPromptContext: IBuildPromptContext): Promise { + return { + ...nullRenderPromptResult(), + messages: [{ role: Raw.ChatRole.User, content: [toTextPart('hello world')] }], + }; + } + + protected override async getAvailableTools(): Promise { + return []; + } + + // Each model call bills 5 credits (5 * 1e9 nano-AIU). + protected override async fetch(): Promise { + return { + type: ChatFetchResponseType.Success, + value: 'test-response', + requestId: 'request-id', + serverRequestId: undefined, + usage: { + prompt_tokens: 100, + completion_tokens: 20, + total_tokens: 120, + copilot_usage: { total_nano_aiu: 5_000_000_000 } + }, + resolvedModel: 'gpt-4.1' + }; + } +} + const chatPanelLocation: ChatRequest['location'] = 1; function createMockChatRequest(overrides: Partial = {}): ChatRequest { @@ -136,7 +167,7 @@ describe('ToolCallingLoop usage reporting', () => { await loop.runOne(stream, 0, tokenSource.token); - expect(stream.usages).toEqual([{ promptTokens: 100, completionTokens: 20 }]); + expect(stream.usages).toEqual([{ promptTokens: 100, completionTokens: 20, copilotCredits: undefined }]); }); it('does not report usage for subagent requests', async () => { @@ -159,4 +190,25 @@ describe('ToolCallingLoop usage reporting', () => { expect(stream.usages).toHaveLength(0); }); + + it('accumulates copilot credits across iterations within a turn', async () => { + const request = createMockChatRequest(); + const loop = instantiationService.createInstance( + CreditsTestToolCallingLoop, + { + conversation: createConversation(request.prompt), + toolCallLimit: 5, + request, + } + ); + disposables.add(loop); + const stream = new UsageCapturingStream(); + + // Two model calls in the same turn: the per-request credits must be the + // running total (5, then 10), not just the final call's 5. + await loop.runOne(stream, 0, tokenSource.token); + await loop.runOne(stream, 1, tokenSource.token); + + expect(stream.usages.map(u => u.copilotCredits)).toEqual([5, 10]); + }); }); diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index 9b4bb19b8a2f7..abfcdf33f7e0f 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -192,7 +192,7 @@ "statusBarItem.prominentBackground": "#0069CCDD", "statusBarItem.prominentForeground": "#FFFFFF", "statusBarItem.prominentHoverBackground": "#0069CC", - "toolbar.hoverBackground": "#E3E3E5", + "toolbar.hoverBackground": "#0000001F", "toolbar.activeBackground": "#D6D6D8", "tab.activeBackground": "#FFFFFF", "tab.activeForeground": "#202020", diff --git a/package.json b/package.json index 504e0672ffc11..882bc400d0cec 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "serve-out-rspack": "cd build/rspack && npx rspack serve --config rspack.serve-out.config.mts", "watch-web": "npm run gulp watch-web", "watch-cli": "npm run gulp watch-cli", + "mock-policy-server": "node --experimental-strip-types scripts/mock-policy-server/server.ts", "eslint": "node build/eslint.ts", "stylelint": "node build/stylelint.ts", "playwright-install": "npm exec playwright install", diff --git a/scripts/mock-policy-server/README.md b/scripts/mock-policy-server/README.md new file mode 100644 index 0000000000000..d5de56ff77f63 --- /dev/null +++ b/scripts/mock-policy-server/README.md @@ -0,0 +1,82 @@ +# Mock Copilot policy endpoints + +A standalone dev tool that mocks the Copilot **policy** endpoints that +`DefaultAccountService` +(`src/vs/workbench/services/accounts/browser/defaultAccount.ts`) calls, so you +can exercise the entitlement / token / MCP-registry / managed-settings (policy) +pipeline locally without the real GitHub backend. + +It is **not** part of the shipped product — it is a local Node server + web GUI. + +## What it mocks + +| Endpoint | Path | `product.json` key | Response | +| --- | --- | --- | --- | +| Entitlements | `/copilot_internal/user` | `entitlementUrl` | `IEntitlementsData` (`chat_enabled`, `copilot_plan`, `cloud_session_storage_enabled`, …) | +| Token | `/copilot_internal/v2/token` | `tokenEntitlementUrl` | `{ token: "agent_mode=1;editor_preview_features=1;mcp=1;…:sig" }` | +| MCP registry | `/copilot/mcp_registry` | `mcpRegistryDataUrl` | `{ mcp_registries: [{ url, registry_access }] }` | +| Managed settings | `/copilot_internal/managed_settings` | `managedSettingsUrl` | `IManagedSettingsResponse` (enterprise `settings.json`) | + +The flow is gated: the **token** and **managed settings** are only fetched when +entitlements report `chat_enabled: true`, and the **MCP registry** only when the +token enables `mcp`. + +## Usage + +```sh +npm run mock-policy-server # starts on http://127.0.0.1:3000 +npm run mock-policy-server -- --port 4000 +npm run mock-policy-server -- --schema ./copilot-agent-runtime/schema/managed-settings-schema.json +``` + +1. Open the printed GUI URL. +2. Pick an endpoint tab, choose a preset or edit the JSON, and **Save**. +3. Click **Wire all endpoints** to point `product.overrides.json` at this server. +4. **Reload** Code OSS (running from sources, so `VSCODE_DEV` is set). +5. Sign in with your GitHub/Copilot account. +6. Run **Developer: Sync Account Policy** (forces a refresh). +7. Run **Developer: Policy Diagnostics** to inspect the applied values. + +Click **Unwire** to restore the original URLs. + +## Managed-settings schema + +The GUI loads the managed-settings JSON schema and, on the **Managed Settings** +tab, warns about top-level keys that are not declared in it (mirroring how +`projectManagedSettings` drops undeclared keys). The schema source is resolved in +this order: + +1. `--schema ` CLI flag +2. `MANAGED_SETTINGS_SCHEMA` environment variable +3. Default: `./copilot-agent-runtime/schema/managed-settings-schema.json`, + resolved against the **app's current working directory** (normally the vscode + repo root, where the schema repo sits side-by-side). + +`http(s)://` URLs and `file://` URIs are both accepted; relative paths are +resolved from the cwd. The schema is re-read on every **Refresh**, so you can +edit it without restarting the server. A missing schema is non-fatal — the GUI +just shows the resolved path and skips schema validation. + +## How wiring works + +`src/bootstrap-meta.ts` merges `product.overrides.json` over `product.json` with +a shallow, top-level `Object.assign`, only when `VSCODE_DEV` is set, and the file +is git-ignored. To override nested keys the tool writes back the **entire** +`defaultChatAgent` object (seeded from `product.json`) with only the four +endpoint URLs flipped, preserving every other key. Unwiring restores those URLs +to their `product.json` values and removes the file if nothing else remains. + +## Caveats + +- Works for the **default (github.com) provider** path, which reads these URLs + directly from config. The enterprise provider derives some URLs from the + enterprise host instead. +- You must be **signed in**; the fetch only fires for an authenticated account. +- Overrides require a **reload** and only apply when running from sources. +- The server ignores the `Authorization` header — any token is accepted. + +## Files + +- `server.js` — zero-dependency Node `http` server (endpoints + control API + schema loader + static). +- `endpoints.js` — shared endpoint definitions and presets (used by server and GUI). +- `public/` — the web GUI (`index.html`, `app.js`, `style.css`). diff --git a/scripts/mock-policy-server/endpoints.ts b/scripts/mock-policy-server/endpoints.ts new file mode 100644 index 0000000000000..938351151f3b6 --- /dev/null +++ b/scripts/mock-policy-server/endpoints.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Shared definition of the Copilot "policy" endpoints that + * `DefaultAccountService` (src/vs/workbench/services/accounts/browser/defaultAccount.ts) + * calls, together with sample response bodies. This is the single source of + * truth shared by the Node server (route table + default state) and the browser + * GUI (endpoint tabs + preset dropdown), exported UMD-style so it loads in both + * environments without a build step. + * + * For the default (github.com) provider these URLs are read verbatim from + * `product.json` -> `defaultChatAgent.`, so pointing all of them at + * a local server via `product.overrides.json` lets a dev exercise the whole + * policy pipeline offline. + * + * NOTE: The server uses `module.stripTypeScriptTypes()` to serve this file to + * the browser as plain JavaScript — no build step is needed. + */ + +export interface EndpointPreset { + id: string; + label: string; + description: string; + body: unknown; +} + +export interface EndpointDef { + /** Stable id used by the API + GUI. */ + id: string; + /** Human label for the GUI tab. */ + label: string; + /** URL path the server serves / Code OSS calls. */ + path: string; + /** Key under product.json `defaultChatAgent`. */ + productKey: string; + /** One-line summary for the GUI. */ + description: string; + /** First preset is used as the default body. */ + presets: EndpointPreset[]; +} + +/* eslint-disable-next-line no-var -- UMD global for browser + + + + diff --git a/scripts/mock-policy-server/public/style.css b/scripts/mock-policy-server/public/style.css new file mode 100644 index 0000000000000..7c5e73aa0ca05 --- /dev/null +++ b/scripts/mock-policy-server/public/style.css @@ -0,0 +1,592 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +:root { + color-scheme: light dark; + --bg: #1e1e1e; + --surface: #252526; + --surface-alt: #2d2d2e; + --border: #3c3c3c; + --text: #e0e0e0; + --text-secondary: #a0a0a0; + --accent: #0e639c; + --accent-hover: #1177bb; + --ok: #4ec9b0; + --error: #f48771; + --code-bg: #1b1b1b; + --code-border: #3c3c3c; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + font-size: 13px; + background: var(--bg); + color: var(--text); + line-height: 1.6; +} + +header { + padding: 24px; + background: linear-gradient(135deg, var(--surface-alt) 0%, var(--surface) 100%); + border-bottom: 1px solid var(--border); +} + +.header-content { + max-width: 1200px; + margin: 0 auto; +} + +h1 { + font-size: 24px; + font-weight: 600; + margin: 0 0 4px; + letter-spacing: -0.5px; +} + +h2 { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary); + margin: 0; +} + +.sub { + color: var(--text-secondary); + margin: 0; + font-size: 13px; +} + +main { + display: grid; + grid-template-columns: 1fr 360px; + gap: 20px; + padding: 24px; + max-width: 1400px; + margin: 0 auto; + align-items: start; +} + +@media (max-width: 900px) { + main { + grid-template-columns: 1fr; + } +} + +.editor-panel { + display: flex; + flex-direction: column; + gap: 20px; +} + +.sidebar-panel { + display: flex; + flex-direction: column; + gap: 20px; +} + +.tabs-header { + border-bottom: 1px solid var(--border); + padding-bottom: 12px; +} + +.content-section { + display: flex; + flex-direction: column; + gap: 20px; +} + +.section-block { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.section-block[hidden] { + display: none; +} + +.section-header { + display: flex; + align-items: center; + gap: 8px; + margin: 0; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +label { + display: block; + font-weight: 500; + color: var(--text); +} + +.label-row { + display: flex; + align-items: center; + gap: 6px; +} + +.label-small { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); +} + +.info-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + font-size: 12px; + cursor: help; + color: var(--accent); + font-weight: 700; + transition: color 0.2s ease; + position: relative; +} + +.info-icon::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 8px; + padding: 6px 10px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 1000; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.info-icon:hover { + color: var(--accent-hover); +} + +.info-icon:hover::after { + opacity: 1; +} + +.row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +select, +textarea { + font-family: inherit; + font-size: inherit; + color: var(--text); + background: var(--code-bg); + border: 1px solid var(--code-border); + border-radius: 4px; + padding: 8px 10px; + transition: border-color 0.2s ease; +} + +select:hover, +textarea:hover { + border-color: var(--border); +} + +select:focus, +textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(14, 99, 156, 0.1); +} + +select { + flex: 1; + min-width: 160px; + cursor: pointer; +} + +textarea { + width: 100%; + min-height: 280px; + resize: vertical; + font-family: 'SF Mono', Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + white-space: pre; + overflow-wrap: normal; + tab-size: 4; +} + +button { + font-family: inherit; + font-size: inherit; + cursor: pointer; + border: 1px solid var(--border); + border-radius: 4px; + padding: 8px 12px; + transition: all 0.2s ease; + white-space: nowrap; +} + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; + font-weight: 500; +} + +.btn-primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.btn-secondary { + background: var(--surface-alt); + border-color: var(--border); + color: var(--text); +} + +.btn-secondary:hover { + background: #3c3c3e; + border-color: var(--text-secondary); +} + +.btn-full { + width: 100%; +} + +.button-group { + display: flex; + gap: 8px; + align-items: center; + margin-top: 4px; +} + +.button-group-vertical { + display: flex; + flex-direction: column; + gap: 8px; +} + +.status-box { + display: flex; + flex-direction: column; + gap: 6px; +} + +.helper-text { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + margin: 0; +} + +.status { + min-height: 18px; + margin: 0; + font-size: 12px; +} + +.status[data-kind='ok'] { + color: var(--ok); +} + +.status[data-kind='error'] { + color: var(--error); +} + +.status[data-kind='warn'] { + color: #d7ba7d; +} + +strong[data-kind='error'] { + color: var(--error); +} + +strong[data-kind='ok'] { + color: var(--ok); +} + +pre.code, +.code { + background: var(--code-bg); + border: 1px solid var(--code-border); + border-radius: 4px; + padding: 8px; + font-family: 'SF Mono', Menlo, Consolas, monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; +} + +.code-inline { + display: inline-block; + background: var(--code-bg); + border: 1px solid var(--code-border); + border-radius: 3px; + padding: 2px 6px; + font-family: 'SF Mono', Menlo, Consolas, monospace; + font-size: 11px; +} + +code { + font-family: 'SF Mono', Menlo, Consolas, monospace; + background: rgba(255, 255, 255, 0.08); + padding: 2px 6px; + border-radius: 3px; + font-size: 12px; +} + +.schema-view { + max-height: 240px; + overflow: auto; + margin-top: 8px; + border-radius: 4px; +} + +.tabs { + display: flex; + gap: 2px; + flex-wrap: wrap; +} + +.tab { + background: transparent; + border: 1px solid transparent; + border-radius: 4px 4px 0 0; + padding: 8px 14px; + color: var(--text-secondary); + font-weight: 500; + transition: all 0.2s ease; +} + +.tab:hover { + background: var(--surface-alt); + color: var(--text); +} + +.tab.active { + background: var(--surface); + border-color: var(--border); + border-bottom-color: var(--surface); + color: var(--text); +} + +.endpoint-description { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; +} + +.meta-route { + font-size: 12px; + color: var(--text-secondary); +} + +.meta-route code { + font-size: 11px; +} + +.meta-desc { + font-size: 13px; + color: var(--text); + line-height: 1.5; +} + +.endpoint-url-box { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px; + background: var(--code-bg); + border: 1px solid var(--code-border); + border-radius: 4px; +} + +.endpoint-url { + padding: 0; + border: none; + background: transparent; + font-family: 'SF Mono', Menlo, Consolas, monospace; + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.steps { + padding-left: 20px; + margin: 0; +} + +.steps li { + margin: 6px 0; + color: var(--text); +} + +.schema-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + position: relative; + transition: background 0.15s ease; +} + +.schema-badge[data-kind="ok"] { + color: var(--ok); + background: rgba(78, 201, 176, 0.1); +} + +.schema-badge[data-kind="error"] { + color: var(--error); + background: rgba(244, 135, 113, 0.1); +} + +.schema-badge:hover { + background: var(--surface-alt); +} + +.schema-badge::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + right: 0; + margin-bottom: 8px; + padding: 6px 10px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 11px; + font-weight: 400; + color: var(--text-secondary); + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 1000; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.schema-badge:hover::after { + opacity: 1; +} + +.schema-details { + margin-top: 0; +} + +.schema-details[hidden] { + display: none; +} + +.schema-source-input { + display: block; + width: 100%; + font-family: 'SF Mono', Menlo, Consolas, monospace; + font-size: 11px; + color: var(--text); + background: var(--code-bg); + border: 1px solid var(--code-border); + border-radius: 4px; + padding: 6px 8px; + margin-bottom: 8px; +} + +#validation-results { + margin-top: 8px; +} + +#validation-results[hidden] { + display: none; +} + +.validation-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.validation-table th { + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + padding: 4px 8px; + border-bottom: 1px solid var(--border); +} + +.validation-table td { + padding: 4px 8px; + border-bottom: 1px solid var(--border); +} + +.validation-table code { + font-size: 11px; +} + +.validation-ok { + color: var(--ok); +} + +.validation-warn { + color: #d7ba7d; +} + +.validation-summary { + font-size: 12px; + margin-top: 6px; +} + +.section-header-toggle { + cursor: pointer; + user-select: none; +} + +.section-header-toggle:hover { + color: var(--text); +} + +.chevron { + font-size: 10px; + color: var(--text-secondary); + transition: transform 0.2s ease; + display: inline-block; +} + +.chevron.open { + transform: rotate(90deg); +} diff --git a/scripts/mock-policy-server/server.ts b/scripts/mock-policy-server/server.ts new file mode 100644 index 0000000000000..f74ca02d59652 --- /dev/null +++ b/scripts/mock-policy-server/server.ts @@ -0,0 +1,437 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Standalone dev tool: a local mock of the Copilot "policy" endpoints that + * `DefaultAccountService` calls (entitlements, token, MCP registry, managed + * settings), plus a small web GUI to author each response and to wire/unwire + * `product.overrides.json`. + * + * This tool is NOT part of the shipped product. Run it from sources with: + * + * npm run mock-policy-server + * + * Then open the printed URL, pick an endpoint, edit the JSON, Save, and Wire. + * Reload Code OSS and run "Developer: Sync Account Policy" + + * "Developer: Policy Diagnostics". + */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { EndpointDef } from './endpoints'; + +const http = require('node:http') as typeof import('node:http'); +const fs = require('node:fs') as typeof import('node:fs'); +const path = require('node:path') as typeof import('node:path'); +const { fileURLToPath } = require('node:url') as typeof import('node:url'); +const { stripTypeScriptTypes } = require('node:module') as typeof import('node:module'); + +const endpoints: EndpointDef[] = require('./endpoints.ts'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const PRODUCT_JSON = path.join(ROOT, 'product.json'); +const PRODUCT_OVERRIDES_JSON = path.join(ROOT, 'product.overrides.json'); +const PRODUCT_OVERRIDES_BACKUP = path.join(ROOT, 'product.overrides.json.pre-mock-server'); +const PUBLIC_DIR = path.join(__dirname, 'public'); + +/** + * Default location of the managed-settings JSON schema, resolved against the + * app's current working directory (i.e. where `npm run mock-policy-server` is + * invoked — normally the vscode repo root). On dev machines the schema sits at + * `./copilot-agent-runtime/schema/managed-settings-schema.json`. Override with + * `--schema ` or the `MANAGED_SETTINGS_SCHEMA` env var; web + * (`http(s)://`) and `file://` URIs are both accepted. + */ +const DEFAULT_SCHEMA_SOURCE = 'copilot-agent-runtime/schema/managed-settings-schema.json'; + +const args = parseArgs(process.argv.slice(2)); +const PORT = Number(args.port || process.env.PORT || 3000); +const HOST = args.host || '127.0.0.1'; +const SCHEMA_SOURCE = args.schema || process.env.MANAGED_SETTINGS_SCHEMA || DEFAULT_SCHEMA_SOURCE; + +/** Path -> endpoint definition. */ +const endpointByPath = new Map(endpoints.map(e => [e.path, e])); + +const currentBodies: Record = {}; +for (const endpoint of endpoints) { + currentBodies[endpoint.id] = endpoint.presets[0] ? clone(endpoint.presets[0].body) : {}; +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url || '/', `http://${req.headers.host}`); + const pathname = url.pathname; + + try { + // Mocked Copilot endpoints. Only these get permissive CORS, so the web + // build (browser) of Code OSS can call them cross-origin. The control API + // (/api/*) and static assets stay same-origin: that avoids a CSRF surface + // where an unrelated website could drive /api/wire and rewrite the local + // product.overrides.json. + const endpoint = endpointByPath.get(pathname); + if (endpoint) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type'); + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + if (req.method === 'GET') { + return sendJson(res, 200, currentBodies[endpoint.id]); + } + } + + // GUI control API (same-origin only — no CORS). + if (pathname === '/api/state' && req.method === 'GET') { + return sendJson(res, 200, getState()); + } + + if (pathname === '/api/schema' && req.method === 'GET') { + const url = new URL(req.url, `http://${req.headers.host}`); + const sourceParam = url.searchParams.get('source') || undefined; + return loadSchema(sourceParam) + .then(result => sendJson(res, 200, result)) + .catch(e => sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) })); + } + + if (pathname === '/api/state' && req.method === 'POST') { + return readBody(req, (err, raw) => { + if (err) { + return sendJson(res, 400, { error: String(err) }); + } + let payload; + try { + payload = JSON.parse(raw); + } catch (e) { + return sendJson(res, 400, { error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` }); + } + const def = endpoints.find(e => e.id === payload?.endpoint); + if (!def) { + return sendJson(res, 400, { error: `Unknown endpoint "${payload?.endpoint}".` }); + } + currentBodies[def.id] = payload.body; + return sendJson(res, 200, getState()); + }); + } + + if (pathname === '/api/wire' && req.method === 'POST') { + try { + wireOverrides(); + return sendJson(res, 200, getState()); + } catch (e) { + return sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } + } + + if (pathname === '/api/unwire' && req.method === 'POST') { + try { + unwireOverrides(); + return sendJson(res, 200, getState()); + } catch (e) { + return sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } + } + + if (req.method === 'GET') { + return serveStatic(pathname, res); + } + + sendJson(res, 404, { error: 'Not found' }); + } catch (e) { + sendJson(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } +}); + +server.listen(PORT, HOST, () => { + const base = `http://${HOST}:${PORT}`; + console.log(''); + console.log(' Mock Copilot policy endpoints dev server'); + console.log(' ----------------------------------------'); + console.log(` GUI: ${base}/`); + for (const endpoint of endpoints) { + console.log(` ${endpoint.label.padEnd(18)} ${base}${endpoint.path}`); + } + console.log(''); + console.log(` Managed-settings schema source: ${SCHEMA_SOURCE}`); + console.log(''); + console.log(' Open the GUI, edit the responses, Save, then Wire product.overrides.json.'); + console.log(' Reload Code OSS and run "Developer: Sync Account Policy".'); + console.log(''); +}); + +/** The URL Code OSS should call for a given endpoint. */ +function endpointUrl(endpoint: EndpointDef): string { + return `http://${HOST}:${PORT}${endpoint.path}`; +} + +/** + * Resolve and load the managed-settings JSON schema from {@link SCHEMA_SOURCE}. + * Accepts a web URL (`http(s)://`), a `file://` URI, or a filesystem path + * (relative paths are resolved against the app's cwd). Re-reads on every call so + * a dev can edit the schema and refresh the GUI without restarting the server. + */ +async function loadSchema(sourceOverride?: string): Promise<{ source: string; resolved: string; ok: boolean; schema?: unknown; error?: string }> { + const source = sourceOverride || SCHEMA_SOURCE; + try { + if (/^https?:\/\//i.test(source)) { + const res = await fetch(source); + if (!res.ok) { + return { source, resolved: source, ok: false, error: `HTTP ${res.status} ${res.statusText}` }; + } + return { source, resolved: source, ok: true, schema: await res.json() }; + } + + const filePath = source.startsWith('file://') + ? fileURLToPath(source) + : path.resolve(process.cwd(), source); + + // Guard against relative path traversal. + if (!path.isAbsolute(source) && filePath.includes('..')) { + return { source, resolved: filePath, ok: false, error: 'Relative paths must not contain ".."' }; + } + + if (!fs.existsSync(filePath)) { + return { source, resolved: filePath, ok: false, error: `Schema file not found at ${filePath}` }; + } + const schema = JSON.parse(fs.readFileSync(filePath, 'utf8')); + return { source, resolved: filePath, ok: true, schema }; + } catch (e) { + return { source, resolved: source, ok: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +/** Build the state object the GUI renders. */ +function getState() { + return { + endpoints: endpoints.map(e => ({ + id: e.id, + label: e.label, + path: e.path, + productKey: e.productKey, + description: e.description, + url: endpointUrl(e), + presets: e.presets, + body: currentBodies[e.id] + })), + wired: isWired(), + overridesPath: PRODUCT_OVERRIDES_JSON, + overridesSnippet: buildOverridesSnippet() + }; +} + +/** Build the full overrides JSON a user would paste into product.overrides.json. */ +function buildOverridesSnippet() { + const product = JSON.parse(fs.readFileSync(PRODUCT_JSON, 'utf8')); + const baseAgent = product?.defaultChatAgent ?? {}; + return JSON.stringify({ defaultChatAgent: { ...baseAgent, ...overrideUrls() } }, null, '\t'); +} + +/** The `defaultChatAgent` URL overrides this server provides. */ +function overrideUrls(): Record { + const urls: Record = {}; + for (const endpoint of endpoints) { + urls[endpoint.productKey] = endpointUrl(endpoint); + } + return urls; +} + +/** Whether `product.overrides.json` currently points every endpoint at this server. */ +function isWired(): boolean { + let overrides; + try { + overrides = JSON.parse(fs.readFileSync(PRODUCT_OVERRIDES_JSON, 'utf8')); + } catch { + return false; + } + const agent = overrides?.defaultChatAgent; + if (!agent) { + return false; + } + const urls = overrideUrls(); + return Object.keys(urls).every(key => agent[key] === urls[key]); +} + +/** + * Write `product.overrides.json` so Code OSS calls this server for every policy + * endpoint. + * + * `src/bootstrap-meta.ts` merges overrides via `Object.assign` (shallow, + * top-level), so overriding nested keys requires writing back the whole + * `defaultChatAgent` object. We seed it from `product.json` and flip only the + * endpoint URLs, preserving every other key. Any other top-level overrides + * already present are kept untouched. + */ +function wireOverrides(): void { + const product = JSON.parse(fs.readFileSync(PRODUCT_JSON, 'utf8')); + const baseAgent = product?.defaultChatAgent ?? {}; + + // Back up existing overrides before touching them. + if (fs.existsSync(PRODUCT_OVERRIDES_JSON)) { + fs.copyFileSync(PRODUCT_OVERRIDES_JSON, PRODUCT_OVERRIDES_BACKUP); + console.log(` Backed up ${PRODUCT_OVERRIDES_JSON} -> ${PRODUCT_OVERRIDES_BACKUP}`); + } + + let overrides = {}; + try { + overrides = JSON.parse(fs.readFileSync(PRODUCT_OVERRIDES_JSON, 'utf8')); + } catch { + overrides = {}; + } + + const existingAgent = overrides.defaultChatAgent ?? baseAgent; + overrides.defaultChatAgent = { + ...baseAgent, + ...existingAgent, + ...overrideUrls() + }; + + fs.writeFileSync(PRODUCT_OVERRIDES_JSON, JSON.stringify(overrides, null, '\t') + '\n'); + console.log(` Wired ${PRODUCT_OVERRIDES_JSON} -> ${HOST}:${PORT}`); +} + +/** + * Revert the endpoint overrides: restore each URL to its `product.json` value + * (or drop the key if absent). If `defaultChatAgent` ends up identical to + * `product.json`, drop it; if the overrides file ends up empty, remove it. + */ +function unwireOverrides(): void { + // If we have a backup, restore it wholesale instead of surgically reverting. + if (fs.existsSync(PRODUCT_OVERRIDES_BACKUP)) { + fs.copyFileSync(PRODUCT_OVERRIDES_BACKUP, PRODUCT_OVERRIDES_JSON); + fs.rmSync(PRODUCT_OVERRIDES_BACKUP, { force: true }); + console.log(` Restored ${PRODUCT_OVERRIDES_JSON} from backup`); + return; + } + + let overrides; + try { + overrides = JSON.parse(fs.readFileSync(PRODUCT_OVERRIDES_JSON, 'utf8')); + } catch { + return; // nothing to unwire + } + if (!overrides.defaultChatAgent) { + return; + } + + const product = JSON.parse(fs.readFileSync(PRODUCT_JSON, 'utf8')); + const baseAgent = product?.defaultChatAgent ?? {}; + + const agent = { ...overrides.defaultChatAgent }; + for (const endpoint of endpoints) { + if (baseAgent[endpoint.productKey] === undefined) { + delete agent[endpoint.productKey]; + } else { + agent[endpoint.productKey] = baseAgent[endpoint.productKey]; + } + } + + if (shallowEqual(agent, baseAgent)) { + delete overrides.defaultChatAgent; + } else { + overrides.defaultChatAgent = agent; + } + + if (Object.keys(overrides).length === 0) { + fs.rmSync(PRODUCT_OVERRIDES_JSON, { force: true }); + console.log(` Removed ${PRODUCT_OVERRIDES_JSON} (no overrides left)`); + } else { + fs.writeFileSync(PRODUCT_OVERRIDES_JSON, JSON.stringify(overrides, null, '\t') + '\n'); + console.log(` Unwired ${PRODUCT_OVERRIDES_JSON}`); + } +} + +/** Serve a file from the public/ directory (plus the shared endpoints.js). */ +/** + * Read a `.ts` source file, strip type annotations via Node's built-in + * `module.stripTypeScriptTypes()`, and serve the result as plain JavaScript. + * This lets the browser GUI stay in TypeScript without a build step. + */ +function serveTypeStripped(tsPath: string, res: ServerResponse): void { + const source = fs.readFileSync(tsPath, 'utf8'); + const stripped = stripTypeScriptTypes(source); + res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' }); + res.end(stripped); +} + +function serveStatic(pathname: string, res: ServerResponse): void { + // The GUI loads the shared endpoints module that lives one level up. + if (pathname === '/endpoints.js') { + return serveTypeStripped(path.join(__dirname, 'endpoints.ts'), res); + } + + const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, ''); + + // Serve .ts sources as type-stripped JS when the browser requests .js. + if (rel.endsWith('.js')) { + const tsPath = path.normalize(path.join(PUBLIC_DIR, rel.replace(/\.js$/, '.ts'))); + if (tsPath.startsWith(PUBLIC_DIR + path.sep) && fs.existsSync(tsPath)) { + return serveTypeStripped(tsPath, res); + } + } + + const filePath = path.normalize(path.join(PUBLIC_DIR, rel)); + + // Guard against path traversal outside public/. + if (!filePath.startsWith(PUBLIC_DIR + path.sep) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + return sendJson(res, 404, { error: 'Not found' }); + } + + res.writeHead(200, { 'Content-Type': contentType(filePath) }); + fs.createReadStream(filePath).pipe(res); +} + +function contentType(filePath: string): string { + switch (path.extname(filePath)) { + case '.html': return 'text/html; charset=utf-8'; + case '.js': return 'text/javascript; charset=utf-8'; + case '.ts': return 'text/javascript; charset=utf-8'; + case '.css': return 'text/css; charset=utf-8'; + case '.json': return 'application/json; charset=utf-8'; + default: return 'application/octet-stream'; + } +} + +function sendJson(res: ServerResponse, status: number, obj: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(obj, null, 2)); +} + +function readBody(req: IncomingMessage, cb: (err: Error | null, raw: string) => void): void { + let raw = ''; + req.on('data', chunk => { raw += chunk; if (raw.length > 1_000_000) { req.destroy(); } }); + req.on('end', () => cb(null, raw)); + req.on('error', err => cb(err, '')); +} + +function shallowEqual(a: Record, b: Record): boolean { + const ak = Object.keys(a); + const bk = Object.keys(b); + if (ak.length !== bk.length) { + return false; + } + return ak.every(k => JSON.stringify(a[k]) === JSON.stringify(b[k])); +} + +function clone(value: unknown): unknown { + return JSON.parse(JSON.stringify(value)); +} + +function parseArgs(argv: string[]): Record { + const out: Record = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next && !next.startsWith('--')) { + out[key] = next; + i++; + } else { + out[key] = 'true'; + } + } + } + return out; +} diff --git a/src/typings/copilot-api.d.ts b/src/typings/copilot-api.d.ts index cf56881a70d34..3048776fdb0e8 100644 --- a/src/typings/copilot-api.d.ts +++ b/src/typings/copilot-api.d.ts @@ -26,7 +26,6 @@ declare module '@vscode/copilot-api' { json?: unknown; method?: 'GET' | 'POST' | 'PUT'; signal?: IAbortSignal; - suppressIntegrationId?: boolean; } export type MakeRequestOptions = Omit & { diff --git a/src/vs/base/browser/ui/splitview/paneview.css b/src/vs/base/browser/ui/splitview/paneview.css index 7bb4282a2279d..ab4d94956cd36 100644 --- a/src/vs/base/browser/ui/splitview/paneview.css +++ b/src/vs/base/browser/ui/splitview/paneview.css @@ -6,6 +6,7 @@ .monaco-pane-view { width: 100%; height: 100%; + --pane-header-size: 22px; } .monaco-pane-view .pane { @@ -21,7 +22,7 @@ } .monaco-pane-view .pane > .pane-header { - height: 22px; + height: var(--pane-header-size); font-size: 11px; font-weight: bold; overflow: hidden; @@ -42,7 +43,7 @@ .monaco-pane-view .pane.horizontal:not(.expanded) > .pane-header { flex-direction: column; height: 100%; - width: 22px; + width: var(--pane-header-size); } .monaco-pane-view .pane > .pane-header > .codicon:first-of-type { diff --git a/src/vs/base/browser/ui/splitview/paneview.ts b/src/vs/base/browser/ui/splitview/paneview.ts index fb2e1f406aecf..42e000a5dcddb 100644 --- a/src/vs/base/browser/ui/splitview/paneview.ts +++ b/src/vs/base/browser/ui/splitview/paneview.ts @@ -48,6 +48,10 @@ export interface IPaneStyles { */ export abstract class Pane extends Disposable implements IView { + /** + * Fallback header size (in px) used when the `--pane-header-size` CSS variable + * is not resolvable (e.g. before the element is attached to the document). + */ private static readonly HEADER_SIZE = 22; readonly element: HTMLElement; @@ -73,6 +77,14 @@ export abstract class Pane extends Disposable implements IView { }; private animationTimer: number | undefined = undefined; + /** + * Cached result of {@link Pane.resolveHeaderSize}. Resolving reads a computed + * style, which is comparatively expensive and runs on the layout hot path + * (`minimumSize` / `maximumSize` / `layout` can each read it), so the value is + * memoized and only re-read once per {@link Pane.layout} pass. + */ + private _headerSize: number | undefined = undefined; + private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; @@ -118,8 +130,23 @@ export abstract class Pane extends Disposable implements IView { this._onDidChange.fire(undefined); } + /** + * Resolves the header size from the `--pane-header-size` CSS variable so it can + * be overridden via CSS (e.g. by the `paneHeaders` style-override) without a + * hard-coded constant. Falls back to {@link Pane.HEADER_SIZE} when the variable + * is absent or unparseable. The result is cached and refreshed once per + * {@link Pane.layout} pass. + */ + private resolveHeaderSize(): number { + if (this._headerSize === undefined) { + const size = parseInt(getWindow(this.element).getComputedStyle(this.element).getPropertyValue('--pane-header-size'), 10); + this._headerSize = isNaN(size) ? Pane.HEADER_SIZE : size; + } + return this._headerSize; + } + private get headerSize(): number { - return this.headerVisible ? Pane.HEADER_SIZE : 0; + return this.headerVisible ? this.resolveHeaderSize() : 0; } get minimumSize(): number { @@ -298,7 +325,10 @@ export abstract class Pane extends Disposable implements IView { } layout(size: number): void { - const headerSize = this.headerVisible ? Pane.HEADER_SIZE : 0; + // Re-read the header size from CSS once per layout pass; subsequent + // `minimumSize` / `maximumSize` reads within the pass reuse the cache. + this._headerSize = undefined; + const headerSize = this.headerSize; const width = this._orientation === Orientation.VERTICAL ? this.orthogonalSize : size; const height = this._orientation === Orientation.VERTICAL ? size - headerSize : this.orthogonalSize - headerSize; diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index dfd6b9991db09..073284c74c107 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -295,6 +295,7 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( onDidChangeCopilotTokenInfo: Event.None, managedSettingsFetchStatus: null, managedSettingsFetchedAt: null, + managedSettingsRawResponse: null, getDefaultAccount: async () => null, setDefaultAccountProvider: () => { }, getDefaultAccountAuthenticationProvider: () => { return { id: 'mockProvider', name: 'Mock Provider', enterprise: false }; }, diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 09100764ebe1b..52cd2abf96de4 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -1131,6 +1131,7 @@ class StandaloneDefaultAccountService implements IDefaultAccountService { readonly onDidChangeCopilotTokenInfo: Event = Event.None; readonly managedSettingsFetchStatus: null = null; readonly managedSettingsFetchedAt: null = null; + readonly managedSettingsRawResponse: unknown = null; async getDefaultAccount(): Promise { return null; diff --git a/src/vs/platform/agentHost/common/sandboxConfigSchema.ts b/src/vs/platform/agentHost/common/sandboxConfigSchema.ts index 3216a0069405e..86ab1ca422234 100644 --- a/src/vs/platform/agentHost/common/sandboxConfigSchema.ts +++ b/src/vs/platform/agentHost/common/sandboxConfigSchema.ts @@ -29,7 +29,6 @@ export const enum AgentHostSandboxKey { Enabled = 'enabled', WindowsEnabled = 'enabled.windows', AllowUnsandboxedCommands = 'allowUnsandboxedCommands', - AutoApproveUnsandboxedCommands = 'autoApproveUnsandboxedCommands', LinuxFileSystem = 'fileSystem.linux', MacFileSystem = 'fileSystem.mac', WindowsFileSystem = 'fileSystem.windows', @@ -43,7 +42,6 @@ export type ISandboxConfigValue = Partial<{ [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue; [AgentHostSandboxKey.WindowsEnabled]: AgentSandboxEnabledValue; [AgentHostSandboxKey.AllowUnsandboxedCommands]: boolean; - [AgentHostSandboxKey.AutoApproveUnsandboxedCommands]: boolean; [AgentHostSandboxKey.LinuxFileSystem]: Record; [AgentHostSandboxKey.MacFileSystem]: Record; [AgentHostSandboxKey.WindowsFileSystem]: Record; @@ -87,10 +85,6 @@ export const sandboxConfigSchema = createSchema({ type: 'boolean', title: localize('agentHost.config.sandbox.allowUnsandboxedCommands.title', "Allow Unsandboxed Commands"), }, - [AgentHostSandboxKey.AutoApproveUnsandboxedCommands]: { - type: 'boolean', - title: localize('agentHost.config.sandbox.autoApproveUnsandboxedCommands.title', "Auto-Approve Unsandboxed Commands"), - }, [AgentHostSandboxKey.LinuxFileSystem]: { type: 'object', title: localize('agentHost.config.sandbox.linuxFileSystem.title', "Linux Sandbox Filesystem"), @@ -133,7 +127,6 @@ export const sandboxSettingIdToAgentHostKey: Readonly { - const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal, suppressIntegrationId: true }; + const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal }; let message: Anthropic.Message; try { message = await this._copilotApiService.messages(runtime.githubToken, body, options); @@ -603,7 +603,7 @@ export class ClaudeProxyService implements IClaudeProxyService { _originalSdkModelId: string, sessionId: string | undefined, ): Promise { - const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal, suppressIntegrationId: true }; + const options: ICopilotApiServiceRequestOptions = { headers, signal: entry.ac.signal }; let stream: AsyncGenerator; try { stream = this._copilotApiService.messages(runtime.githubToken, body, options); diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index e413b832cadad..0d35ca7ccde1b 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -713,7 +713,7 @@ export class CodexAgent extends Disposable implements IAgent { private async _refreshModels(token: string): Promise { try { - const all = await this._copilotApiService.models(token, { suppressIntegrationId: true }); + const all = await this._copilotApiService.models(token); if (this._githubToken !== token) { return; } diff --git a/src/vs/platform/agentHost/node/codex/codexProxyService.ts b/src/vs/platform/agentHost/node/codex/codexProxyService.ts index a6c4f68481690..4218c7afc30e6 100644 --- a/src/vs/platform/agentHost/node/codex/codexProxyService.ts +++ b/src/vs/platform/agentHost/node/codex/codexProxyService.ts @@ -408,7 +408,7 @@ export class CodexProxyService implements ICodexProxyService { try { this._logService.info(`[${PROXY_USER_FACING_NAME}] forwarding to CAPI responses...`); - const upstream = await this._copilotApiService.responses(dispatchedToken, body, { signal: entry.ac.signal, suppressIntegrationId: true }); + const upstream = await this._copilotApiService.responses(dispatchedToken, body, { signal: entry.ac.signal }); const contentType = upstream.headers.get('content-type') ?? 'application/json'; const upstreamHeaders = [...upstream.headers.entries()].map(([k, v]) => `${k}: ${v}`).join(', '); this._logService.info(`[${PROXY_USER_FACING_NAME}] <<< CAPI response: status=${upstream.status}, contentType=${contentType}, headers=[${upstreamHeaders}]`); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 516ffb6c196f1..aa37993b82811 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -1818,7 +1818,6 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientState: launchPlan.activeClientState, resolveMcpChildId: name => findMcpChildId(activeClient.pluginController.getCustomizations(), name), serverToolHost: this._serverToolHost, - fetchQuotaSnapshots: () => this._fetchQuotaSnapshots(), }, ); @@ -1827,22 +1826,6 @@ export class CopilotAgent extends Disposable implements IAgent { return agentSession; } - /** - * Fetches the current Copilot quota snapshots via the SDK's - * `account.getQuota` RPC (exposed only on the top-level client). Returns - * the raw `quotaSnapshots` map keyed by quota type, or `undefined` when no - * client is running. Bound and handed to each {@link CopilotAgentSession} - * so it can forward per-response quota to the client. - */ - private async _fetchQuotaSnapshots(): Promise | undefined> { - const client = this._client; - if (!client) { - return undefined; - } - const result = await client.rpc.account.getQuota({}); - return result.quotaSnapshots as Record | undefined; - } - /** * Register a freshly initialised session in `_sessions`, or — if * shutdown has already started between init beginning and resolving — diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 625ad632c7a67..e81e71e4a462a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -327,14 +327,6 @@ export interface ICopilotAgentSessionOptions { * the future) and exposes SDK tool handlers that execute them in-process. */ readonly serverToolHost?: IAgentServerToolHost; - /** - * Fetches the user's current Copilot quota snapshots (keyed by quota type, - * e.g. `chat` / `premium_interactions`) via the SDK's `account.getQuota` - * RPC. The SDK exposes this only on the top-level client, so the agent - * passes a bound callback. Used to forward per-response quota to the client - * so the core can update `IChatEntitlementService`. - */ - readonly fetchQuotaSnapshots?: () => Promise | undefined>; } /** @@ -446,23 +438,6 @@ export class CopilotAgentSession extends Disposable { private readonly _workingDirectory: URI | undefined; private readonly _customizationDirectory: URI | undefined; private readonly _serverToolHost: IAgentServerToolHost | undefined; - /** Fetches the user's current quota snapshots via the SDK `account.getQuota` RPC. */ - private readonly _fetchQuotaSnapshots: (() => Promise | undefined>) | undefined; - /** - * Most recent usage emitted for the active turn (token totals + `_meta`), - * and its turn id. The out-of-band quota fetch re-emits this latest usage - * with quota attached, so a quota update never regresses the token meter - * even if a newer usage event lands while `account.getQuota` is in flight. - */ - private _latestUsage: UsageInfo | undefined; - private _latestUsageTurnId = ''; - /** - * Guards against overlapping {@link _fetchAndEmitQuota} calls: `assistant.usage` - * can fire multiple times per turn (e.g. per model call / sub-agent), so we - * collapse concurrent `account.getQuota` fetches into one. The follow-up emit - * always re-reads {@link _latestUsage}, so a skipped fetch loses no data. - */ - private _quotaFetchInFlight = false; /** Bridges SDK-reported MCP server state into AHP customization actions. */ private readonly _mcpCustomizations: McpCustomizationController; @@ -516,7 +491,6 @@ export class CopilotAgentSession extends Disposable { this._workingDirectory = options.workingDirectory; this._customizationDirectory = options.customizationDirectory; this._serverToolHost = options.serverToolHost; - this._fetchQuotaSnapshots = options.fetchQuotaSnapshots; this._appliedSnapshot = options.clientSnapshot ?? { tools: [], plugins: [] }; this._clientToolNames = new Set(this._appliedSnapshot.tools.map(t => t.name)); @@ -590,47 +564,6 @@ export class CopilotAgentSession extends Disposable { }); } - /** - * Out-of-band quota update: fetches the user's current quota snapshots via - * `account.getQuota` and forwards them on a follow-up {@link ActionType.ChatUsage} - * action. The SDK does not carry quota on the (public) usage event, so we fetch - * it separately; the fetch is fire-and-forget so it never delays the token / - * credit meter. To avoid regressing the meter if a newer usage event lands while - * the RPC is in flight, the follow-up re-emits the {@link _latestUsage} (current - * token totals) with quota attached — the consumer applies quota from `_meta` - * and dedupes the unchanged token totals. - */ - private async _fetchAndEmitQuota(sessionId: string): Promise { - // Collapse bursts of usage events into a single in-flight fetch. - if (this._quotaFetchInFlight) { - return; - } - this._quotaFetchInFlight = true; - let quotaSnapshots: Record | undefined; - try { - quotaSnapshots = await this._fetchQuotaSnapshots?.(); - } catch (error) { - this._logService.warn(`[Copilot:${sessionId}] account.getQuota failed`, error); - } finally { - this._quotaFetchInFlight = false; - } - - const latest = this._latestUsage; - if (!quotaSnapshots || Object.keys(quotaSnapshots).length === 0 || !latest) { - return; - } - - const usage: UsageInfo = { - ...latest, - _meta: { ...(latest._meta ?? {}), quotaSnapshots }, - }; - this._emitAction({ - type: ActionType.ChatUsage, - turnId: this._latestUsageTurnId, - usage, - }); - } - /** * Promotes a pending steering message into its own protocol turn: * closes the in-flight turn (so its responseParts settle into history) @@ -2442,8 +2375,7 @@ export class CopilotAgentSession extends Disposable { } // TODO: `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. - const rawUsage = e.data as unknown as Record; - const copilotUsage = rawUsage.copilotUsage as { totalNanoAiu?: number } | undefined; + const copilotUsage = (e.data as unknown as Record).copilotUsage as { totalNanoAiu?: number } | undefined; if (typeof copilotUsage?.totalNanoAiu === 'number') { this._turnCopilotUsageTotalNanoAiu += copilotUsage.totalNanoAiu; metadata.copilotUsage = { @@ -2451,7 +2383,6 @@ export class CopilotAgentSession extends Disposable { totalNanoAiu: this._turnCopilotUsageTotalNanoAiu, }; } - this._logService.trace(`[Copilot:${sessionId}] Usage: model=${e.data.model}, in=${e.data.inputTokens ?? '?'}, out=${e.data.outputTokens ?? '?'}, cacheRead=${e.data.cacheReadTokens ?? '?'}, cost=${e.data.cost ?? '?'}, totalNanoAiu=${metadata.copilotUsage ? this._turnCopilotUsageTotalNanoAiu : '?'}`); if (typeof e.data.model === 'string' && e.data.model) { this._lastSeenModelId = e.data.model; @@ -2463,21 +2394,11 @@ export class CopilotAgentSession extends Disposable { cacheReadTokens: e.data.cacheReadTokens, ...(Object.keys(metadata).length > 0 ? { _meta: metadata } : {}), }; - // Emit the token/credit usage immediately so the live meter is not - // delayed by the quota fetch below. - this._latestUsage = usage; - this._latestUsageTurnId = this._turnId; this._emitAction({ type: ActionType.ChatUsage, turnId: this._turnId, usage, }); - - // The SDK's (public) `assistant.usage` event does not carry quota snapshots, - // so we fetch them out-of-band via `account.getQuota` and forward them on a - // follow-up usage action (mirrors the Copilot Chat extension's per-response - // quota update). Fire-and-forget so the meter above is never blocked. - void this._fetchAndEmitQuota(sessionId); })); this._register(wrapper.onReasoningDelta(e => { diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index 14004b8afb109..d0db4dea996a7 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -716,11 +716,7 @@ export async function createShellTools( ); } - const autoApproveUnsandboxed = engine.isAutoApproveUnsandboxedCommands(); const requestUnsandboxedConfirmation = async (blockedDomains?: readonly string[]): Promise => { - if (autoApproveUnsandboxed) { - return true; - } if (!confirmUnsandboxedExecution) { const blocked = blockedDomains?.join(', ') ?? '(unknown)'; return makeFailureResult( diff --git a/src/vs/platform/agentHost/node/shared/copilotApiService.ts b/src/vs/platform/agentHost/node/shared/copilotApiService.ts index 730766c3ea44d..a405bdc995b9f 100644 --- a/src/vs/platform/agentHost/node/shared/copilotApiService.ts +++ b/src/vs/platform/agentHost/node/shared/copilotApiService.ts @@ -10,7 +10,7 @@ import { getDevDeviceId, getMachineId } from '../../../../base/node/id.js'; import { createDecorator } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; -import { COPILOT_INTEGRATION_ID, COPILOT_LICENSE_AGREEMENT } from '../../../endpoint/common/licenseAgreement.js'; +import { COPILOT_LICENSE_AGREEMENT } from '../../../endpoint/common/licenseAgreement.js'; // #region Types @@ -28,19 +28,6 @@ import { COPILOT_INTEGRATION_ID, COPILOT_LICENSE_AGREEMENT } from '../../../endp export interface ICopilotApiServiceRequestOptions { readonly headers?: Readonly>; readonly signal?: AbortSignal; - - /** - * Suppress the `Copilot-Integration-Id` header on this request. - * - * When unset, `@vscode/copilot-api` derives the integration id from the - * discovered Copilot SKU: a `no_auth_limited_copilot` SKU maps to - * `vscode-nl`, which the CAPI backend treats as the limited/no-auth - * integration and refuses premium models such as `claude-opus-4.7`. - * Setting this to `true` omits the header so CAPI authorizes against the - * token's real entitlement. Mirrors the Copilot Chat extension's - * `ClaudeStreamingPassThroughEndpoint.getEndpointFetchOptions()`. - */ - readonly suppressIntegrationId?: boolean; } /** @@ -535,9 +522,6 @@ export class CopilotApiService implements ICopilotApiService { ...options?.headers, 'Authorization': `Bearer ${githubToken}`, }, - // Opt-in per request — see - // `ICopilotApiServiceRequestOptions.suppressIntegrationId`. - suppressIntegrationId: options?.suppressIntegrationId, signal: options?.signal, }, { type: RequestType.Models }, @@ -582,9 +566,6 @@ export class CopilotApiService implements ICopilotApiService { 'X-Request-Id': requestId, 'OpenAI-Intent': 'conversation', }, - // Opt-in per request — see - // `ICopilotApiServiceRequestOptions.suppressIntegrationId`. - suppressIntegrationId: options?.suppressIntegrationId, body, signal: options?.signal, }, @@ -769,7 +750,6 @@ export class CopilotApiService implements ICopilotApiService { // paths already omit it). Thread a real per-turn initiator here if // that signal ever becomes available at the proxy boundary. }, - suppressIntegrationId: options?.suppressIntegrationId, body, signal: options?.signal, }, @@ -831,9 +811,6 @@ export class CopilotApiService implements ICopilotApiService { this._clientsByToken.delete(githubToken); } - protected getIntegrationId(): string | undefined { - return COPILOT_INTEGRATION_ID; - } private async _buildClientForToken(githubToken: string): Promise { const { extensionInfo, userUrl } = await this._getCapiBase(); const fetch = this._fetch; @@ -844,7 +821,7 @@ export class CopilotApiService implements ICopilotApiService { body: options.body, signal: options.signal as AbortSignal | undefined, }), - }, undefined, this.getIntegrationId()); + }); this._logService.debug('[CopilotApiService] Discovering CAPI endpoints via /copilot_internal/user'); diff --git a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts index 29e0aec8b26b1..ab7b878c23a85 100644 --- a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts +++ b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts @@ -39,29 +39,19 @@ export interface IEditSurvivalReporterLaunchParams { readonly afterText: string; /** Whether the tool created a new file (no prior content existed). */ readonly isCreate: boolean; + /** Name of the edit tool, e.g. `Edit`, `apply_patch`. Empty if unknown. */ + readonly toolName?: string; /** - * The model that produced this edit (e.g. `claude-sonnet-4.5`, - * `gpt-5-mini`). Used to slice survival telemetry by model in - * dashboards. Expected to always be populated in practice (Claude - * reads it off every assistant message; Copilot tracks the session's - * last-seen model via setModel and onUsage). The field is optional - * so a missing model can't suppress the survival sample, but - * `undefined` here is a bug -- the resulting event is emitted with - * `modelId=''` and the caller is expected to log a warning. + * Model that produced this edit, e.g. `claude-sonnet-4.5`. Optional + * defensively, but always expected to be set */ readonly modelId?: string; /** - * The explicit text chunks the AI wrote, extracted from the tool - * input (e.g. `Edit.new_string`, each `MultiEdit.edits[*].new_string`, - * or `Write.content`). When provided, the reporter computes - * `survivalRateFourGram` with the chunked, search-within math -- so - * the score does not decay as the file grows around the chunks. - * - * In practice every known file-edit tool produces chunks (see the - * coverage invariant in `editChunkExtractor.ts`). Omitting or - * passing an empty array is a safety-net path for unknown / drifted - * tool shapes; the reporter then falls back to whole-file scoring - * and tags the telemetry event with `scoringMode='whole-file'`. + * Explicit AI-written text chunks extracted from the tool input + * (see `editChunkExtractor.ts`). When provided, survival is scored + * against just these chunks; when omitted or empty, the reporter + * falls back to whole-file scoring and tags the event with + * `scoringMode='whole-file'`. */ readonly aiChunks?: readonly string[]; } @@ -93,6 +83,7 @@ export class NullEditSurvivalReporterFactory implements IEditSurvivalReporterFac interface IEditSurvivalTelemetryEvent { provider: string; modelId: string; + toolName: string; agentSessionId: string; turnId: string; toolCallId: string; @@ -113,6 +104,7 @@ interface IEditSurvivalTelemetryEvent { type IEditSurvivalTelemetryClassification = { provider: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The provider handling the agent host session.' }; modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model that produced the edit, e.g. "claude-sonnet-4.5" or "gpt-5-mini". Empty if the host could not determine the per-edit model.' }; + toolName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Name of the edit tool that produced the edit, e.g. "Edit", "apply_patch". Empty if unknown.' }; agentSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host session identifier.' }; turnId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host turn identifier this edit belongs to.' }; toolCallId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The tool call identifier that produced the edit.' }; @@ -204,6 +196,7 @@ class SessionEditSurvivalReporter extends Disposable { { provider: AgentSession.provider(this._params.sessionUri) ?? 'unknown', modelId: this._params.modelId ?? '', + toolName: this._params.toolName ?? '', agentSessionId: AgentSession.id(this._params.sessionUri), turnId: this._params.turnId, toolCallId: this._params.toolCallId, diff --git a/src/vs/platform/agentHost/node/shared/fileEditTracker.ts b/src/vs/platform/agentHost/node/shared/fileEditTracker.ts index b39e40749044f..914b4d10e57cc 100644 --- a/src/vs/platform/agentHost/node/shared/fileEditTracker.ts +++ b/src/vs/platform/agentHost/node/shared/fileEditTracker.ts @@ -143,25 +143,9 @@ export class FileEditTracker { * and returns the result as an {@link ToolResultFileEditContent} * for inclusion in the tool result. * - * @param turnId - The turn that produced this edit. - * @param toolCallId - The tool call that produced this edit. - * @param filePath - Absolute path of the edited file. - * @param toolName - The tool that produced this edit. Used together - * with {@link toolInput} to extract the AI-written text chunks - * for region-based survival scoring (see - * {@link IEditSurvivalReporterLaunchParams.aiChunks}). Pass an - * empty string when the tool is unknown; the survival reporter - * then falls back to whole-file scoring. - * @param toolInput - The raw tool input, as received from the - * agent. Parsed by {@link extractAiChunks} -- unknown shapes are - * tolerated and yield an empty chunk list (whole-file fallback). - * @param modelId - The model that produced this edit (e.g. - * `claude-sonnet-4.5`). Forwarded to the survival reporter so the - * resulting telemetry can be sliced by model. Expected to always - * be populated in practice; the parameter is typed `undefined`- - * tolerant so a missing model can't suppress the edit-survival - * sample, but `undefined` here is a bug and is logged as a warning - * (and surfaces as an empty `modelId` string in telemetry). + * `toolName` and `toolInput` are forwarded to {@link extractAiChunks} + * for region-based survival scoring; unknown shapes fall back to + * whole-file scoring. */ async takeCompletedEdit(turnId: string, toolCallId: string, filePath: string, toolName: string, toolInput: unknown, modelId: string | undefined): Promise { const edit = this._completedEdits.get(filePath); @@ -215,6 +199,7 @@ export class FileEditTracker { afterText, isCreate, modelId, + toolName, aiChunks: extractAiChunks(toolName, toolInput, filePath), }); diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index 240afb9e7e8bb..5f6c455d9de0b 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -941,39 +941,4 @@ suite('CopilotShellTools', () => { assert.strictEqual(terminalManager.sentTexts.length, 0, 'Disallowed command should not be sent to the terminal'); }); - test('primary shell tool skips confirmation when autoApproveUnsandboxedCommands is enabled', async function () { - const { instantiationService, terminalManager, agentConfigurationService } = createServices({ sandboxEnabled: true }); - agentConfigurationService.setSandboxValue(AgentHostSandboxKey.AllowUnsandboxedCommands, true); - agentConfigurationService.setSandboxValue(AgentHostSandboxKey.AutoApproveUnsandboxedCommands, true); - terminalManager.commandDetectionSupported = true; - const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); - const confirmationRequests: IUnsandboxedCommandConfirmationRequest[] = []; - const tools = await createShellTools(shellManager, terminalManager, new NullLogService(), async request => { - confirmationRequests.push(request); - return true; - }); - const bashTool = tools.find(tool => tool.name === 'bash'); - assert.ok(bashTool); - - const invocation: ToolInvocation = { - sessionId: 'session-1', - toolCallId: 'tool-1', - toolName: 'bash', - arguments: { command: 'curl https://example.com' }, - }; - const resultPromise = bashTool.handler!({ command: 'curl https://example.com' }, invocation); - await terminalManager.commandFinishedListenerRegistered.p; - terminalManager.fireCommandFinished({ - commandId: 'cmd-1', - exitCode: 0, - command: 'curl https://example.com', - output: '', - }); - const result = await resultPromise as ToolResultObject; - - assert.strictEqual(confirmationRequests.length, 0, 'No confirmation should have been requested when auto-approve is enabled'); - assert.ok(terminalManager.sentTexts.length >= 1, 'Auto-approved command should be sent to the terminal unsandboxed'); - assert.ok(terminalManager.sentTexts.every(entry => !entry.data.includes('sandbox-runtime')), 'Auto-approved command should run unsandboxed'); - assert.strictEqual(result.resultType, 'success'); - }); }); diff --git a/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts b/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts index a01bb5b784fc1..0bf26ea1be564 100644 --- a/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts @@ -34,15 +34,7 @@ suite('CopilotApiService.utilityChatCompletion (real CAPI)', () => { // `globalThis.fetch` through `this._fetch(...)` throws // "Illegal invocation" in the Electron renderer. const boundFetch: typeof globalThis.fetch = (...args) => globalThis.fetch(...args); - const service = new class extends CopilotApiService { - constructor() { - super(boundFetch, new NullLogService(), productService); - } - override getIntegrationId(): string | undefined { - return undefined; // Don't send an integration ID for tests - } - }(); - return service; + return new CopilotApiService(boundFetch, new NullLogService(), productService); } (hasToken ? test : test.skip)('answers a trivial arithmetic prompt', async function () { diff --git a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts index d10f76c7b6447..710ccf6b2a261 100644 --- a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts @@ -85,14 +85,7 @@ function modelsResponse(models: object[]): Response { } function createService(fetchImpl: FetchFunction): CopilotApiService { - return new class extends CopilotApiService { - constructor() { - super(fetchImpl, new NullLogService(), testProductService); - } - override getIntegrationId(): string | undefined { - return undefined; // Don't send an integration ID for tests - } - }(); + return new CopilotApiService(fetchImpl, new NullLogService(), testProductService); } type CapturedRequest = { url: string; init: RequestInit | undefined }; @@ -594,23 +587,18 @@ suite('CopilotApiService', () => { assert.strictEqual(headers['OpenAI-Intent'], 'messages-proxy'); }); - test('suppressIntegrationId opt-in controls the Copilot-Integration-Id header', async () => { + test('sends a derived Copilot-Integration-Id header by default', async () => { const { fetch: fetchFn, captured } = routingFetch( () => anthropicResponse([{ type: 'text', text: 'ok' }]), ); const service = createService(fetchFn); - // Default (no opt-in): @vscode/copilot-api derives and sends the header. + // @vscode/copilot-api derives the integration id from the license / + // SKU / build state and sends it on every request. await service.messages('gh-tok', baseRequest); - const withHeader = captured().init?.headers as Record; - - // Opt-in: the header is omitted entirely so CAPI authorizes against - // the token's real entitlement instead of the derived integration id. - await service.messages('gh-tok', baseRequest, { suppressIntegrationId: true }); - const suppressed = captured().init?.headers as Record; + const headers = captured().init?.headers as Record; - assert.ok(withHeader['Copilot-Integration-Id'], 'integration id should be present by default'); - assert.strictEqual(suppressed['Copilot-Integration-Id'], undefined, 'integration id should be suppressed when opted in'); + assert.ok(headers['Copilot-Integration-Id'], 'integration id should be present'); }); }); @@ -1597,21 +1585,16 @@ suite('CopilotApiService', () => { assert.strictEqual(capturedHeaders?.['Authorization'], 'Bearer gh-tok'); }); - test('suppressIntegrationId opt-in controls the Copilot-Integration-Id header', async () => { + test('sends a derived Copilot-Integration-Id header by default', async () => { const { fetch: fetchFn, captured } = routingFetch(() => modelsResponse([])); const service = createService(fetchFn); - // Default (no opt-in): @vscode/copilot-api derives and sends the header. + // @vscode/copilot-api derives the integration id from the license / + // SKU / build state and sends it on every request. await service.models('gh-tok'); - const withHeader = captured().init?.headers as Record; - - // Opt-in: the header is omitted entirely so CAPI authorizes against - // the token's real entitlement instead of the derived integration id. - await service.models('gh-tok', { suppressIntegrationId: true }); - const suppressed = captured().init?.headers as Record; + const headers = captured().init?.headers as Record; - assert.ok(withHeader['Copilot-Integration-Id'], 'integration id should be present by default'); - assert.strictEqual(suppressed['Copilot-Integration-Id'], undefined, 'integration id should be suppressed when opted in'); + assert.ok(headers['Copilot-Integration-Id'], 'integration id should be present'); }); }); diff --git a/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts b/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts index 4589c30b5ca51..de5543056c972 100644 --- a/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts @@ -55,6 +55,7 @@ suite('agentHost editSurvivalReporter', () => { afterText: 'after-text', isCreate: false, modelId: 'claude-sonnet-4.5', + toolName: 'Edit', aiChunks: ['after-text'], }); disposables.add(reporter); @@ -68,6 +69,7 @@ suite('agentHost editSurvivalReporter', () => { const data = first.data as Record; assert.strictEqual(data.provider, 'claude'); assert.strictEqual(data.modelId, 'claude-sonnet-4.5'); + assert.strictEqual(data.toolName, 'Edit'); assert.strictEqual(data.agentSessionId, 'session-1'); assert.strictEqual(data.turnId, 'turn-1'); assert.strictEqual(data.toolCallId, 'tc-1'); diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index 8b3814aedb0e9..e5f16fe242c08 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -37,6 +37,8 @@ export interface IDefaultAccountProvider { readonly managedSettingsFetchStatus: ManagedSettingsFetchStatus; /** Timestamp (ms) of the last managed-settings fetch, or `null` if never fetched. */ readonly managedSettingsFetchedAt: number | null; + /** The raw JSON response from the managed-settings endpoint, for diagnostics. */ + readonly managedSettingsRawResponse: unknown; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; /** @@ -66,6 +68,8 @@ export interface IDefaultAccountService { readonly managedSettingsFetchStatus: ManagedSettingsFetchStatus; /** Timestamp (ms) of the last managed-settings fetch, or `null` if never fetched. */ readonly managedSettingsFetchedAt: number | null; + /** The raw JSON response from the managed-settings endpoint, for diagnostics. */ + readonly managedSettingsRawResponse: unknown; getDefaultAccount(): Promise; getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; setDefaultAccountProvider(provider: IDefaultAccountProvider): void; diff --git a/src/vs/platform/sandbox/common/settings.ts b/src/vs/platform/sandbox/common/settings.ts index 0d36906a960d2..d48e472cec1fd 100644 --- a/src/vs/platform/sandbox/common/settings.ts +++ b/src/vs/platform/sandbox/common/settings.ts @@ -11,7 +11,6 @@ export const enum AgentSandboxSettingId { AgentSandboxWindowsEnabled = 'chat.agent.sandbox.enabledWindows', AgentSandboxAllowUnsandboxedCommands = 'chat.agent.sandbox.allowUnsandboxedCommands', AgentSandboxRetryWithAllowNetworkRequests = 'chat.agent.sandbox.retryWithAllowNetworkRequests', - AgentSandboxAutoApproveUnsandboxedCommands = 'chat.agent.sandbox.autoApproveUnsandboxedCommands', AgentSandboxAllowAutoApprove = 'chat.agent.sandbox.allowAutoApprove', AgentSandboxLinuxFileSystem = 'chat.agent.sandbox.fileSystem.linux', AgentSandboxMacFileSystem = 'chat.agent.sandbox.fileSystem.mac', diff --git a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts index e5d0bef1bbfea..eeed08009ac2f 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts @@ -166,11 +166,6 @@ export class TerminalSandboxEngine extends Disposable { return this._areRetryWithAllowNetworkRequestsAllowed(); } - isAutoApproveUnsandboxedCommands(): boolean { - return this._areUnsandboxedCommandsAllowed() - && this._getSettingValue(AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands) === true; - } - async getOS(): Promise { this._os = await this._host.getOS(); return this._os; diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 530789e03b13a..4fc7f42f5937e 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -53,10 +53,14 @@ const SIGN_OUT_ACTION_ID = 'workbench.action.agenticSignOut'; const SIGN_IN_ACTION_ID = 'workbench.action.agenticSignIn'; // Register the shared VS Code update title bar entry into the Agents titlebar layout. -registerUpdateTitleBarMenuPlacement(Menus.TitleBarRightLayout, { +// Placed as the first (leftmost) item of the leftmost right-cluster container so that, in +// the right-aligned title bar, the update button grows into the empty space on its left +// when it appears and every other control (session toggles, account widget) stays anchored +// and doesn't shift. +registerUpdateTitleBarMenuPlacement(Menus.TitleBarSessionMenu, { when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()), group: 'navigation', - order: 99, + order: -1, }); // Sign In (shown when signed out) diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts index f044fcc2dcfda..4756cf46299ec 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts @@ -239,15 +239,6 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid const itemActions: ICommentItemActions = { editAction: undefined!, convertAction: undefined, removeAction: undefined!, addReplyAction: undefined! }; - itemActions.editAction = this._eventStore.add(new Action( - 'agentFeedback.widget.edit', - nls.localize('editComment', "Edit"), - ThemeIcon.asClassName(Codicon.edit), - true, - (): void => { this._startEditing(comment, text, itemActions); }, - )); - actionBar.push(itemActions.editAction, { icon: true, label: false }); - itemActions.addReplyAction = this._eventStore.add(new Action( 'agentFeedback.widget.addReply', nls.localize('addToComment', "Add to Comment"), @@ -257,10 +248,19 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid )); actionBar.push(itemActions.addReplyAction, { icon: true, label: false }); + itemActions.editAction = this._eventStore.add(new Action( + 'agentFeedback.widget.edit', + nls.localize('editComment', "Edit"), + ThemeIcon.asClassName(Codicon.edit), + true, + (): void => { this._startEditing(comment, text, itemActions); }, + )); + actionBar.push(itemActions.editAction, { icon: true, label: false }); + if (comment.canConvertToAgentFeedback) { itemActions.convertAction = this._eventStore.add(new Action( 'agentFeedback.widget.convert', - nls.localize('convertComment', "Convert to Agent Feedback"), + nls.localize('convertComment', "Accept"), ThemeIcon.asClassName(Codicon.check), true, () => this._convertToAgentFeedback(comment), diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 64aa5294ab9db..c879c189bd101 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -10,7 +10,7 @@ import { Schemas } from '../../../../base/common/network.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; import { IObjectTreeElement, ITreeSorter } from '../../../../base/browser/ui/tree/tree.js'; -import { ActionRunner, IAction, SubmenuAction, toAction } from '../../../../base/common/actions.js'; +import { ActionRunner, IAction, Separator, SubmenuAction, toAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; @@ -59,7 +59,7 @@ import { getChangesEditorLabels } from './changesEditorLabels.js'; import { getChangesMultiDiffSourceUri } from './changesMultiDiffSourceResolver.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { CIStatusWidget } from './checksWidget.js'; -import { GITHUB_REMOTE_FILE_SCHEME, SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus } from '../../../services/sessions/common/session.js'; +import { GITHUB_REMOTE_FILE_SCHEME, ISessionChangesetOperation, SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus } from '../../../services/sessions/common/session.js'; import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; import { Orientation } from '../../../../base/browser/ui/sash/sash.js'; import { IView, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js'; @@ -251,44 +251,86 @@ class ChangesWorkbenchButtonBarWidget extends Disposable { return getActionBarActions(menu.getActions({ shouldForwardArgs: true })); }); - const operationActionsObs = derived(reader => { + const operationActionGroupsObs = derived(reader => { const changeset = viewModel.activeSessionChangesetObs.read(reader); if (!changeset) { return []; } const operations = viewModel.activeSessionChangesetOperationsObs.read(reader); - return operations.filter(op => op.scopes.includes(SessionChangesetOperationScope.Changeset)) - .map(op => toAction({ - id: op.id, - label: op.icon - ? op.status === SessionChangesetOperationStatus.Running - ? `$(loading) ${op.label}` - : `$(${op.icon.id}) ${op.label}` - : op.status === SessionChangesetOperationStatus.Running - ? `$(loading) ${op.label}` - : op.label, - tooltip: op.description, - enabled: op.status !== SessionChangesetOperationStatus.Disabled && op.status !== SessionChangesetOperationStatus.Running, - run: () => changeset.invokeOperation(op.id), - })); + const changesetOperations = operations + .filter(op => op.scopes.includes(SessionChangesetOperationScope.Changeset)); + + const toOperationAction = (op: ISessionChangesetOperation) => toAction({ + id: op.id, + label: op.icon + ? op.status === SessionChangesetOperationStatus.Running + ? `$(loading) ${op.label}` + : `$(${op.icon.id}) ${op.label}` + : op.status === SessionChangesetOperationStatus.Running + ? `$(loading) ${op.label}` + : op.label, + tooltip: op.description, + enabled: op.status !== SessionChangesetOperationStatus.Disabled && op.status !== SessionChangesetOperationStatus.Running, + run: () => changeset.invokeOperation(op.id), + }); + + // Group the remaining changeset-scoped operations by their + // group identifier, preserving the order in which groups + // are first encountered. + const groups = new Map(); + for (const op of changesetOperations) { + // Skip the running operations as they will be handled separately + if (op.status === SessionChangesetOperationStatus.Running) { + continue; + } + + const action = toOperationAction(op); + const groupActions = groups.get(op.group); + if (groupActions) { + groupActions.push(action); + } else { + groups.set(op.group, [action]); + } + } + + // Running operations are extracted into a dedicated group that appears first + // so that the running operation acts as the primary action of the dropdown. + const runningActions = changesetOperations + .filter(op => op.status === SessionChangesetOperationStatus.Running) + .map(toOperationAction); + + return [ + ...(runningActions.length > 0 + ? [runningActions] + : []), + ...groups.values(), + ]; }); this._register(autorun(reader => { - const operationActions = operationActionsObs.read(reader); + const operationActionGroups = operationActionGroupsObs.read(reader); const menuActions = menuActionsObs.read(reader); const primaryActions: IAction[] = []; + const operationActions = operationActionGroups.flat(); if (operationActions.length > 1) { - // Dropdown action - const actions = operationActions.slice(); - const runningActionIndex = actions.findIndex(action => - action.label.startsWith('$(loading)') && !action.enabled); - const primaryActionIndex = runningActionIndex !== -1 ? runningActionIndex : 0; - const primaryAction = actions.splice(primaryActionIndex, 1)[0]; - - primaryActions.push(new SubmenuAction('changesView.operations.primary.dropdown', primaryAction.label, [primaryAction, ...actions])); + // The action groups are build so that the + // running action(s) appear in the first group + const primaryAction = operationActions[0]; + + // Join the groups with separators to + // visually separate related operations. + const dropdownActions: IAction[] = []; + for (const group of operationActionGroups) { + if (dropdownActions.length > 0) { + dropdownActions.push(new Separator()); + } + dropdownActions.push(...group); + } + + primaryActions.push(new SubmenuAction('changesView.operations.primary.dropdown', primaryAction.label, dropdownActions)); } else { primaryActions.push(...operationActions); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts index bdcf9fb74540e..bd9d0073ae692 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts @@ -28,6 +28,7 @@ export interface IAgentHostSessionEnumPickerItem { readonly value: string; readonly label: string; readonly description?: string; + readonly checked?: boolean; } function getModeIcon(value: string | undefined): ThemeIcon | undefined { @@ -235,7 +236,7 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { label: item.label, detail: item.description, group: { title: '', icon: this._getActionItemIcon(item, ctx.currentValue) }, - item, + item: { ...item, checked: item.value === ctx.currentValue }, })); actionItems.push(...this._getFooterActionItems()); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index e4949b88d9637..35132c6bc365b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -124,8 +124,7 @@ function toSessionChangesetOperation(operation: ChangesetOperation): ISessionCha icon: operation.icon ? ThemeIcon.fromId(operation.icon) : undefined, - scopes: operation.scopes.map(toSessionChangesetOperationScope), - status: toSessionChangesetOperationStatus(operation.status), + group: operation.group, confirmation: operation.confirmation ? typeof operation.confirmation === 'string' ? operation.confirmation @@ -133,6 +132,8 @@ function toSessionChangesetOperation(operation: ChangesetOperation): ISessionCha isTrusted: false, supportThemeIcons: true }) : undefined, + scopes: operation.scopes.map(toSessionChangesetOperationScope), + status: toSessionChangesetOperationStatus(operation.status), }; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index df0662047cff2..d3c0e0f73ae5e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -77,6 +77,7 @@ export interface IConfigPickerItem { readonly value: string; readonly label: string; readonly description?: string; + readonly checked?: boolean; } export function getConfigIcon(property: string, value: unknown | undefined): ThemeIcon | undefined { @@ -110,7 +111,7 @@ function toActionItems(property: string, items: readonly IConfigPickerItem[], cu detail: item.description, group: { title: '', icon: getConfigIcon(property, item.value) }, disabled: policyRestricted && (item.value === 'autoApprove' || item.value === 'autopilot'), - item: { ...item, label: isSelectedValue(currentValue, item.value) ? `${item.label} ${localize('selected', "(Selected)")}` : item.label }, + item: { ...item, checked: isSelectedValue(currentValue, item.value) }, })); } @@ -455,8 +456,8 @@ export class AgentHostSessionConfigPicker extends Disposable { } const isAutoApproveProperty = property === SessionConfigKey.AutoApprove; - const currentValue = provider.getSessionConfig(sessionId)?.values[property]; - const currentItem = items.find(i => i.value === currentValue); + const currentValue = provider.getSessionConfig(sessionId)?.values[property] ?? schema.default; + const currentItem = items.find(i => isSelectedValue(currentValue, i.value)); const actionItems = toActionItems(property, items, currentValue, policyRestricted); const delegate: IActionListDelegate = { @@ -487,7 +488,7 @@ export class AgentHostSessionConfigPicker extends Disposable { ? query => this._filterDelayer.trigger(async () => { const filteredRawItems = await this._getItems(provider, sessionId, property, schema, query); const { items: filteredItems, policyRestricted: filteredPolicyRestricted } = applyAutoApproveFiltering(filteredRawItems, property, this._configurationService); - return toActionItems(property, filteredItems, provider.getSessionConfig(sessionId)?.values[property], filteredPolicyRestricted); + return toActionItems(property, filteredItems, provider.getSessionConfig(sessionId)?.values[property] ?? schema.default, filteredPolicyRestricted); }) : undefined, onHide: () => trigger.focus(), diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 12f4b96e14ccb..d4e3e34e1145b 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -223,6 +223,8 @@ export interface ISessionChangesetOperation { readonly description?: string; /** Optional icon for the operation. */ readonly icon?: ThemeIcon; + /** Optional group identifier, used to group related operations together. */ + readonly group?: string; /** The scopes to which this operation applies. */ readonly scopes: SessionChangesetOperationScope[]; /** Current execution status for this operation. */ diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index 687dd85ca28ec..97faedfadc819 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -133,6 +133,7 @@ class MockDefaultAccountService implements IDefaultAccountService { readonly onDidChangeCopilotTokenInfo = Event.None; readonly managedSettingsFetchStatus: null = null; readonly managedSettingsFetchedAt: null = null; + readonly managedSettingsRawResponse: unknown = null; async getDefaultAccount(): Promise { return MOCK_ACCOUNT; } getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { return MOCK_ACCOUNT.authenticationProvider; } diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index 0fa9f2a5e6e88..ee85640cac73e 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -793,6 +793,15 @@ class PolicyDiagnosticsAction extends Action2 { content += `| Fetched at | ${fetchedAt ? new Date(fetchedAt).toLocaleString() : '*n/a*'} |\n`; content += '\n'; + const rawResponse = defaultAccountService.managedSettingsRawResponse; + if (rawResponse !== null && rawResponse !== undefined) { + content += '### Raw Response\n\n'; + content += '```json\n'; + content += JSON.stringify(rawResponse, null, 2); + content += '\n```\n\n'; + } + + content += '### Processed (after projection)\n\n'; const managedSettingsData = { managedSettings: policyData?.managedSettings, }; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index fa5898fd97d50..a3d4b57adcdc7 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -616,14 +616,7 @@ export class OpenDelegationPickerAction extends Action2 { ChatContextKeys.inQuickChat.negate(), ChatContextKeys.chatSessionSupportsDelegation, ChatContextKeys.chatSessionIsEmpty.negate(), - // In the agents window, hide the delegation chip while a - // request (or the input) is being edited. The editor window - // keeps showing it during edits. - ContextKeyExpr.or( - IsSessionsWindowContext.negate(), - ContextKeyExpr.and( - ChatContextKeys.currentlyEditing.negate(), - ChatContextKeys.currentlyEditingInput.negate())) + IsSessionsWindowContext.negate() ), group: 'navigation', }, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index 777c883935993..90386345d0979 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -17,7 +17,7 @@ import { IViewsService } from '../../../../services/views/common/viewsService.js import { ChatContextKeyExprs, ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatEditingSession } from '../../common/editing/chatEditingService.js'; import { IChatService } from '../../common/chatService/chatService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; +import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { ChatViewId, IChatWidgetService } from '../chat.js'; import { EditingSessionAction, EditingSessionActionContext, getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; import { ACTION_ID_NEW_CHAT, ACTION_ID_NEW_EDIT_SESSION, CHAT_CATEGORY, clearChatSessionPreservingType, handleCurrentEditingSession } from './chatActions.js'; @@ -346,7 +346,7 @@ async function runNewChatAction( if (typeof executeCommandContext.agentMode === 'boolean') { widget.input.setChatMode(executeCommandContext.agentMode ? ChatModeKind.Agent : ChatModeKind.Edit); - } else if (widget.input.currentModeKind === ChatModeKind.Edit && configurationService.getValue(ChatConfiguration.EditModeHidden)) { + } else if (widget.input.currentModeKind === ChatModeKind.Edit) { widget.input.setChatMode(ChatModeKind.Agent); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 266a53ea24947..99dd728444d8f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -47,6 +47,7 @@ interface IConfigPickerItem { readonly value: string; readonly label: string; readonly description?: string; + readonly checked?: boolean; } function getConfigIcon(property: string, value: unknown | undefined): ThemeIcon | undefined { @@ -96,7 +97,7 @@ function toActionItems(property: string, items: readonly IConfigPickerItem[], cu detail: item.description, group: { title: '', icon: getConfigIcon(property, item.value) }, disabled: policyRestricted && property === SessionConfigKey.AutoApprove && (item.value === 'autoApprove' || item.value === 'autopilot'), - item: { ...item, label: isSelectedValue(currentValue, item.value) ? `${item.label} ${localize('selected', "(Selected)")}` : item.label }, + item: { ...item, checked: isSelectedValue(currentValue, item.value) }, })); } 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 582edc23673dc..692fab3914a44 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -29,7 +29,7 @@ import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as Ahp import { ConfirmationOptionKind, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ChatAction, type ClientChatAction, type ClientSessionAction, type ChatInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildSubagentSessionUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, buildChatUri, buildDefaultChatUri, parseChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn, type UsageInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentSessionUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, buildChatUri, buildDefaultChatUri, parseChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -49,7 +49,7 @@ import { import { coerceImageBuffer } from '../../../common/chatImageExtraction.js'; import { ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, formatCopilotCredits, type IChatMultiSelectAnswer, type IChatQuestionAnswerValue, type IChatResponseErrorDetails, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; -import { IChatEntitlementService, isProUser, type IQuotaSnapshot } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; @@ -139,24 +139,6 @@ function userOriginMessage(text: string, attachments: readonly MessageAttachment : { text, origin: { kind: MessageKind.User } }; } -/** - * Shape of a single quota snapshot forwarded by the agent host under - * `UsageInfo._meta.quotaSnapshots` — the SDK's `account.getQuota` result - * (`AccountQuotaSnapshot`), keyed by quota type (`chat` / `completions` / - * `premium_interactions`). All fields are optional because they originate from - * a dynamically-read, server-controlled payload. - */ -interface IRawQuotaSnapshot { - readonly isUnlimitedEntitlement?: boolean; - readonly entitlementRequests?: number; - readonly usedRequests?: number; - readonly remainingPercentage?: number; - readonly overage?: number; - readonly overageAllowedWithExhaustedQuota?: boolean; - readonly usageAllowedWithExhaustedQuota?: boolean; - readonly hasQuota?: boolean; - readonly resetDate?: string; -} /** * Map a local {@link ConfirmedReason} (how the {@link ChatToolInvocation} * resolved its confirmation gate) to the protocol's @@ -553,56 +535,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }; } - /** - * Pushes the per-response quota snapshots forwarded by the agent host - * (under `UsageInfo._meta.quotaSnapshots`) into `IChatEntitlementService`, - * mirroring the Copilot Chat extension's `chat.updateQuotas(...)` after each - * response. Without this the chat input quota notification never renders in - * the agent host because the entitlement service is only refreshed on a - * slow background poll, never per-response. - */ - private _applyQuotaSnapshotsFromUsage(usage: UsageInfo | undefined): void { - const snapshots = usage?._meta?.quotaSnapshots; - if (!snapshots || typeof snapshots !== 'object') { - return; - } - - const record = snapshots as Record; - const entitlement = this._chatEntitlementService.entitlement; - // Paid plans draw down premium interactions; everyone else (signed out, - // unresolved, Free-eligible, or signed-up Free) is tracked under `chat`. - const isFree = !isProUser(entitlement); - const raw = isFree ? record['chat'] : (record['premium_interactions'] ?? record['premium_models']); - if (!raw) { - return; - } - - const entitlementCount = raw.entitlementRequests; - const unlimited = raw.isUnlimitedEntitlement ?? entitlementCount === -1; - const percentRemaining = Math.max(0, Math.min(100, raw.remainingPercentage ?? 0)); - const snapshot: IQuotaSnapshot = { - percentRemaining, - unlimited, - // Prefer the server's authoritative `hasQuota` (an "unlimited" plan can - // still be blocked, e.g. exhausted Business/Enterprise); fall back to a - // derivation only when it is absent. - hasQuota: raw.hasQuota ?? (unlimited || percentRemaining > 0), - entitlement: typeof entitlementCount === 'number' && Number.isFinite(entitlementCount) ? entitlementCount : undefined, - }; - - const existing = this._chatEntitlementService.quotas; - const quotas = { - ...existing, - resetDate: raw.resetDate ?? existing.resetDate, - chat: isFree ? snapshot : existing.chat, - premiumChat: isFree ? existing.premiumChat : snapshot, - additionalUsageEnabled: raw.overageAllowedWithExhaustedQuota ?? existing.additionalUsageEnabled, - additionalUsageCount: raw.overage ?? existing.additionalUsageCount, - }; - - this._chatEntitlementService.acceptQuotas(quotas); - } - async provideChatInputCompletions(sessionResource: URI, params: IChatInputCompletionsParams, token: CancellationToken): Promise { const backendSession = this._resolveSessionUri(sessionResource); // Note: we don't forward `token` across IPC \u2014 cancellation tokens @@ -1606,13 +1538,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (opts.subAgentInvocationId === undefined) { let lastUsage: ReturnType; store.add(autorun(reader => { - const rawUsage = usage$.read(reader); - // Update the core quota state (and thus the chat input quota - // notification) from the per-response quota snapshots forwarded - // by the agent host. Mirrors the Copilot Chat extension, which - // calls `chat.updateQuotas(...)` after each response. - this._applyQuotaSnapshotsFromUsage(rawUsage); - const usage = usageInfoToChatUsage(rawUsage); + const usage = usageInfoToChatUsage(usage$.read(reader)); if (!usage) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 9fed5910529b9..7e70a8ffaaee3 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -415,6 +415,25 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati } } +export class EmptyItemProviderItemSource extends Disposable implements IAICustomizationItemSource { + + readonly onDidAICustomizationItemsChange = Event.None; + + constructor( + readonly sessionResource: URI, + ) { + super(); + } + + fetchAICustomizationItems(promptType: PromptsType): Promise { + return Promise.resolve([]); + } + + fetchProviderItems(): Promise { + return Promise.resolve([]); + } +} + export class PureItemProviderItemSource extends Disposable implements IAICustomizationItemSource { readonly onDidAICustomizationItemsChange: Event; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts index a7f6928a460bf..0069af2131672 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts @@ -20,11 +20,13 @@ import { ICustomizationHarnessService, isPluginCustomizationItem } from '../../c import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; -import { AICustomizationItemNormalizer, IAICustomizationItemSource, IAICustomizationListItem, ItemProviderItemSource, PureItemProviderItemSource } from './aiCustomizationItemSource.js'; +import { AICustomizationItemNormalizer, EmptyItemProviderItemSource, IAICustomizationItemSource, IAICustomizationListItem, ItemProviderItemSource, PureItemProviderItemSource } from './aiCustomizationItemSource.js'; import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; import { URI } from '../../../../../base/common/uri.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { isAgentHostTarget } from '../agentSessions/agentSessions.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; + /** * The set of sections whose items are sourced from the customization @@ -152,6 +154,7 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz @IProductService productService: IProductService, @IFileService private readonly fileService: IFileService, @IPathService private readonly pathService: IPathService, + @ILogService private readonly logService: ILogService, ) { super(); @@ -233,16 +236,21 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz } private getOrCreateSource(sessionResource: URI): IAICustomizationItemSource { - if (this.sourceCache.value && isEqual(sessionResource, this.sourceCache.value.sessionResource)) { - return this.sourceCache.value; + const cached = this.sourceCache.value; + if (cached && isEqual(sessionResource, cached.sessionResource) && !(cached instanceof EmptyItemProviderItemSource)) { + return cached; } const sessionType = getChatSessionType(sessionResource); const descriptor = this.harnessService.findHarnessById(sessionType); const getItemSource = () => { if (isAgentHostTarget(sessionType)) { - if (!descriptor?.itemProvider) { - throw new Error(`Agent host targets must have an item provider`); + if (!descriptor) { + this.logService.warn(`Agent-host session type ${sessionType} has no harness descriptor`); + return new EmptyItemProviderItemSource(sessionResource); + } else if (!descriptor.itemProvider) { + this.logService.warn(`Agent-host session type ${sessionType} has no item provider`); + return new EmptyItemProviderItemSource(sessionResource); } return new PureItemProviderItemSource(sessionResource, descriptor.itemProvider, this.itemNormalizer); } else { 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 6bec93af4d8d9..f92ccac1cbc63 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -1247,26 +1247,6 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.requestQueuing.defaultAction.description', "Controls which action is the default for the queue button when a request is in progress."), default: 'steer', }, - [ChatConfiguration.EditModeHidden]: { - type: 'boolean', - description: nls.localize('chat.editMode.hidden', "When enabled, hides the Edit mode from the chat mode picker."), - default: true, - tags: ['experimental'], - experiment: { - mode: 'auto' - }, - policy: { - name: 'DeprecatedEditModeHidden', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.112', - localization: { - description: { - key: 'chat.editMode.hidden', - value: nls.localize('chat.editMode.hidden', "When enabled, hides the Edit mode from the chat mode picker."), - } - } - } - }, [ChatConfiguration.EnableMath]: { type: 'boolean', description: nls.localize('chat.mathEnabled.description', "Enable math rendering in chat responses using KaTeX."), diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentFeedbackReviewConfirmation.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentFeedbackReviewConfirmation.css index b978ea20c241c..5487b72f7f0ff 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentFeedbackReviewConfirmation.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAgentFeedbackReviewConfirmation.css @@ -60,11 +60,66 @@ font-size: var(--vscode-bodyFontSize-small); } +.chat-agent-feedback-review-text-container { + position: relative; +} + .chat-agent-feedback-review-text { white-space: pre-wrap; word-break: break-word; } +.chat-agent-feedback-review-text-container.collapsed .chat-agent-feedback-review-text { + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Fade affordance shown only when the comment is collapsed and overflowing, making it clear there is more text below the two visible lines. */ +.chat-agent-feedback-review-text-container.collapsed.overflowing::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1.4em; + pointer-events: none; + background: linear-gradient(to bottom, transparent, var(--vscode-chat-requestBackground)); +} + +.chat-agent-feedback-review-expand-toggle { + display: none; + position: absolute; + bottom: 0; + right: 0; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: var(--vscode-cornerRadius-xSmall); + background: transparent; + color: var(--vscode-icon-foreground); + cursor: pointer; + z-index: 1; +} + +.chat-agent-feedback-review-text-container.overflowing .chat-agent-feedback-review-expand-toggle { + display: flex; +} + +.chat-agent-feedback-review-expand-toggle:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.chat-agent-feedback-review-expand-toggle:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + .chat-agent-feedback-review-actions { flex: 0 0 auto; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatAgentFeedbackReviewConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatAgentFeedbackReviewConfirmationSubPart.ts index ac3a68b6b2761..db7c4fcbe588f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatAgentFeedbackReviewConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatAgentFeedbackReviewConfirmationSubPart.ts @@ -5,6 +5,7 @@ import * as dom from '../../../../../../../base/browser/dom.js'; import { ActionBar } from '../../../../../../../base/browser/ui/actionbar/actionbar.js'; +import { getDefaultHoverDelegate } from '../../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { Checkbox } from '../../../../../../../base/browser/ui/toggle/toggle.js'; import { Action } from '../../../../../../../base/common/actions.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; @@ -16,6 +17,7 @@ import { localize } from '../../../../../../../nls.js'; import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.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 { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js'; import { ILogService } from '../../../../../../../platform/log/common/log.js'; @@ -65,6 +67,7 @@ export class ChatAgentFeedbackReviewConfirmationSubPart extends AbstractToolConf @IChatToolRiskAssessmentService riskAssessmentService: IChatToolRiskAssessmentService, @ICommandService private readonly commandService: ICommandService, @ILogService private readonly logService: ILogService, + @IHoverService private readonly hoverService: IHoverService, ) { super(toolInvocation, context, instantiationService, keybindingService, contextKeyService, chatWidgetService, languageModelToolsService, riskAssessmentService); @@ -164,15 +167,14 @@ export class ChatAgentFeedbackReviewConfirmationSubPart extends AbstractToolConf dom.append(header, dom.$('.chat-agent-feedback-review-kind', undefined, comment.kindLabel)); } const fileUri = URI.revive(comment.fileUri); - const fileLabel = rowStore.add(this._resourceLabels.create(header, { supportIcons: true })); + const fileLabel = rowStore.add(this._resourceLabels.create(header)); fileLabel.element.classList.add('chat-agent-feedback-review-file'); fileLabel.setResource( { resource: fileUri, name: basename(fileUri) }, { fileKind: FileKind.FILE, title: fileUri.fsPath || fileUri.path }, ); - const textElement = dom.append(main, dom.$('.chat-agent-feedback-review-text')); - textElement.textContent = comment.text; + this._renderCommentText(rowStore, main, comment.text); const actionsContainer = dom.append(rowElement, dom.$('.chat-agent-feedback-review-actions')); const actionBar = rowStore.add(new ActionBar(actionsContainer)); @@ -194,6 +196,85 @@ export class ChatAgentFeedbackReviewConfirmationSubPart extends AbstractToolConf this._rows.set(comment.id, { comment, checkbox, element: rowElement }); } + /** + * Renders the comment body clamped to two visual lines by default, with an + * expand/collapse toggle in the bottom-right corner. The toggle and the + * fade/ellipsis affordance only appear when the text actually overflows two + * lines; overflow is re-evaluated whenever the available width changes. + */ + private _renderCommentText(rowStore: DisposableStore, main: HTMLElement, text: string): void { + const container = dom.append(main, dom.$('.chat-agent-feedback-review-text-container')); + const textElement = dom.append(container, dom.$('.chat-agent-feedback-review-text')); + textElement.textContent = text; + + const toggle = dom.append(container, dom.$('button.chat-agent-feedback-review-expand-toggle')); + toggle.type = 'button'; + toggle.tabIndex = 0; + const toggleIcon = dom.append(toggle, dom.$('span.codicon')); + toggleIcon.setAttribute('aria-hidden', 'true'); + + const expandLabel = localize('agentFeedback.expandComment', "Show More"); + const collapseLabel = localize('agentFeedback.collapseComment', "Show Less"); + + let expanded = false; + + const renderState = () => { + container.classList.toggle('collapsed', !expanded); + container.classList.toggle('expanded', expanded); + toggleIcon.classList.toggle('codicon-chevron-down', !expanded); + toggleIcon.classList.toggle('codicon-chevron-up', expanded); + toggle.setAttribute('aria-label', expanded ? collapseLabel : expandLabel); + toggle.setAttribute('aria-expanded', String(expanded)); + }; + + const isOverflowing = (): boolean => { + // `scrollHeight` reflects the full content height even while clamped, + // so compare it against the (clamped) `clientHeight`. Measure in the + // collapsed state, restoring the previous state in the same frame so + // no intermediate layout is painted. + const wasExpanded = expanded; + if (wasExpanded) { + container.classList.add('collapsed'); + container.classList.remove('expanded'); + } + const overflowing = textElement.scrollHeight - textElement.clientHeight > 1; + if (wasExpanded) { + container.classList.remove('collapsed'); + container.classList.add('expanded'); + } + return overflowing; + }; + + const updateOverflow = () => { + const overflowing = isOverflowing(); + container.classList.toggle('overflowing', overflowing); + if (!overflowing && expanded) { + expanded = false; + renderState(); + } + }; + + rowStore.add(this.hoverService.setupManagedHover( + getDefaultHoverDelegate('element'), + toggle, + () => expanded ? collapseLabel : expandLabel, + )); + + rowStore.add(dom.addDisposableListener(toggle, dom.EventType.CLICK, e => { + e.preventDefault(); + e.stopPropagation(); + expanded = !expanded; + renderState(); + })); + + renderState(); + + const targetWindow = dom.getWindow(container); + const observer = new targetWindow.ResizeObserver(() => updateOverflow()); + observer.observe(textElement); + rowStore.add(toDisposable(() => observer.disconnect())); + } + private async _reveal(commentId: string): Promise { try { await this.commandService.executeCommand(AgentFeedbackReviewCommandId.Reveal, this._sessionResource, commentId); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index d979559db27f5..d67f805570480 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -51,7 +51,6 @@ export interface IModePickerDelegate { const builtinDefaultIcon = (mode: IChatMode) => { switch (mode.name.get().toLowerCase()) { case 'ask': return Codicon.ask; - case 'edit': return Codicon.edit; case 'plan': return Codicon.tasklist; default: return undefined; } @@ -325,13 +324,9 @@ function isModeConsideredBuiltIn(mode: IChatMode, productService: IProductServic } function shouldShowBuiltInMode(mode: IChatMode, assignments: { showOldAskMode: boolean }, agentModeDisabledViaPolicy: boolean): boolean { - // The built-in "Edit" mode is deprecated, but still supported for older conversations and agent disablement. - if (mode.id === ChatMode.Edit.id || mode.name.get().toLowerCase() === 'edit') { - if (mode.id === ChatMode.Edit.id) { - return agentModeDisabledViaPolicy; - } else { - return !agentModeDisabledViaPolicy; - } + // The built-in "Edit" mode is deprecated, but still shown when agent mode is disabled via policy. + if (mode.id === ChatMode.Edit.id) { + return agentModeDisabledViaPolicy; } // The "Ask" mode is a special case - we want to show either the old or new version based on the assignment or agent disablement, but not both 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 18111944aa6f1..d31e014e73693 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -596,7 +596,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const newSessionButtonContainer = this.sessionsNewButtonContainer = append(sessionsContainer, $('.agent-sessions-new-button-container')); const newSessionButton = this._register(new Button(newSessionButtonContainer, { ...defaultButtonStyles, secondary: true })); newSessionButton.label = localize('newSession', "New Session"); - this._register(newSessionButton.onDidClick(() => this.commandService.executeCommand(ACTION_ID_NEW_CHAT))); + this._register(newSessionButton.onDidClick(() => this.commandService.executeCommand(ACTION_ID_NEW_CHAT, this.getActionsContext()))); // Sessions Control this.sessionsControlContainer = append(sessionsContainer, $('.agent-sessions-control-container')); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 30f54a353508f..6f4d9c515bcbb 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -32,7 +32,6 @@ export enum ChatConfiguration { EditorAssociations = 'chat.editorAssociations', UnifiedAgentsBar = 'chat.unifiedAgentsBar.enabled', AgentSessionProjectionEnabled = 'chat.agentSessionProjection.enabled', - EditModeHidden = 'chat.editMode.hidden', ExtensionToolsEnabled = 'chat.extensionTools.enabled', RepoInfoEnabled = 'chat.repoInfo.enabled', EditRequests = 'chat.editRequests', diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index 5d1c11fac9048..758c8353e4938 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -207,7 +207,6 @@ export const tocData: ITOCEntry = { 'chat.alternativeToolAction.*', 'chat.codeBlock.*', 'chat.editing.explainChanges.enabled', - 'chat.editMode.hidden', 'chat.editorAssociations', 'chat.extensionUnification.*', 'chat.inlineReferences.*', diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css b/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css index 65486623c304f..3ff1a9e2b385d 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css @@ -30,3 +30,56 @@ border-radius: var(--vscode-cornerRadius-medium); background-color: var(--vscode-activityBar-activeBackground, var(--vscode-list-inactiveSelectionBackground)); } + +/* + * Some color themes pick the activity bar foreground to contrast a distinctly + * colored activity bar background. When that background is neutralized to match + * the window, the theme's foreground can become invisible. Force the item + * foreground (set inline by the part) to the general editor foreground so icons + * stay legible across all themes, keeping inactive items dimmed for hierarchy. + * + * Codicon items are tinted via `color`. Image (iconUrl) items render as a + * tinted SVG mask, so the part colors them via `background-color` instead — we + * mirror the same tint there, scoped to `:not(.codicon)` so codicon items never + * gain a filled background box. + */ +.style-override-activityBar .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .action-label.codicon { + color: var(--vscode-foreground) !important; +} + +.style-override-activityBar .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked) .action-label.codicon { + color: var(--vscode-descriptionForeground) !important; +} + +.style-override-activityBar .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .action-label:not(.codicon) { + background-color: var(--vscode-foreground) !important; +} + +.style-override-activityBar .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked) .action-label:not(.codicon) { + background-color: var(--vscode-descriptionForeground) !important; +} + +/* + * Give activity bar items a consistent hover background. By default the activity + * bar only recolors the icon on hover, so themes without a hover tint give no + * feedback. Mirror the active item's rounded box (identical geometry) using the + * item's own `::before`, painted behind the icon at z-index 0. + * + * The box lives on the action-item (not the label) so it also shows behind image + * (mask) icons, whose label paints the icon via a masked background and would + * clip any descendant box. It is skipped while dragging (the `::before` is reused + * for the drop-line indicators) and on the checked item (which already shows the + * active box). + */ +.style-override-activityBar .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 { + content: ""; + display: block; + position: absolute; + z-index: 0; + top: 4px; + left: calc((var(--activity-bar-width, 48px) - var(--activity-bar-action-height, 48px) + 8px) / 2); + width: calc(var(--activity-bar-action-height, 48px) - 8px); + height: calc(var(--activity-bar-action-height, 48px) - 8px); + border-radius: var(--vscode-cornerRadius-medium); + background-color: var(--vscode-list-hoverBackground); +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css b/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css new file mode 100644 index 0000000000000..9be0e34850e45 --- /dev/null +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/commandCenter.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +/* + * Style-override module: "Agents Window Command Center". + * + * Mirrors the Agents window title-bar styling (see + * src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css and + * src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css): + * the command center and agent-status input sit on a transparent background at + * rest and only reveal the command-center active background/border on hover, + * instead of carrying a solid fill the whole time. + * + * All rules are gated behind the `.style-override-commandCenter` class, which + * the StyleOverridesContribution toggles onto the workbench container(s) when + * the `workbench.experimental.styleOverrides` setting includes "commandCenter". + */ + +/* + * Command center box. Drop the solid `commandCenter-background` fill so it reads + * transparent at rest; the base stylesheet's `:hover` rule (titlebarpart.css) + * still reveals the active background/border on hover. + */ +.style-override-commandCenter.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center { + background-color: transparent !important; +} + +/* + * Agent status pill and its input area. Both default to the indicator + * background; clear it so they match the transparent command center. Their own + * `:hover` rules already reveal the active background, and the connected + * `session-mode` pill keeps its active fill (excluded below). + */ +.style-override-commandCenter .agent-status-pill:not(.session-mode), +.style-override-commandCenter .agent-status-pill .agent-status-input-area { + background-color: transparent !important; +} + +/* + * Connected badge segments (sparkle / chat toggle dropdown, status sections and + * the command-center toolbar) also default to the indicator background. Clear + * it so the whole pill reads transparent at rest; each segment's own `:hover` + * rule still reveals the active background, and the `filtered` state keeps its + * own active fill (excluded below). + */ +.style-override-commandCenter .agent-status-badge-section:not(.filtered), +.style-override-commandCenter .agent-status-command-center-toolbar { + background-color: transparent !important; +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css b/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css index a4d8e7c04c021..fb6f0fa9dc03b 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css @@ -114,9 +114,15 @@ .style-override-fontRamp .monaco-pane-view .pane > .pane-header > .title, /* Part titles (sidebar, auxiliary bar, panel) — but never editor filenames. */ .style-override-fontRamp.monaco-workbench .part:not(.editor) > .title > .title-label h2, -/* Panel / composite bar action-item labels. */ -.style-override-fontRamp.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item, -.style-override-fontRamp.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item, +/* + * Panel / composite bar action-item labels. The font-size must land on the + * `.action-label` (the ``), not the `.action-item` (the `
  • `): the base + * `.monaco-action-bar .action-label` rule (actionbar.css) sets font-size + * directly on the label, and a direct value always beats one inherited from the + * parent item, so targeting the item alone leaves the visible text at 11px. + */ +.style-override-fontRamp.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.style-override-fontRamp.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, /* Notifications center header. */ .style-override-fontRamp.monaco-workbench > .notifications-center > .notifications-center-header > .notifications-center-header-title, /* Debug call-stack session state labels. */ diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css b/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css new file mode 100644 index 0000000000000..880dbd6fa2624 --- /dev/null +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Style-override module: "Keyboard-Only Focus Borders". + * + * Hides the focus borders that appear when a panel surface is focused via the + * mouse, while keeping them for keyboard navigation. This relies on the + * `:focus-visible` pseudo-class: a pointer (mouse) interaction matches `:focus` + * but not `:focus-visible`, whereas keyboard navigation matches both. Suppressing + * outlines on `:focus:not(:focus-visible)` therefore removes mouse-click focus + * rings and leaves keyboard focus rings intact. + * + * All rules are gated behind the `.style-override-keyboardFocusOnly` class, which + * the StyleOverridesContribution toggles onto the workbench container(s) when the + * `workbench.experimental.styleOverrides` setting includes "keyboardFocusOnly". + * + * Scoped to the panel surfaces (side bar, bottom panel, auxiliary bar, status + * bar) so editor and global widget focus rings are unaffected. + */ + +/* + * Monaco lists / trees draw the focus ring on the focused row while the list + * *container* is focused. When the list was focused via the mouse it matches + * `:focus` but not `:focus-visible`, so drop the row ring in that case. Keyboard + * focus (`:focus-visible`) keeps it. + */ +.style-override-keyboardFocusOnly .part.sidebar .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused, +.style-override-keyboardFocusOnly .part.panel .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused, +.style-override-keyboardFocusOnly .part.auxiliarybar .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused { + outline: 0 !important; +} + +/* + * Part-level and control-level focus outlines. Panels (the part element itself, + * which is `[tabindex="0"]`, plus inner tabindex elements, action items and + * inputs) draw an outline on `:focus`. Hide it for mouse focus, keep it for + * keyboard focus. The part element is listed explicitly because a descendant + * combinator would not match the part itself. + */ +.style-override-keyboardFocusOnly .part.sidebar:focus:not(:focus-visible), +.style-override-keyboardFocusOnly .part.sidebar :focus:not(:focus-visible), +.style-override-keyboardFocusOnly .part.panel:focus:not(:focus-visible), +.style-override-keyboardFocusOnly .part.panel :focus:not(:focus-visible), +.style-override-keyboardFocusOnly .part.auxiliarybar:focus:not(:focus-visible), +.style-override-keyboardFocusOnly .part.auxiliarybar :focus:not(:focus-visible), +.style-override-keyboardFocusOnly .part.statusbar:focus:not(:focus-visible), +.style-override-keyboardFocusOnly .part.statusbar :focus:not(:focus-visible) { + outline-color: transparent !important; +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css new file mode 100644 index 0000000000000..6207f026add76 --- /dev/null +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +/* + * Style-override module: "Padding". + * + * Adjusts the internal padding of common workbench surfaces to give content + * more breathing room. + * + * All rules are gated behind the `.style-override-padding` class, which the + * StyleOverridesContribution toggles onto the workbench container(s) when the + * `workbench.experimental.styleOverrides` setting includes "padding". + * Without that class these rules never apply. + */ + +/* + * Inset view pane content (sidebar / panel / auxiliary bar sections) by a + * horizontal amount matching the Agents window panel padding + * (8px / --vscode-spacing-size80) so content has breathing room from the edges. + * + * The pane *header* is padded, and each list/tree *row* box is inset so there is + * a real margin between the row (including its hover / selection highlight) and + * the pane edge. Rows are `position: absolute; width: 100%`, so insetting them + * with `left` / `right` (and `width: auto`) leaves a gap on both sides. The + * vertical scrollbar is a sibling of the rows inside `.monaco-scrollable-element` + * (not a child of the rows), so it stays pinned to the pane edge. + * + * The chat & agent-sessions UI is excluded: `ChatViewPane` adds the + * `chat-viewpane` class directly onto its `.pane-body`. + */ + +/* Header: inset the section title / actions to line up with the rows below. */ +.style-override-padding .monaco-pane-view .pane:not(:has(> .pane-body.chat-viewpane)) > .pane-header { + padding-left: var(--vscode-spacing-size80, 8px); + padding-right: var(--vscode-spacing-size80, 8px); +} + +/* Body: inset each row box, leaving the scrollbar pinned to the pane edge. */ +.style-override-padding .monaco-pane-view .pane-body:not(.chat-viewpane) .monaco-list-row { + left: var(--vscode-spacing-size80, 8px); + right: var(--vscode-spacing-size80, 8px); + width: auto; +} + +/* Tighten the part title bar gutters: flush-left, small right inset. */ +.style-override-padding.monaco-workbench .part > .title { + padding-left: var(--vscode-spacing-size40, 4px); + padding-right: var(--vscode-spacing-size80, 8px); +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css b/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css index cc7bc866c1f36..4f3e1a3b3c56d 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css @@ -52,9 +52,31 @@ display: none; } -/* Round the header corners. */ +.style-override-paneHeaders .monaco-pane-view .pane, +.style-override-paneHeaders.floating-panels .part.sidebar, +.style-override-paneHeaders.floating-panels .part.auxiliarybar { + background-color: var(--vscode-sideBar-background, var(--vscode-panel-background)) !important; +} + +/* Round the header corners and let the header match its surface at rest so it + * reads as part of the panel / side bar body rather than a tinted strip. The + * inline header background from the PaneView is overridden here; the hover tint + * below still wins via its higher specificity. */ .style-override-paneHeaders .monaco-pane-view .pane > .pane-header { border-radius: var(--vscode-cornerRadius-medium); + background-color: var(--vscode-sideBar-background, var(--vscode-panel-background)) !important; +} + +/* + * Add vertical breathing room to the headers. `--pane-header-size` drives both + * the CSS header height (paneview.css) and the size the split view reserves for + * the header during layout (Pane.resolveHeaderSize in paneview.ts), so the + * taller header is reserved correctly even when a section is collapsed. The + * StyleOverridesContribution triggers a relayout when this setting changes so + * the new reservation is picked up immediately. + */ +.style-override-paneHeaders .monaco-pane-view { + --pane-header-size: 28px; } /* Tint the header background on hover (overrides the inline header background). */ @@ -70,3 +92,12 @@ .style-override-paneHeaders .monaco-pane-view .pane > .pane-header:focus:not(:focus-visible) { outline: none !important; } + +/* + * The global focus rule uses `outline-offset: -1px`, which puts the ring's top + * stroke right on the 1px section separator so it looks clipped. Inset it a bit + * more so the keyboard focus ring clears the separator. + */ +.style-override-paneHeaders .monaco-pane-view .pane > .pane-header:focus { + outline-offset: -2px; +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css index c38d6eb3e66d8..37589163ed649 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css @@ -207,3 +207,15 @@ border-radius: var(--vscode-cornerRadius-large) !important; overflow: hidden; } + +/* ============================================================================= + * Sashes — round the hover/drag affordance ends (mirrors the Agents window). + * ========================================================================== */ +.style-override-roundedCorners .monaco-sash:before { + border-radius: calc(var(--vscode-sash-hover-size) / 2); +} + +.style-override-roundedCorners .monaco-sash:not(.disabled) > .orthogonal-drag-handle { + border-radius: var(--vscode-sash-size); +} + diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css new file mode 100644 index 0000000000000..27b9b8cdee82e --- /dev/null +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Style-override module: "Scroll Shadows". + * + * Fades the top and bottom edges of scrollable surfaces (lists / trees and the + * editor) into the colour of the surface they sit on, so content reads as + * scrolling underneath the surface rather than being clipped at a hard edge. + * + * The built-in scrollable element only paints a scroll-aware shadow on the top + * (and left) edge, and only when `useShadows` is enabled, with no bottom + * counterpart. To get a consistent top + bottom treatment everywhere, this + * module overlays two fixed pseudo-elements pinned to the viewport edges. Each + * one is a linear-gradient from the surface background colour to transparent, so + * the fade always matches whatever part it lives in (editor, panel, side bar, + * ...). The surface colour is supplied via the `--scroll-shadow-surface` + * variable, defaulted below and refined per part. The overlays are + * non-interactive and sit above the scrolling content. + * + * All rules are gated behind the `.style-override-scrollShadows` class, which + * the StyleOverridesContribution toggles onto the workbench container(s) when + * the `workbench.experimental.styleOverrides` setting includes "scrollShadows". + */ + +/* + * Surface colour the edges fade into. Default to the editor background, then + * refine per part so lists in the panel / side bar fade into their own surface. + */ +.style-override-scrollShadows { + --scroll-shadow-surface: var(--vscode-editor-background); +} + +.style-override-scrollShadows .part.panel { + --scroll-shadow-surface: var(--vscode-panel-background); +} + +.style-override-scrollShadows .part.sidebar { + --scroll-shadow-surface: var(--vscode-sideBar-background, var(--vscode-editor-background)); +} + +.style-override-scrollShadows .part.auxiliarybar { + --scroll-shadow-surface: var(--vscode-sideBar-background, var(--vscode-editor-background)); +} + +/* ============================================================================= + * Lists and trees. + * ========================================================================== */ +.style-override-scrollShadows .monaco-list { + position: relative; +} + +.style-override-scrollShadows .monaco-list > .monaco-scrollable-element::before, +.style-override-scrollShadows .monaco-list > .monaco-scrollable-element::after { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 12px; + pointer-events: none; + z-index: 10; +} + +.style-override-scrollShadows .monaco-list > .monaco-scrollable-element::after { + bottom: 0; + background: linear-gradient(to top, var(--scroll-shadow-surface), transparent); +} + +/* ============================================================================= + * Editor area. The main editor viewport scrolls inside `.editor-scrollable`; + * pin the fades to that scrollable element so they share the scrollbar's + * stacking context and sit below it. Scope to the editor / panel parts so the + * fade never leaks onto inline editors such as the chat input. + * ========================================================================== */ +.style-override-scrollShadows .part.editor .monaco-editor .editor-scrollable::before, +.style-override-scrollShadows .part.editor .monaco-editor .editor-scrollable::after { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 24px; + pointer-events: none; + /* Below the scrollbar (z-index 11) so it is never obscured, above content. */ + z-index: 10; +} + +.style-override-scrollShadows .part.editor .monaco-editor .editor-scrollable::before, +.style-override-scrollShadows .part.panel .monaco-editor .editor-scrollable::before { + top: 0; + background: linear-gradient(to bottom, var(--scroll-shadow-surface), transparent); +} + +.style-override-scrollShadows .part.editor .monaco-editor .editor-scrollable::after, +.style-override-scrollShadows .part.panel .monaco-editor .editor-scrollable::after { + bottom: 0; + background: linear-gradient(to top, var(--scroll-shadow-surface), transparent); +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/statusBar.css b/src/vs/workbench/contrib/styleOverrides/browser/media/statusBar.css new file mode 100644 index 0000000000000..8f4cd0ba7a7c4 --- /dev/null +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/statusBar.css @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Status Bar — keep the status bar foreground legible. + * + * Some color themes pick the status bar foreground to contrast a distinctly + * colored status bar background. When that background is neutralized to match + * the window, the theme's foreground can become invisible. Force the status bar + * text/icon color (set inline on the part by the workbench) to the general + * editor foreground so it stays readable across all themes. Items that set + * their own color (e.g. prominent, error or warning entries) keep theirs. + */ +.style-override-statusBar .part.statusbar { + color: var(--vscode-foreground) !important; +} + +/* + * Keep the hover foreground identical to the item's resting foreground. By + * default hovering a status bar item swaps the text color (to + * `statusBarItem-hoverForeground`, or the warning/error hover foreground for + * kind items), which now clashes with the neutralized foreground above. + * Forcing the hovered anchor to `inherit` makes it pick up its item's resting + * color — `--vscode-foreground` for standard items, or the kind color for + * warning/error items — so only the background changes on hover. + */ +.style-override-statusBar .part.statusbar > .items-container > .statusbar-item > a:hover:not(.disabled) { + color: inherit !important; +} + +/* + * Round the status bar item backgrounds to a control-sized pill. The interactive + * hover/active background lives on the label (`a.statusbar-item-label`); custom + * and kind (warning/error) backgrounds live on the item container. Round both so + * whichever surface paints a background reads as rounded. + * + * Compact combo controls — a left/right pair that visually merges into a single + * control — are flagged with `.compact-left` / `.compact-right` (an item can be + * both, when it sits in the middle of a 3+ combo). Only the combo's outer corners + * are rounded so the pair reads as one pill: `.compact-left` joins its neighbor on + * the left (square left, round right), `.compact-right` joins on the right (square + * right, round left), and a middle item stays square on both sides. + */ +.style-override-statusBar .part.statusbar > .items-container > .statusbar-item, +.style-override-statusBar .part.statusbar > .items-container > .statusbar-item > .statusbar-item-label { + border-radius: var(--vscode-cornerRadius-small, 4px); +} + +.style-override-statusBar .part.statusbar > .items-container > .statusbar-item.compact-left, +.style-override-statusBar .part.statusbar > .items-container > .statusbar-item.compact-left > .statusbar-item-label { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.style-override-statusBar .part.statusbar > .items-container > .statusbar-item.compact-right, +.style-override-statusBar .part.statusbar > .items-container > .statusbar-item.compact-right > .statusbar-item-label { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css index 0ea6ecfcd5b6d..18d6122c6aac1 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css @@ -22,47 +22,30 @@ } .style-override-tabs .part.editor .tabs-container > .tab { - background-color: transparent !important; + background-color: color-mix(in srgb, var(--vscode-foreground) 5%, transparent) !important; border-right: none !important; border-radius: var(--vscode-cornerRadius-small); font-size: 12px !important; font-weight: var(--vscode-agents-fontWeight-semiBold); box-shadow: none !important; padding: 0 0 0 4px !important; + margin-right: 4px !important; --tab-border-top-color: transparent !important; } -/* Allow tabs to shrink when space is constrained so the close button stays - * reachable, while still letting them grow to fit their label when there is - * space. */ -.style-override-tabs .part.editor .tabs-container > .tab.sizing-fit { - width: auto !important; - min-width: 0 !important; - flex: 0 1 auto !important; -} - -.style-override-tabs .part.editor .tabs-container > .tab.sizing-fit .monaco-icon-label, -.style-override-tabs .part.editor .tabs-container > .tab.sizing-fit .monaco-icon-label > .monaco-icon-label-container { - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; -} - -/* Keep the close button reserved within the tab so it remains accessible when - * the tab shrinks. */ -.style-override-tabs .part.editor .tabs-container > .tab > .tab-actions { - flex: 0 0 auto; - overflow: visible; -} - +/* Center tabs vertically and strip the bottom border so the pills float. */ .style-override-tabs .part.editor .tabs-and-actions-container { --tabs-border-bottom-color: transparent !important; align-items: center; - padding: 4px; + padding: 4px 0; } .style-override-tabs .part.editor .tabs-container > .tab.active { - background-color: color-mix(in srgb, var(--vscode-foreground) 15%, transparent) !important; + background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent) !important; +} + +.style-override-tabs.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; } .style-override-tabs .part.editor .tabs-container > .tab .tab-border-top-container, @@ -113,6 +96,54 @@ 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, 12px/600 type, a subtle background + * on the active tab and no underline indicator. + */ +.style-override-tabs .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item, +.style-override-tabs .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-tabs .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, +.style-override-tabs .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { + font-size: 12px; + font-weight: var(--vscode-agents-fontWeight-semiBold); + line-height: 22px; /* keep consistent with other 22px title/control heights */ +} + +.style-override-tabs .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked, +.style-override-tabs .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-tabs` class winning) across the + * `.title` and `.header-or-footer` checked and focused states. + */ +.style-override-tabs .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before, +.style-override-tabs .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:focus .active-item-indicator:before, +.style-override-tabs .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked .active-item-indicator:before, +.style-override-tabs .part.auxiliarybar > .header-or-footer > .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; +} + .style-override-tabs .part.editor > .content .editor-group-container > .title .tabs-container > .tab .tab-label a { font-size: 12px; font-weight: var(--vscode-agents-fontWeight-semiBold); diff --git a/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts b/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts index 8237d1fa72bac..c1b736c16ad14 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts +++ b/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry } from '../../../common/contributions.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; @@ -17,9 +17,14 @@ import { workbenchConfigurationNodeBase } from '../../../common/configuration.js // rules behind a `.style-override-` ancestor class, so the styles are inert // until the matching class is toggled onto the workbench container(s) below. import './media/activityBar.css'; +import './media/commandCenter.css'; import './media/fontRamp.css'; +import './media/keyboardFocusOnly.css'; +import './media/padding.css'; import './media/paneHeaders.css'; import './media/roundedCorners.css'; +import './media/scrollShadows.css'; +import './media/statusBar.css'; import './media/tabs.css'; const SETTING_ID = 'workbench.experimental.styleOverrides'; @@ -28,6 +33,12 @@ interface IStyleOverrideModule { readonly id: string; readonly label: string; readonly description: string; + /** + * Whether this module changes layout-affecting CSS variables (e.g. the pane + * header size). Toggling such a module requires a workbench relayout so the + * new values are read; modules without this flag only affect appearance. + */ + readonly layoutAffecting?: boolean; } /** @@ -40,23 +51,49 @@ const STYLE_OVERRIDE_MODULES: readonly IStyleOverrideModule[] = [ { id: 'activityBar', label: localize('styleOverrides.activityBar', "Activity Bar"), - description: localize('styleOverrides.activityBar.description', "Replaces the active activity bar item's left highlight border with a rounded background behind the icon.") + description: localize('styleOverrides.activityBar.description', "Replaces the active activity bar item's left highlight border with a rounded background behind the icon, and forces item foregrounds to the editor foreground so icons stay legible.") + }, + { + id: 'commandCenter', + label: localize('styleOverrides.commandCenter', "Agents Window Command Center"), + description: localize('styleOverrides.commandCenter.description', "Makes the command center and agent status input transparent at rest, revealing their background on hover to match the Agents window.") }, { id: 'fontRamp', label: localize('styleOverrides.fontRamp', "Font Ramp"), description: localize('styleOverrides.fontRamp.description', "Applies a unified typographic ramp across the workbench: headings at 26/18px, 13px body, 12px section titles and tabs, 11px metadata and 10px badges.") }, + { + id: 'keyboardFocusOnly', + label: localize('styleOverrides.keyboardFocusOnly', "Keyboard-Only Focus Borders"), + description: localize('styleOverrides.keyboardFocusOnly.description', "Hides focus borders that appear when panels are focused with the mouse, while keeping them for keyboard navigation.") + }, + { + id: 'padding', + label: localize('styleOverrides.padding', "Padding"), + description: localize('styleOverrides.padding.description', "Adds extra padding to view pane headers and bodies for more breathing room around content.") + }, { id: 'paneHeaders', label: localize('styleOverrides.paneHeaders', "Pane Headers"), - description: localize('styleOverrides.paneHeaders.description', "Insets the view pane header separators, rounds their corners and adds a background tint on hover.") + description: localize('styleOverrides.paneHeaders.description', "Insets the view pane header separators, rounds their corners and adds a background tint on hover."), + layoutAffecting: true }, { id: 'roundedCorners', label: localize('styleOverrides.roundedCorners', "Rounded Corners"), description: localize('styleOverrides.roundedCorners.description', "Applies a three-tier corner radius system: 8px for overlays (quick input, hovers, menus, dialogs), 6px for non-control containers and 4px for interactable controls (inputs, lists).") }, + { + id: 'scrollShadows', + label: localize('styleOverrides.scrollShadows', "Scroll Shadows"), + description: localize('styleOverrides.scrollShadows.description', "Adds soft inset shadows to the top and bottom of lists, trees and the editor so content reads as scrolling under a fixed frame.") + }, + { + id: 'statusBar', + label: localize('styleOverrides.statusBar', "Status Bar"), + description: localize('styleOverrides.statusBar.description', "Forces the status bar foreground to the general editor foreground so its text and icons stay legible when the status bar background is neutralized.") + }, { id: 'tabs', label: localize('styleOverrides.tabs', "Agents Window Tabs"), @@ -82,18 +119,32 @@ export class StyleOverridesContribution extends Disposable implements IWorkbench private readonly knownModuleIds = new Set(STYLE_OVERRIDE_MODULES.map(m => m.id)); private readonly knownClassNames = STYLE_OVERRIDE_MODULES.map(m => classNameFor(m.id)); + private readonly layoutAffectingClassNames = new Set(STYLE_OVERRIDE_MODULES.filter(m => m.layoutAffecting).map(m => classNameFor(m.id))); + + /** Whether a layout-affecting module was active at the last applied selection. */ + private layoutAffectingActive = false; constructor( @IConfigurationService private readonly configurationService: IConfigurationService, - @ILayoutService private readonly layoutService: ILayoutService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, ) { super(); + this.layoutAffectingActive = this.hasActiveLayoutAffectingModule(); + // A config change re-applies to every container (the global `update()` // covers all windows, including auxiliary ones). this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(SETTING_ID)) { this.update(); + // Some modules drive layout-affecting CSS variables (e.g. the + // `paneHeaders` header size). Only relayout when one of those is + // toggled, to avoid an unnecessary full workbench relayout. + const layoutAffectingActive = this.hasActiveLayoutAffectingModule(); + if (layoutAffectingActive !== this.layoutAffectingActive) { + this.layoutAffectingActive = layoutAffectingActive; + this.layoutService.layout(); + } } })); @@ -119,6 +170,16 @@ export class StyleOverridesContribution extends Disposable implements IWorkbench return active; } + private hasActiveLayoutAffectingModule(): boolean { + const active = this.activeClassNames(); + for (const className of this.layoutAffectingClassNames) { + if (active.has(className)) { + return true; + } + } + return false; + } + private update(): void { const active = this.activeClassNames(); for (const container of this.layoutService.containers) { diff --git a/src/vs/workbench/contrib/terminal/terminalContribExports.ts b/src/vs/workbench/contrib/terminal/terminalContribExports.ts index d739f8cf9221a..7e3eae681bfba 100644 --- a/src/vs/workbench/contrib/terminal/terminalContribExports.ts +++ b/src/vs/workbench/contrib/terminal/terminalContribExports.ts @@ -51,7 +51,6 @@ export const enum TerminalContribSettingId { AgentSandboxWindowsEnabled = AgentSandboxSettingId.AgentSandboxWindowsEnabled, AgentSandboxAllowUnsandboxedCommands = AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, AgentSandboxRetryWithAllowNetworkRequests = AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, - AgentSandboxAutoApproveUnsandboxedCommands = AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands, AgentSandboxAllowAutoApprove = AgentSandboxSettingId.AgentSandboxAllowAutoApprove, DeprecatedAgentSandboxEnabled = AgentSandboxSettingId.DeprecatedAgentSandboxEnabled, DeprecatedAgentSandboxLinuxFileSystem = TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxLinuxFileSystem, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index e3c8bc6b6b932..1f8a127c3c489 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -745,10 +745,6 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return this._configurationService.getValue(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands) === true; } - private get _autoApproveUnsandboxedCommands(): boolean { - return this._allowUnsandboxedCommands && this._configurationService.getValue(AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands) === true; - } - private get _retryWithAllowNetworkRequests(): boolean { return this._configurationService.getValue(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests) === true; } @@ -1146,9 +1142,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // Would be auto-approved based on rules wouldBeAutoApproved ); - const isUnsandboxedAutoApproved = isSandboxEnabled && requiresUnsandboxConfirmation === true && this._autoApproveUnsandboxedCommands; const isSandboxAutoApproved = isSandboxEnabled && toolSpecificData.commandLine.isSandboxWrapped === true && !requiresAllowNetworkConfirmation && this._allowSandboxAutoApprove; - const isFinalAutoApproved = isUnsandboxedAutoApproved || isSandboxAutoApproved || isAutoApprovedByRules || commandLineAnalyzerResults.some(e => e.forceAutoApproval); + const isFinalAutoApproved = isSandboxAutoApproved || isAutoApprovedByRules || commandLineAnalyzerResults.some(e => e.forceAutoApproval); // Pass auto approve info if the command: // - Was auto approved @@ -1383,9 +1378,6 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } private async _confirmAutomaticSandboxRetry(retryKind: AutomaticSandboxRetryKind, sessionResource: URI | undefined, command: string, shell: string, blockedDomains: string[] | undefined, riskAssessment: { toolId: string; parameters: unknown } | undefined, token: CancellationToken): Promise { - if (retryKind === 'unsandboxed' && this._autoApproveUnsandboxedCommands) { - return true; - } const chatModel = sessionResource && this._chatService.getSession(sessionResource); if (!(chatModel instanceof ChatModel)) { return false; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts index abddf72bcd0c0..f35a29b95f944 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/sandboxSettingsReader.ts @@ -14,7 +14,6 @@ export const SANDBOX_SETTING_KEYS: readonly string[] = [ AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxSettingId.AgentSandboxWindowsEnabled, AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, - AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands, AgentSandboxSettingId.AgentSandboxLinuxFileSystem, AgentSandboxSettingId.AgentSandboxMacFileSystem, AgentSandboxSettingId.AgentSandboxWindowsFileSystem, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 5186e9059d074..ce9ebb73ba472 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -609,24 +609,6 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary { setConfig(TerminalChatAgentToolsSettingId.BlockDetectedFileWrites, 'outsideWorkspace'); setConfig(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands, true); setConfig(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); - setConfig(AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands, false); setConfig(AgentSandboxSettingId.AgentSandboxAllowAutoApprove, false); sandboxEnabled = false; sandboxPrereqResult = { @@ -950,17 +949,6 @@ suite('RunInTerminalTool', () => { await assertAutomaticUnsandboxRetryElicitation(runInTerminalTool, LocalChatSessionUri.forSession('auto-retry-sandbox-force-approved-session'), 'rm dangerous-file.txt', 'bash', undefined); }); - test('should auto-retry without elicitation when unsandboxed command auto approve is enabled', async () => { - setConfig(AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands, true); - const sessionResource = LocalChatSessionUri.forSession('auto-retry-unsandboxed-setting-session'); - - const model = createChatModelWithRequest(sessionResource); - const shouldRetry = await confirmAutomaticUnsandboxRetry(runInTerminalTool, sessionResource, 'rm dangerous-file.txt', 'bash', undefined); - - strictEqual(shouldRetry, true, 'Expected unsandboxed auto approve setting to retry without prompting'); - const elicitation = model.getRequests().at(-1)?.response?.response.value.find(part => part.kind === 'elicitation2'); - ok(!elicitation, 'Expected no elicitation when unsandboxed auto approve setting is enabled'); - }); }); suite('default auto-approve rules', () => { @@ -1419,56 +1407,6 @@ suite('RunInTerminalTool', () => { ok(result.content[0].kind === 'text' && result.content[0].value.includes('chat.agent.sandbox.allowUnsandboxedCommands')); }); - test('should auto-approve explicit unsandboxed execution requests when unsandboxed auto approve is enabled', async () => { - setConfig(AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands, true); - setConfig(TerminalChatAgentToolsSettingId.EnableAutoApprove, false); - sandboxEnabled = true; - sandboxPrereqResult = { - enabled: true, - sandboxConfigPath: '/tmp/sandbox.json', - failedCheck: undefined, - }; - runInTerminalTool.setBackendOs(OperatingSystem.Linux); - - const result = await executeToolTest({ - requestUnsandboxedExecution: true, - requestUnsandboxedExecutionReason: 'Needs network access outside the sandbox', - }); - - assertAutoApproved(result); - const terminalData = result!.toolSpecificData as IChatTerminalToolInvocationData; - strictEqual(terminalData.requestUnsandboxedExecution, true); - strictEqual(terminalData.requestUnsandboxedExecutionReason, 'Needs network access outside the sandbox'); - strictEqual(terminalData.commandLine.toolEdited, 'unsandboxed:echo hello'); - }); - - test('should auto-approve blocked-domain unsandboxed fallback when unsandboxed auto approve is enabled', async () => { - setConfig(AgentSandboxSettingId.AgentSandboxAutoApproveUnsandboxedCommands, true); - setConfig(TerminalChatAgentToolsSettingId.EnableAutoApprove, false); - sandboxEnabled = true; - sandboxPrereqResult = { - enabled: true, - sandboxConfigPath: '/tmp/sandbox.json', - failedCheck: undefined, - }; - runInTerminalTool.setBackendOs(OperatingSystem.Linux); - terminalSandboxService.wrapCommand = async (command: string) => ({ - command: `unsandboxed:${command}`, - isSandboxWrapped: false, - requiresUnsandboxConfirmation: true, - blockedDomains: ['evil.com'], - deniedDomains: ['evil.com'], - }); - - const result = await executeToolTest({ command: 'curl https://evil.com' }); - - assertAutoApproved(result); - const terminalData = result!.toolSpecificData as IChatTerminalToolInvocationData; - strictEqual(terminalData.requestUnsandboxedExecution, true); - strictEqual(terminalData.commandLine.toolEdited, 'unsandboxed:curl https://evil.com'); - strictEqual(terminalData.requestUnsandboxedExecutionReason, 'This command accesses evil.com, which is blocked by chat.agent.deniedNetworkDomains.'); - }); - test('should auto-approve sandboxed commands when sandbox auto approve is enabled', async () => { setConfig(AgentSandboxSettingId.AgentSandboxAllowAutoApprove, true); setConfig(TerminalChatAgentToolsSettingId.EnableAutoApprove, false); diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index a8c7d3631d7d7..21837b5f90d2f 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -124,6 +124,7 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount get managedSettingsFetchStatus(): ManagedSettingsFetchStatus { return this.defaultAccountProvider?.managedSettingsFetchStatus ?? null; } get managedSettingsFetchedAt(): number | null { return this.defaultAccountProvider?.managedSettingsFetchedAt ?? null; } + get managedSettingsRawResponse(): unknown { return this.defaultAccountProvider?.managedSettingsRawResponse ?? null; } private readonly initBarrier = new Barrier(); @@ -275,6 +276,9 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid get managedSettingsFetchStatus(): ManagedSettingsFetchStatus { return this._managedSettingsFetchStatus; } get managedSettingsFetchedAt(): number | null { return this._policyData?.managedSettingsFetchedAt ?? null; } + private _managedSettingsRawResponse: unknown = null; + get managedSettingsRawResponse(): unknown { return this._managedSettingsRawResponse; } + private readonly _onDidChangeDefaultAccount = this._register(new Emitter()); readonly onDidChangeDefaultAccount = this._onDidChangeDefaultAccount.event; @@ -913,6 +917,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid try { const data = await asJson(response); this.logService.trace('[DefaultAccount] Managed settings raw response:', JSON.stringify(data ?? null)); + this._managedSettingsRawResponse = data ?? null; const adapted = adaptManagedSettings(data ?? {}, msg => this.logService.warn(msg)); // An empty response (`{}`) is a successful "no policy file present" signal. const managedSettingsCount = adapted.managedSettings ? Object.keys(adapted.managedSettings).length : 0; diff --git a/src/vs/workbench/services/policies/common/accountPolicyService.ts b/src/vs/workbench/services/policies/common/accountPolicyService.ts index 8258f05c18ec0..279e81166d1ea 100644 --- a/src/vs/workbench/services/policies/common/accountPolicyService.ts +++ b/src/vs/workbench/services/policies/common/accountPolicyService.ts @@ -179,23 +179,21 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli private getPolicyData(managedSettings?: ManagedSettingsData): IPolicyData | undefined { const accountPolicyData = this.defaultAccountService.policyData ?? undefined; - const managedPolicyData = managedSettings ?? this.copilotManagedSettingsService?.managedSettings; - const hasManagedPolicyData = managedPolicyData && Object.keys(managedPolicyData).length > 0; - if (!accountPolicyData && !hasManagedPolicyData) { + const nativeManagedSettings = managedSettings ?? this.copilotManagedSettingsService?.managedSettings; + const hasNativeManagedSettings = nativeManagedSettings && Object.keys(nativeManagedSettings).length > 0; + if (!accountPolicyData && !hasNativeManagedSettings) { return undefined; } - // Managed settings arrive from two delivery channels: the server `managed_settings` - // endpoint (carried on `accountPolicyData`) and native MDM (the Copilot managed-settings - // service). Merge them — MDM overrides server — then project the result onto the schema - // declared by policy definitions so both channels honor the same declaration-driven keys - // and value types. + // Single authoritative source: server-delivered managed settings win over native MDM. + // See `.github/skills/add-policy/github-managed-settings.md` for the precedence rationale. + const serverManagedSettings = accountPolicyData?.managedSettings; + const hasServerManagedSettings = serverManagedSettings && Object.keys(serverManagedSettings).length > 0; + const winningManagedSettings = hasServerManagedSettings ? serverManagedSettings : nativeManagedSettings; + const declaredManagedSettings = collectManagedSettingsDefinitions(this.policyDefinitions); const managedSettingsData = projectManagedSettings( - { - ...accountPolicyData?.managedSettings, - ...managedPolicyData, - }, + { ...winningManagedSettings }, declaredManagedSettings, msg => this.logService.warn(`[AccountPolicy] ${msg}`) ); diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index 17445d3204034..8b3e59dd3e7e3 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -38,6 +38,7 @@ class DefaultAccountProvider implements IDefaultAccountProvider { readonly onDidChangeCopilotTokenInfo = Event.None; readonly managedSettingsFetchStatus: null = null; readonly managedSettingsFetchedAt: null = null; + readonly managedSettingsRawResponse: unknown = null; constructor( readonly defaultAccount: IDefaultAccount, @@ -283,9 +284,10 @@ suite('AccountPolicyService', () => { }); }); - test('managed settings: native MDM value overrides the server value for the same declared key', async () => { - // MDM says 'disable', server says 'enable'. The merged, declaration-projected bag must - // let MDM win, so the gated policy resolves to `false`. + test('managed settings: server value wins over native MDM for the same declared key', async () => { + // Server says 'enable', native MDM says 'disable'. The server is the authoritative + // source when present, so native MDM is ignored entirely and the gated policy is NOT + // forced to `false`. const copilotManagedSettingsService = disposables.add(new FakeCopilotManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, copilotManagedSettingsService)); const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); @@ -298,6 +300,23 @@ suite('AccountPolicyService', () => { await policyConfiguration.initialize(); + assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), undefined); + }); + + test('managed settings: native MDM applies when the server provides no managed settings', async () => { + // No server managed settings — native MDM is the authoritative source and forces the + // gated policy to `false`. + const copilotManagedSettingsService = disposables.add(new FakeCopilotManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); + policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, copilotManagedSettingsService)); + const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); + await defaultConfiguration.initialize(); + policyConfiguration = disposables.add(new PolicyConfiguration(defaultConfiguration, policyService, new NullLogService())); + + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, {})); + await defaultAccountService.refresh(); + + await policyConfiguration.initialize(); + assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), false); }); diff --git a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts index 901b4e1b5f083..b2771c0c25c6e 100644 --- a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts @@ -43,6 +43,7 @@ class DefaultAccountProvider implements IDefaultAccountProvider { readonly onDidChangeCopilotTokenInfo = Event.None; readonly managedSettingsFetchStatus: null = null; readonly managedSettingsFetchedAt: null = null; + readonly managedSettingsRawResponse: unknown = null; constructor( readonly defaultAccount: IDefaultAccount, diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentFeedbackReviewConfirmation.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentFeedbackReviewConfirmation.fixture.ts index d30e61a0b8e49..bdcb0b565a53a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentFeedbackReviewConfirmation.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatAgentFeedbackReviewConfirmation.fixture.ts @@ -15,10 +15,12 @@ import { IChatWidgetService } from '../../../../contrib/chat/browser/chat.js'; import { IChatToolRiskAssessmentService } from '../../../../contrib/chat/browser/tools/chatToolRiskAssessmentService.js'; import { IChatContentPartRenderContext, InlineTextModelCollection } from '../../../../contrib/chat/browser/widget/chatContentParts/chatContentParts.js'; import { IChatMarkdownAnchorService } from '../../../../contrib/chat/browser/widget/chatContentParts/chatMarkdownAnchorService.js'; -import { ChatAgentFeedbackReviewConfirmationSubPart } from '../../../../contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatAgentFeedbackReviewConfirmationSubPart.js'; +import { ChatToolConfirmationCarouselPart, ToolInvocationPartFactory } from '../../../../contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationCarouselPart.js'; +import { ChatToolInvocationPart } from '../../../../contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.js'; import { AgentFeedbackReviewCommandId, IChatAgentFeedbackReviewComment, IChatAgentFeedbackReviewConfirmationData } from '../../../../contrib/chat/common/chatService/chatService.js'; import { ChatToolInvocation } from '../../../../contrib/chat/common/model/chatProgressTypes/chatToolInvocation.js'; import { IChatResponseViewModel } from '../../../../contrib/chat/common/model/chatViewModel.js'; +import { IChatTodoListService } from '../../../../contrib/chat/common/tools/chatTodoListService.js'; import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../../contrib/chat/common/tools/languageModelToolsService.js'; import { IDecorationsService } from '../../../../services/decorations/common/decorations.js'; import { INotebookDocumentService } from '../../../../services/notebook/common/notebookDocumentService.js'; @@ -28,10 +30,9 @@ import { ComponentFixtureContext, createEditorServices, defineComponentFixture, import '../../../../contrib/chat/browser/widget/media/chat.css'; import '../../../../contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css'; import '../../../../contrib/chat/browser/widget/chatContentParts/media/chatAgentFeedbackReviewConfirmation.css'; +import '../../../../contrib/chat/browser/widget/chatContentParts/media/chatToolConfirmationCarousel.css'; -const sessionResource = URI.parse('copilot:/fixture-session'); - -function createMockContext(): IChatContentPartRenderContext { +function createMockContext(sessionResource: URI): IChatContentPartRenderContext { const element = new class extends mock() { override readonly sessionResource = sessionResource; }(); @@ -51,7 +52,7 @@ function createMockContext(): IChatContentPartRenderContext { }; } -function createToolInvocation(): ChatToolInvocation { +function createToolInvocation(toolCallId: string): ChatToolInvocation { const toolData: IToolData = { id: 'viewUnreviewedComments', source: ToolDataSource.Internal, @@ -71,7 +72,7 @@ function createToolInvocation(): ChatToolInvocation { toolSpecificData, }, toolData, - 'fixture-tool-call', + toolCallId, undefined, undefined, ); @@ -79,17 +80,20 @@ function createToolInvocation(): ChatToolInvocation { /** * Mock command service that backs the confirmation renderer. Returns the - * supplied comments for the "get comments" command and no-ops for the reveal / - * delete / accept actions so the fixture renders without touching the real - * feedback service. + * comments associated with the requesting session for the "get comments" + * command (so each carousel item can resolve its own set) and no-ops for the + * reveal / delete / accept actions so the fixture renders without touching the + * real feedback service. */ -function createCommandService(comments: readonly IChatAgentFeedbackReviewComment[]): ICommandService { +function createCommandService(commentsBySession: ReadonlyMap): ICommandService { return new class extends mock() { override readonly onWillExecuteCommand = Event.None; override readonly onDidExecuteCommand = Event.None; - override async executeCommand(commandId: string): Promise { + override async executeCommand(commandId: string, ...args: unknown[]): Promise { if (commandId === AgentFeedbackReviewCommandId.GetComments) { - return comments as unknown as R; + const sessionResource = args[0] as URI | undefined; + const key = sessionResource?.toString(); + return (key ? commentsBySession.get(key) : undefined) as unknown as R ?? ([] as unknown as R); } return undefined; } @@ -97,8 +101,32 @@ function createCommandService(comments: readonly IChatAgentFeedbackReviewComment } function renderConfirmation(context: ComponentFixtureContext, comments: readonly IChatAgentFeedbackReviewComment[]): void { + renderConfirmations(context, [comments]); +} + +/** + * Renders one tool confirmation per supplied comment set, all hosted inside a + * real {@link ChatToolConfirmationCarouselPart} so the confirmation is shown + * with the same overlay chrome (title, Allow All / Skip / expand actions and, + * for multiple confirmations, the step indicator and navigation arrows) it has + * in the chat input. Each confirmation resolves its own comments via a distinct + * session resource. + */ +function renderConfirmations(context: ComponentFixtureContext, commentSets: readonly (readonly IChatAgentFeedbackReviewComment[])[]): void { const { container, disposableStore } = context; + const commentsBySession = new Map(); + const contextByToolCallId = new Map(); + const tools: ChatToolInvocation[] = []; + + commentSets.forEach((comments, index) => { + const sessionResource = URI.parse(`copilot:/fixture-session-${index}`); + commentsBySession.set(sessionResource.toString(), comments); + const tool = createToolInvocation(`fixture-tool-call-${index}`); + tools.push(tool); + contextByToolCallId.set(tool.toolCallId, createMockContext(sessionResource)); + }); + const instantiationService = createEditorServices(disposableStore, { colorTheme: context.theme, additionalServices: (reg) => { @@ -108,7 +136,8 @@ function renderConfirmation(context: ComponentFixtureContext, comments: readonly 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 }; } }()); reg.defineInstance(INotebookDocumentService, new class extends mock() { }()); - reg.defineInstance(ICommandService, createCommandService(comments)); + reg.defineInstance(ICommandService, createCommandService(commentsBySession)); + reg.defineInstance(IChatTodoListService, new class extends mock() { override setTodos() { } }()); reg.defineInstance(IChatMarkdownAnchorService, new class extends mock() { override register() { return { dispose() { } }; } }()); @@ -122,22 +151,38 @@ function renderConfirmation(context: ComponentFixtureContext, comments: readonly }, }); - const toolInvocation = createToolInvocation(); - const part = disposableStore.add( - instantiationService.createInstance( - ChatAgentFeedbackReviewConfirmationSubPart, - toolInvocation, - createMockContext(), - ) + const toolPartFactory: ToolInvocationPartFactory = (tool) => instantiationService.createInstance( + ChatToolInvocationPart, + tool, + contextByToolCallId.get(tool.toolCallId)!, + undefined!, // renderer — unused by the agent feedback confirmation sub part + undefined!, // listPool — unused by the agent feedback confirmation sub part + undefined!, // editorPool — unused by the agent feedback confirmation sub part + () => 480, + undefined, + 0, ); - container.style.width = '520px'; + const carousel = disposableStore.add(new ChatToolConfirmationCarouselPart(toolPartFactory, tools)); + + container.style.width = '680px'; container.style.padding = '8px'; container.classList.add('interactive-session'); - - const itemContainer = dom.$('.interactive-item-container'); - itemContainer.appendChild(part.domNode); - container.appendChild(itemContainer); + // In product the carousel is hosted in the chat input, which sits on the + // side bar / panel background; reproduce that backdrop on the host element + // so the carousel reads correctly in place instead of on a transparent + // surface. + container.style.backgroundColor = 'var(--vscode-sideBar-background, var(--vscode-editor-background))'; + + // The carousel layout/visibility CSS keys off + // `.interactive-session .interactive-input-part > .chat-tool-confirmation-carousel-container`, + // so reproduce that ancestry here. + const inputPart = dom.$('.interactive-input-part'); + const carouselContainer = dom.$('.chat-tool-confirmation-carousel-container'); + inputPart.appendChild(carouselContainer); + container.appendChild(inputPart); + + carouselContainer.appendChild(carousel.domNode); } // ============================================================================ @@ -194,4 +239,13 @@ export default defineThemedFixtureGroup({ path: 'chat/' }, { labels: { kind: 'screenshot' }, render: (ctx) => renderConfirmation(ctx, []), }), + + Carousel: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderConfirmations(ctx, [ + [prComment], + [longComment], + [prComment, agentReviewComment, longComment], + ]), + }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index eaeaf497e8973..50719f0b3e51c 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -554,6 +554,7 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre onDidChangeCopilotTokenInfo: new Emitter().event, managedSettingsFetchStatus: null, managedSettingsFetchedAt: null, + managedSettingsRawResponse: null, getDefaultAccount: async () => null, getDefaultAccountAuthenticationProvider: () => ({ id: 'test', name: 'Test', scopes: [], enterprise: false }), resolveGitHubUrl: (path: string) => `https://github.com/${path}`, diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index 9ac04a886cbe3..f035be815b0f7 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -27,10 +27,12 @@ interface SessionConfig { readonly reply: string; readonly scenarioId2: string; readonly reply2: string; + /** Skip the second message/assertion (e.g. while a known flake is being investigated). */ + readonly skipReply2?: boolean; } const SESSIONS: readonly SessionConfig[] = [ - { name: 'Copilot CLI', scenarioId: 'smoke-hello-copilot', reply: 'MOCKED_COPILOT_RESPONSE', scenarioId2: 'smoke-hello-copilot-2', reply2: 'MOCKED_COPILOT_RESPONSE_2' }, + { name: 'Copilot CLI', scenarioId: 'smoke-hello-copilot', reply: 'MOCKED_COPILOT_RESPONSE', scenarioId2: 'smoke-hello-copilot-2', reply2: 'MOCKED_COPILOT_RESPONSE_2', skipReply2: true }, { name: 'Claude', scenarioId: 'smoke-hello-claude', reply: 'MOCKED_CLAUDE_RESPONSE', scenarioId2: 'smoke-hello-claude-2', reply2: 'MOCKED_CLAUDE_RESPONSE_2' }, { name: 'Local', scenarioId: 'smoke-hello-local', reply: 'MOCKED_LOCAL_RESPONSE', scenarioId2: 'smoke-hello-local-2', reply2: 'MOCKED_LOCAL_RESPONSE_2' }, ]; @@ -233,23 +235,27 @@ export function setup(logger: Logger) { await app.workbench.agentsWindow.activateSessionByLabel(session.reply); } - // Follow-up message in the same session — exercises the - // active-session input path (not the new-session homepage). - // For Copilot CLI, pass the expected active label so - // `sendFollowUpMessage` re-verifies the active slot right - // before sending (the workbench can auto-swap the slot to - // a fresh untitled session between `activateSessionByLabel` - // returning and the send-button click). - const expectedActiveLabel = session.name === 'Copilot CLI' ? session.reply : undefined; - await app.workbench.agentsWindow.sendFollowUpMessage( - `hello again [scenario:${session.scenarioId2}]`, - undefined, - expectedActiveLabel, - ); - - const secondTurnTimeout = session.name === 'Copilot CLI' ? 180_000 : 60_000; - const text2 = await app.workbench.agentsWindow.waitForAssistantText(session.reply2, secondTurnTimeout); - logger.log(`Agents Window (${session.name}) response 2: ${text2}`); + if (!session.skipReply2) { + // Follow-up message in the same session — exercises the + // active-session input path (not the new-session homepage). + // For Copilot CLI, pass the expected active label so + // `sendFollowUpMessage` re-verifies the active slot right + // before sending (the workbench can auto-swap the slot to + // a fresh untitled session between `activateSessionByLabel` + // returning and the send-button click). + const expectedActiveLabel = session.name === 'Copilot CLI' ? session.reply : undefined; + await app.workbench.agentsWindow.sendFollowUpMessage( + `hello again [scenario:${session.scenarioId2}]`, + undefined, + expectedActiveLabel, + ); + + const secondTurnTimeout = session.name === 'Copilot CLI' ? 180_000 : 60_000; + const text2 = await app.workbench.agentsWindow.waitForAssistantText(session.reply2, secondTurnTimeout); + logger.log(`Agents Window (${session.name}) response 2: ${text2}`); + } else { + logger.log(`[Agents Window/${session.name}] skipping second reply assertion (skipReply2=true)`); + } assert.ok( mockServer.requestCount() > requestsBefore, @@ -551,7 +557,7 @@ export function setup(logger: Logger) { }); }); - describe('Agents Window (Codex)', () => { + describe.skip('Agents Window (Codex)', () => { const codex = setupAgentHostSuite(logger, { serverLabel: 'Codex', diff --git a/test/smoke/src/areas/chat/chatSessions.test.ts b/test/smoke/src/areas/chat/chatSessions.test.ts index ab58614511f82..8003b026dac3f 100644 --- a/test/smoke/src/areas/chat/chatSessions.test.ts +++ b/test/smoke/src/areas/chat/chatSessions.test.ts @@ -25,10 +25,12 @@ interface SessionConfig { readonly reply: string; readonly scenarioId2: string; readonly reply2: string; + /** Skip the second message/assertion (e.g. while a known flake is being investigated). */ + readonly skipReply2?: boolean; } const SESSIONS: readonly SessionConfig[] = [ - { name: 'Copilot CLI', command: 'smoketest.openCopilotCliChat', kind: 'editor', scenarioId: 'smoke-chat-sessions-copilot-cli', reply: 'MOCKED_CHAT_SESSIONS_COPILOT_CLI_RESPONSE', scenarioId2: 'smoke-chat-sessions-copilot-cli-2', reply2: 'MOCKED_CHAT_SESSIONS_COPILOT_CLI_RESPONSE_2' }, + { name: 'Copilot CLI', command: 'smoketest.openCopilotCliChat', kind: 'editor', scenarioId: 'smoke-chat-sessions-copilot-cli', reply: 'MOCKED_CHAT_SESSIONS_COPILOT_CLI_RESPONSE', scenarioId2: 'smoke-chat-sessions-copilot-cli-2', reply2: 'MOCKED_CHAT_SESSIONS_COPILOT_CLI_RESPONSE_2', skipReply2: true }, { name: 'Claude', command: 'smoketest.openClaudeChat', kind: 'editor', scenarioId: 'smoke-chat-sessions-claude', reply: 'MOCKED_CHAT_SESSIONS_CLAUDE_RESPONSE', scenarioId2: 'smoke-chat-sessions-claude-2', reply2: 'MOCKED_CHAT_SESSIONS_CLAUDE_RESPONSE_2' }, { name: 'Local', command: 'workbench.action.chat.open', kind: 'view', scenarioId: 'smoke-chat-sessions-local', reply: 'MOCKED_CHAT_SESSIONS_LOCAL_RESPONSE', scenarioId2: 'smoke-chat-sessions-local-2', reply2: 'MOCKED_CHAT_SESSIONS_LOCAL_RESPONSE_2' }, ]; @@ -158,14 +160,18 @@ export function setup(logger: Logger) { // Follow-up message + second scenario reply, sent in the same // chat surface to exercise the follow-up code path. - logger.log(`[Chat Sessions/${session.name}] running second command and waiting for chat editor`); - const responseText2 = await sendAndWaitForReply(app.workbench.chat, session, `hello again [scenario:${session.scenarioId2}]`, session.reply2); - logger.log(`Chat Sessions (${session.name}) response 2: ${responseText2}`); - - assert.ok( - responseText2.includes(session.reply2), - `Expected ${session.name} response 2 to include mocked scenario response "${session.reply2}".\n\nResponse:\n${responseText2}` - ); + if (!session.skipReply2) { + logger.log(`[Chat Sessions/${session.name}] running second command and waiting for chat editor`); + const responseText2 = await sendAndWaitForReply(app.workbench.chat, session, `hello again [scenario:${session.scenarioId2}]`, session.reply2); + logger.log(`Chat Sessions (${session.name}) response 2: ${responseText2}`); + + assert.ok( + responseText2.includes(session.reply2), + `Expected ${session.name} response 2 to include mocked scenario response "${session.reply2}".\n\nResponse:\n${responseText2}` + ); + } else { + logger.log(`[Chat Sessions/${session.name}] skipping second reply assertion (skipReply2=true)`); + } assert.ok( mockServer.requestCount() > requestsBefore, `expected the mock LLM server to have received a new request from the ${session.name} session (before=${requestsBefore}, after=${mockServer.requestCount()})`