From 9adb47928bb6bc07064835f491b5cde8c3fcb9db Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Wed, 22 Jul 2026 20:55:34 +1000 Subject: [PATCH 01/34] Restore last-used agent-host model instead of falling back to Auto (#326938) * fix(chat): restore last-used agent-host model instead of falling back to Auto Agent-host chat sessions (e.g. Copilot CLI) publish their model list asynchronously after the agent host connects. During that cold-start window the model-restore logic treated an absent remembered/session model as conclusively unavailable and reset the picker to "Auto" instead of waiting for the pool to load. This affected new chat editors, the sidebar chat view, and reopened existing sessions. Root fix: give agent-host vendors the same "empty results are transient" grace already applied to Copilot in isLanguageModelVendorAbsenceConclusive, so a remembered/restored model resolves as `pending` (wait) rather than `unavailable` (give up) while the agent-host model pool is still loading. This fixes every restore surface at once (initialize, conversation-restore and their model-change waiters) and naturally survives the transient empty resolutions emitted before the real models arrive. Additional hardening: - loadRemoteSession: carry the session's known model (with metadata) into the restored input state when the live lookup is cold, including through a transferred draft, instead of dropping it to undefined. - syncFromConversationState: keep waiting through transient empty agent-host resolutions; only fall back once the pool has loaded without the model. Adds regression tests (verified to fail if the root fix is reverted). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address review: scope agent-host grace to resolution; revert redundant wait Addresses two review comments on #326938: - Relocate the agent-host "empty is transient" grace out of the shared isLanguageModelVendorAbsenceConclusive (reverted to original) into resolveModelIdentifierFromCatalog, so it applies to model-restore resolution only and no longer relaxes cache-retention (mergeModelsWithCache) or send-availability. A resolved-empty agent-host vendor's cache is evicted again (authoritative), so stale/removed models aren't offered. The grace is guarded so it only softens the empty-pool case; once the vendor has live models an absent model stays conclusive. - Revert the syncFromConversationState wait change: with the resolution grace, agent-host cold-empty already resolves as `pending`, so the extra `|| !hasModelsTargetingSession(...)` branch was redundant and would arm a never-firing wait for a non-agent-host contributed session whose provider resolved to an authoritatively-empty pool (fallback would never run). Tests: resolution/restore tests now route through the real catalog resolver and are verified to fail if the grace is removed; a new mergeModelsWithCache test locks in that a resolved-empty agent-host cache is evicted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * review: reuse canonical isAgentHostTarget instead of a bespoke helper Replace the local isAgentHostVendor helper (in languageModels.ts) with the existing isAgentHostTarget from chatSessionsService.ts, used directly in resolveModelIdentifierFromCatalog. The agent-host model vendor id equals the session type, so isAgentHostTarget classifies it correctly. This reverts languageModels.ts back to its original form (the PR no longer touches it) and avoids duplicating agent-host prefix detection. No import cycle: modelSelection.ts is only consumed by browser-layer input files, so it is unreachable from chatSessionsService's common-only dependency graph, and isAgentHostTarget is a runtime function (not a load-time constant). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/chatService/chatServiceImpl.ts | 39 ++++- .../contrib/chat/common/modelSelection.ts | 15 +- .../widget/input/chatInputModelUtils.test.ts | 18 +++ .../input/chatModelSelectionModel.test.ts | 139 +++++++++++++++++- .../common/chatService/chatService.test.ts | 40 ++++- .../chat/test/common/modelSelection.test.ts | 26 ++++ 6 files changed, 269 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 1d4c5806137e11..ebd9b544a47531 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -149,6 +149,27 @@ export function backfillRestoredPickerState( return { ...stateToApply, mode }; } +/** + * Recover the selected model on a restored input state when it was dropped during a cold + * handoff/restore. + * + * At cold restore an agent-host transferred draft can arrive without its `selectedModel` (the live + * model list is not loaded yet, so the model resolved to `undefined`). Fall back to the model + * derived from the session's request history so the picker restores the last-used model instead of + * Auto. The history-derived model carries full metadata (including `targetChatSessionType`), so the + * input part can wait for the model pool and apply it once it loads. An explicit model already + * present on `stateToApply` is never overridden. + */ +export function backfillRestoredModel( + stateToApply: ISerializableChatModelInputState | undefined, + historyModel: ISerializableChatModelInputState['selectedModel'], +): ISerializableChatModelInputState | undefined { + if (!stateToApply || stateToApply.selectedModel || !historyModel) { + return stateToApply; + } + return { ...stateToApply, selectedModel: historyModel }; +} + export class ChatService extends Disposable implements IChatService { declare _serviceBrand: undefined; @@ -685,7 +706,18 @@ export class ChatService extends Disposable implements IChatService { const modelConfiguration = storedInputState?.selectedModel?.identifier === modelId ? storedModelConfiguration : undefined; - const selectedModel: ISerializableChatModelInputState['selectedModel'] = modelId && modelMetadata ? { identifier: modelId, metadata: modelMetadata, modelConfiguration } : undefined; + // When the live model list has not loaded yet (cold restore) `lookupLanguageModel` + // returns undefined. Don't discard the known model: fall back to the session's saved + // draft model, which carries the full serialized metadata (including + // `targetChatSessionType`), when it refers to the same id the request history reports + // as last used. Handing the input part a model-with-metadata lets it wait for the + // model pool and apply it once it loads, instead of falling back to Auto. + const storedSelectedModel = storedInputState?.selectedModel; + const selectedModel: ISerializableChatModelInputState['selectedModel'] = modelId && modelMetadata + ? { identifier: modelId, metadata: modelMetadata, modelConfiguration } + : (modelId && storedSelectedModel && storedSelectedModel.identifier === modelId + ? { ...storedSelectedModel, modelConfiguration } + : undefined); historySelectedModel = selectedModel?.identifier; historyDerivedModel = selectedModel; // This is used to initialize the state of the chat input box, with the selected model, mode, etc @@ -729,8 +761,9 @@ export class ChatService extends Disposable implements IChatService { : undefined; // At cold restore the agent-host transferred draft can drop the user's per-session picker // selections (model/mode); restore them from the session's own saved `storedInputState` - // (see {@link backfillRestoredPickerState}). - const stateToApply = providedSession.transferredState?.inputState ?? restoredDraft; + // (mode, via {@link backfillRestoredPickerState}) and from the history-derived model + // (via {@link backfillRestoredModel}). + const stateToApply = backfillRestoredModel(providedSession.transferredState?.inputState ?? restoredDraft, historyDerivedModel); const inputState = backfillRestoredPickerState(stateToApply, storedInputState, ChatMode.Agent.id); const modelRef = this._sessionModels.acquireOrCreate({ initialData, diff --git a/src/vs/workbench/contrib/chat/common/modelSelection.ts b/src/vs/workbench/contrib/chat/common/modelSelection.ts index 887e7f00ddde39..86c2c222b74b07 100644 --- a/src/vs/workbench/contrib/chat/common/modelSelection.ts +++ b/src/vs/workbench/contrib/chat/common/modelSelection.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, isLanguageModelVendorAbsenceConclusive } from './languageModels.js'; +import { isAgentHostTarget } from './chatSessionsService.js'; export type ModelIdentifierResolution = | { readonly kind: 'notRequested' } @@ -48,11 +49,19 @@ export function resolveModelIdentifierFromCatalog( const separator = identifier.search(/[/:]/); const vendor = separator === -1 ? undefined : identifier.substring(0, separator); - const isAbsenceConclusive = !vendor || isLanguageModelVendorAbsenceConclusive( + const hasLive = vendor ? vendorResolution.hasLiveModels(vendor) : false; + // Agent-host vendors publish their models asynchronously after the agent host connects, so an + // empty (not-yet-populated) list is transient: keep the remembered/restored model `pending` + // (wait) rather than `unavailable` (give up). Once the vendor HAS live models, an absent model + // is genuinely gone, so stay conclusive. This grace is scoped to restore *resolution* only — + // cache-retention (`mergeModelsWithCache`) and send-availability keep treating a resolved-empty + // list as authoritative. The vendor id equals the session type for agent-host models, so + // `isAgentHostTarget` classifies it directly. + const isAbsenceConclusive = !vendor || (isLanguageModelVendorAbsenceConclusive( vendor, - vendorResolution.hasLiveModels(vendor), + hasLive, vendorResolution.hasResolved(vendor), - ); + ) && (hasLive || !isAgentHostTarget(vendor))); return resolveModelIdentifier(models, identifier, isAbsenceConclusive); } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputModelUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputModelUtils.test.ts index e78be665d74a09..925c21e4504cff 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputModelUtils.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputModelUtils.test.ts @@ -746,6 +746,24 @@ suite('ChatInputModelUtils', () => { ); assert.deepStrictEqual(result.map(m => m.metadata.id).sort(), ['a-model', 'b-model']); }); + + test('evicts cached agent-host entries when the vendor is resolved with zero live models', () => { + // The agent-host "empty is transient" grace is scoped to restore *resolution* only + // (resolveModelIdentifierFromCatalog); it must NOT relax cache-retention. A resolved + // agent-host vendor with no live models is authoritative here, so its cache is evicted + // like any other vendor — otherwise a removed/unentitled agent-host model could be + // offered from cache (and the input's "no models"/send-blocked state would be masked). + const liveCopilot = createModel('gpt', 'GPT'); + const staleAgentHost = createVendorModel('agent-host-copilotcli', 'gpt-5.6-sol', 'GPT 5.6 Sol'); + const result = mergeModelsWithCache( + [liveCopilot], + [staleAgentHost], + new Set(['copilot', 'agent-host-copilotcli']), + new Set(['copilot', 'agent-host-copilotcli']), + ); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].metadata.vendor, 'copilot'); + }); }); suite('model switching scenarios', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts index e4d21f74aabf04..fa2f26b931cbc0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../ba import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../common/constants.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; -import { ModelSelectionReason, resolveModelIdentifier } from '../../../../common/modelSelection.js'; +import { ModelSelectionReason, resolveModelIdentifier, resolveModelIdentifierFromCatalog } from '../../../../common/modelSelection.js'; import { ChatInputModelSelectionController, IChatInputModelSelectionRuntime } from '../../../../browser/widget/input/chatInputModelSelectionController.js'; import { ChatModelSelectionModel } from '../../../../browser/widget/input/chatModelSelectionModel.js'; @@ -574,6 +574,143 @@ suite('ChatModelSelectionModel', () => { }); }); + test('syncFromConversationState waits through a resolved-but-empty agent-host pool and restores the model', () => { + // Cold-restart race: the agent-host vendor is registered ("resolved") but its models arrive + // later. Routed through the real catalog resolver, the agent-host grace keeps the absent + // model `pending` (not `unavailable`), so the restore waits through the intermediate empty + // re-resolutions and applies the model once the pool loads — instead of defaulting to Auto. + // (If the grace in resolveModelIdentifierFromCatalog is removed, resolution is `unavailable`, + // no wait is armed, and this test fails.) + const selection = new ChatModelSelectionModel(); + const sessionType = 'agent-host-copilotcli'; + const base = targetedModel('agent-host-copilotcli:gpt-5.6-sol', sessionType); + const desired = { ...base, metadata: { ...base.metadata, vendor: sessionType } }; + const modelChanges = disposables.add(new Emitter()); + let models: ILanguageModelChatMetadataAndIdentifier[] = []; + const applied: string[] = []; + const restored: { modelId: string; configuration: Record | undefined }[] = []; + const runtime: IChatInputModelSelectionRuntime = { + location: ChatAgentLocation.Chat, + getCurrentModeKind: () => ChatModeKind.Ask, + getCurrentSessionType: () => sessionType, + isEmpty: () => false, + getModels: () => models, + getAllModels: () => models, + requiresCustomModels: () => true, + getConfiguredModelValue: () => undefined, + // Faithful to production: vendor is resolved but publishes models asynchronously. + resolveModelIdentifier: identifier => resolveModelIdentifierFromCatalog(models, identifier, { + hasLiveModels: vendor => models.some(m => m.metadata.vendor === vendor), + hasResolved: () => true, + }), + subscribeToModelChanges: listener => modelChanges.event(listener), + getBoundConversationKey: () => 'chat:one', + getVisibleConversationKey: () => 'chat:one', + restoreModelConfiguration: (modelId, configuration) => restored.push({ modelId, configuration }), + applyModel: selected => { + applied.push(selected.identifier); + selection.setCurrentModel(selected, false); + }, + }; + const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + + controller.syncFromConversationState(desired, { effort: 'high' }, sessionType, 'chat:one'); + const pending = controller.hasAuthoritativeModelWait(); + // An intermediate empty re-resolution must not end the wait or apply a default. + modelChanges.fire('still-empty'); + const stillPendingAfterEmpty = controller.hasAuthoritativeModelWait(); + const appliedAfterEmpty = [...applied]; + // The real models finally arrive. + models = [desired]; + modelChanges.fire('loaded'); + + assert.deepStrictEqual({ + pending, + stillPendingAfterEmpty, + appliedAfterEmpty, + pendingAfterLoad: controller.hasAuthoritativeModelWait(), + applied, + restored, + }, { + pending: true, + stillPendingAfterEmpty: true, + appliedAfterEmpty: [], + pendingAfterLoad: false, + applied: [desired.identifier], + restored: [{ modelId: desired.identifier, configuration: { effort: 'high' } }], + }); + }); + + test('initialize waits for a resolved-but-empty agent-host pool and restores the remembered model', () => { + // Root-fix regression test (Option 1). A NEW/untitled agent-host session restores its + // remembered model via `initialize`. At cold start the agent-host vendor is registered + // ("resolved") but its models have not arrived yet. Using the real catalog resolver, the + // agent-host "empty is transient" grace must make the remembered model resolve as `pending` + // (not `unavailable`) so `initialize` waits for the pool and applies the model on load, + // instead of returning `none` and leaving the picker on Auto. + // + // If the fix in `isLanguageModelVendorAbsenceConclusive` is reverted, the resolution becomes + // `unavailable`, `initialize` gets `none`, no wait is armed, and nothing is applied — this + // test then fails on `pendingAfterInit`/`applied`/`current`. + const selection = new ChatModelSelectionModel(); + const sessionType = 'agent-host-copilotcli'; + const remembered = targetedModel('agent-host-copilotcli:gpt-5.6-sol', sessionType); + const modelChanges = disposables.add(new Emitter()); + let models: ILanguageModelChatMetadataAndIdentifier[] = []; + const applied: string[] = []; + const runtime: IChatInputModelSelectionRuntime = { + location: ChatAgentLocation.Chat, + getCurrentModeKind: () => ChatModeKind.Ask, + getCurrentSessionType: () => sessionType, + isEmpty: () => true, + getModels: () => models, + getAllModels: () => models, + requiresCustomModels: () => true, + getConfiguredModelValue: () => undefined, + // Faithful to production wiring: the vendor is resolved but publishes models + // asynchronously, so route through the catalog resolver that applies the grace. + resolveModelIdentifier: identifier => resolveModelIdentifierFromCatalog(models, identifier, { + hasLiveModels: vendor => models.some(m => m.metadata.vendor === vendor), + hasResolved: () => true, + }), + subscribeToModelChanges: listener => modelChanges.event(listener), + getBoundConversationKey: () => 'chat:one', + getVisibleConversationKey: () => 'chat:one', + restoreModelConfiguration: () => { }, + applyModel: selected => { + applied.push(selected.identifier); + selection.setCurrentModel(selected, false); + }, + }; + const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + + controller.initialize(remembered.identifier, () => { }); + const pendingAfterInit = controller.hasAuthoritativeModelWait(); + const appliedAfterInit = [...applied]; + // An intermediate empty re-resolution must not end the wait or apply a default. + modelChanges.fire('still-empty'); + const pendingAfterEmpty = controller.hasAuthoritativeModelWait(); + // The agent-host pool finally publishes its models. + models = [remembered]; + modelChanges.fire('loaded'); + + assert.deepStrictEqual({ + pendingAfterInit, + appliedAfterInit, + pendingAfterEmpty, + pendingAfterLoad: controller.hasAuthoritativeModelWait(), + applied, + current: selection.currentModel.get()?.identifier, + }, { + pendingAfterInit: true, + appliedAfterInit: [], + pendingAfterEmpty: true, + pendingAfterLoad: false, + applied: [remembered.identifier], + current: remembered.identifier, + }); + }); + test('late best-match restore remains authoritative after configured-model refresh', () => { const selection = new ChatModelSelectionModel(); const modelChanges = disposables.add(new Emitter()); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 2b5fc8d47aa10d..9b7962ad7147aa 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -46,7 +46,7 @@ import { IChatVariablesService } from '../../../common/attachments/chatVariables import { IChatDebugService } from '../../../common/chatDebugService.js'; import { ChatDebugServiceImpl } from '../../../common/chatDebugServiceImpl.js'; import { ChatRequestQueueKind, ChatSendResult, IChatFollowup, IChatModelReference, IChatProgress, IChatService, ResponseModelState } from '../../../common/chatService/chatService.js'; -import { backfillRestoredPickerState, ChatService } from '../../../common/chatService/chatServiceImpl.js'; +import { backfillRestoredModel, backfillRestoredPickerState, ChatService } from '../../../common/chatService/chatServiceImpl.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../common/languageModels.js'; @@ -2653,6 +2653,44 @@ suite('backfillRestoredPickerState', () => { }); }); +suite('backfillRestoredModel', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const AGENT = 'agent'; + const model = (identifier: string): ISerializableChatModelInputState['selectedModel'] => ({ + identifier, + metadata: { + id: identifier, name: identifier, vendor: 'copilot', version: '1.0', family: 'test', + extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, + isDefaultForLocation: {} + } + }); + const state = (selectedModel: ISerializableChatModelInputState['selectedModel']): ISerializableChatModelInputState => ({ + attachments: [], mode: { id: AGENT, kind: ChatModeKind.Agent }, selectedModel, inputText: '', selections: [], contrib: {} + }); + + test('backfills the history model when the restored state dropped its model (cold restore)', () => { + const history = model('agent-host-copilotcli:gpt-5.6-sol'); + const result = backfillRestoredModel(state(undefined), history); + assert.strictEqual(result?.selectedModel?.identifier, 'agent-host-copilotcli:gpt-5.6-sol'); + }); + + test('never overrides a model already present on the restored state', () => { + const result = backfillRestoredModel(state(model('agent-host-copilotcli:gpt-5.6-terra')), model('agent-host-copilotcli:gpt-5.6-sol')); + assert.strictEqual(result?.selectedModel?.identifier, 'agent-host-copilotcli:gpt-5.6-terra'); + }); + + test('leaves the state unchanged when there is no history model', () => { + const chosen = state(undefined); + assert.strictEqual(backfillRestoredModel(chosen, undefined), chosen); + assert.strictEqual(chosen.selectedModel, undefined); + }); + + test('returns undefined state as-is', () => { + assert.strictEqual(backfillRestoredModel(undefined, model('agent-host-copilotcli:gpt-5.6-sol')), undefined); + }); +}); + function toSnapshotExportData(model: IChatModel) { const exp = model.toExport(); diff --git a/src/vs/workbench/contrib/chat/test/common/modelSelection.test.ts b/src/vs/workbench/contrib/chat/test/common/modelSelection.test.ts index 269b4d2daa39ed..00764f178327ff 100644 --- a/src/vs/workbench/contrib/chat/test/common/modelSelection.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/modelSelection.test.ts @@ -111,6 +111,32 @@ suite('ModelSelection', () => { }); }); + test('treats a resolved-but-empty agent-host vendor as still loading (pending)', () => { + // Agent-host vendors publish their models asynchronously after the agent host connects, so — + // like Copilot — an empty resolution during startup is transient (pending), not conclusive. + // This is the root fix for the "restored agent-host session shows Auto" bug: without it the + // absent model resolves as `unavailable`, and the restore gives up instead of waiting. + const resolvedVendors = new Set(['agent-host-copilotcli', 'remote-abc-copilotcli']); + const liveVendors = new Set(); + const vendorResolution = { + hasLiveModels: (vendor: string) => liveVendors.has(vendor), + hasResolved: (vendor: string) => resolvedVendors.has(vendor), + }; + const localDesired = 'agent-host-copilotcli:gpt-5.6-sol'; + const remoteDesired = 'remote-abc-copilotcli:gpt-5.6-sol'; + const emptyLocal = resolveModelIdentifierFromCatalog([], localDesired, vendorResolution); + const emptyRemote = resolveModelIdentifierFromCatalog([], remoteDesired, vendorResolution); + // Once the agent-host pool has published models (but not this one) the absence is conclusive. + liveVendors.add('agent-host-copilotcli'); + const loadedWithout = resolveModelIdentifierFromCatalog([], localDesired, vendorResolution); + + assert.deepStrictEqual({ emptyLocal, emptyRemote, loadedWithout }, { + emptyLocal: { kind: 'pending', identifier: localDesired }, + emptyRemote: { kind: 'pending', identifier: remoteDesired }, + loadedWithout: { kind: 'unavailable', identifier: localDesired }, + }); + }); + test('shares configured, desired, pending, then fallback precedence', () => { assert.deepStrictEqual([ resolveInitialModelSelection({ configuredModelValue: 'second', configuredModel: second, waitForConfiguredModel: true, desiredModelResolution: { kind: 'available', model: first }, desiredReason: ModelSelectionReason.Remembered, fallbackModel: first, fallbackReason: ModelSelectionReason.FirstAvailable }), From 079b81a429c0fb3512e1730fd04f373fe9a4ece3 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Wed, 22 Jul 2026 12:43:33 +0100 Subject: [PATCH 02/34] Style overrides: Update tab label font weight for consistency (#326937) * style: update tab label font weight for consistency * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: mrleemurray Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../contrib/styleOverrides/browser/media/tabs.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css index 75dd458aeb3aec..6cfefbf0f3704c 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css @@ -32,13 +32,18 @@ border-right: none !important; border-radius: var(--vscode-cornerRadius-small); font-size: var(--vscode-fontSize-body1) !important; - font-weight: var(--vscode-agents-fontWeight-semiBold); + font-weight: var(--vscode-fontWeight-regular); box-shadow: none !important; margin-right: var(--vscode-spacing-size40) !important; padding: 0 0 0 4px !important; --tab-border-top-color: transparent !important; } +.style-override .part.editor .tabs-container > .tab > .tab-label > .monaco-icon-label-container { + font-weight: var(--vscode-fontWeight-semiBold); +} + + .style-override .part.editor .tabs-container > .tab.sticky-compact { justify-content: center; padding: 0 !important; From 7ceb4a5584a107f6b8809431ae82812ddd041266 Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:44:42 +0200 Subject: [PATCH 03/34] Try fix macOS test flake (#326798) * Try fix macOS test flake * try again * Platform agnostic preseed --- .github/skills/flaky-smoke-tests/SKILL.md | 31 +++++++- .../src/areas/chat/chatModelConfig.test.ts | 4 +- .../smoke/src/areas/chat/chatSessions.test.ts | 77 ++++++++----------- test/smoke/src/utils.ts | 25 +++--- 4 files changed, 77 insertions(+), 60 deletions(-) diff --git a/.github/skills/flaky-smoke-tests/SKILL.md b/.github/skills/flaky-smoke-tests/SKILL.md index ab74f76fb1dce9..ca68c58cc26e57 100644 --- a/.github/skills/flaky-smoke-tests/SKILL.md +++ b/.github/skills/flaky-smoke-tests/SKILL.md @@ -29,6 +29,17 @@ iterations sequentially in one job. directory. The artifact is job-scoped, not iteration-scoped: `smoke-test-runner.log` can contain multiple iterations, and suite directories are not separated by iteration. +- Do not cancel a platform job while investigating if you still need its logs + artifact. Cancellation can skip the publish steps entirely, even when one or + more failed iteration task logs are already available. If a validation run + has become predictably red, either let the current job finish or queue a + single diagnostic iteration and let that job publish before iterating. +- Later iterations can overwrite or truncate files in the shared logs + directory. The final artifact may therefore contain the last iteration's + suite directory or runner log but not the failed iteration's detailed + diagnostics. Preserve the exact task log, and use a one-iteration diagnostic + run when extension-host, renderer, mock-server, screenshot, or trace evidence + from the failed attempt is required. - The exact iteration task log is authoritative for its failure summary and time range. Correlate that range with the job-level artifact. - Iterations can see persisted smoke-test state from earlier iterations, such @@ -138,8 +149,11 @@ Bash: jq -r '.value[]' task-log.json > task-log.txt ``` -The task log provides the concise Mocha failure, stack, active-view dump, and -the iteration's exact time range even before platform artifacts are published. +The task log provides the concise Mocha failure, stack, and the iteration's +exact time range even before platform artifacts are published. Diagnostics +written through the smoke runner's `Logger` (including +`dumpFailureDiagnostics`) may exist only in `smoke-test-runner.log`, so do not +assume they will be present in the Azure task log. ## 4. Download the Platform Logs Artifact @@ -165,6 +179,8 @@ az pipelines runs artifact download \ ``` Artifacts are available after the platform job publishes its outputs. +An absent platform artifact after a canceled run is expected; it is not +evidence that the artifact name was wrong. ## 5. Correlate the Failure @@ -225,6 +241,16 @@ Session-list text and active-view text answer different questions: iteration. - Reusing a fixed warm-up scenario marker can let a later warm-up wait match an earlier response and abandon the actually-running warm-up. +- Standard smoke suites created through `installAllHandlers` use distinct + randomized user-data directories. Do not attribute a setting from the + preceding suite to the failing suite without comparing the actual + `vscode-userdata:` or `--user-data-dir` paths in their logs. Iterations share + broader job state, but sibling suites normally do not share the same profile. +- A setup change can alter rather than fix the symptom. Re-check the exact + stack and the gesture/dispatch/request/rendering chain on every validation + run. For example, moving extension enablement before startup can eliminate an + editor-open timeout while activating the extension before test settings are + written, producing a later request or response timeout instead. Prefer assertions that establish identity: @@ -312,4 +338,3 @@ Summarize: - introducing commit, with why it is causal - fix commit - focused validation run ID, platforms, iterations, and current result - diff --git a/test/smoke/src/areas/chat/chatModelConfig.test.ts b/test/smoke/src/areas/chat/chatModelConfig.test.ts index 663a1dedd63514..991bd1d47e1250 100644 --- a/test/smoke/src/areas/chat/chatModelConfig.test.ts +++ b/test/smoke/src/areas/chat/chatModelConfig.test.ts @@ -197,13 +197,13 @@ export function setup(logger: Logger) { ...copilotEnv, }, }; - }, app => { + }, async app => { // Seed the migration storage key so the from-source built-in // copilot-chat stays enabled on the fresh per-run profile. Without // this, BuiltinChatExtensionEnablementMigration disables it (chat // setup is never "completed" in automation) and the first send fails // through chat-setup's install path before the warm-up retry recovers. - preseedChatExtensionEnablement(app.userDataPath); + await preseedChatExtensionEnablement(app.userDataPath); }); before(async function () { diff --git a/test/smoke/src/areas/chat/chatSessions.test.ts b/test/smoke/src/areas/chat/chatSessions.test.ts index a6140e9ed15a74..6133ed8a9ebf85 100644 --- a/test/smoke/src/areas/chat/chatSessions.test.ts +++ b/test/smoke/src/areas/chat/chatSessions.test.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; import { Application, Chat, Logger } from '../../../../automation'; -import { dumpFailureDiagnostics, getCopilotSmokeTestEnv, getMockLlmServerPath, installAllHandlers, MockLlmServer } from '../../utils'; +import { dumpFailureDiagnostics, getCopilotSmokeTestEnv, getMockLlmServerPath, installAllHandlers, MockLlmServer, preseedChatExtensionEnablement } from '../../utils'; import { runInTerminalScenario, shellEchoResponseMatcher, shellEchoScenario } from './shellScenarios'; /** @@ -58,6 +60,36 @@ const SHELL_SESSIONS: readonly ShellSessionConfig[] = [ { name: 'Local', command: 'smoketest.openLocalChat', kind: 'view', scenarioId: 'smoke-chat-sessions-local-terminal', reply: 'MOCKED_CHAT_SESSIONS_LOCAL_TERMINAL_RESPONSE', scenarioFactory: runInTerminalScenario }, ]; +/** + * Write all activation-sensitive settings before VS Code starts. Pre-seeding + * only the enablement migration can activate Copilot Chat before its mock + * endpoints are configured. + */ +async function preseedChatSessionProfile(userDataDir: string | undefined, mockServerUrl: string): Promise { + if (!userDataDir) { + throw new Error('Cannot pre-seed Chat Sessions profile without a user data directory'); + } + + const settingsPath = path.join(userDataDir, 'User', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ + 'github.copilot.advanced.debug.overrideProxyUrl': mockServerUrl, + 'github.copilot.advanced.debug.overrideCapiUrl': mockServerUrl, + 'github.copilot.advanced.debug.overrideAuthType': 'token', + 'chat.allowAnonymousAccess': true, + 'github.copilot.chat.githubMcpServer.enabled': false, + 'chat.mcp.discovery.enabled': false, + 'chat.mcp.enabled': false, + 'chat.disableAIFeatures': false, + 'github.copilot.chat.backgroundAgent.enabled': true, + 'github.copilot.chat.claudeAgent.enabled': true, + 'github.copilot.chat.claudeAgent.useSdkExtension': false, + 'chat.tools.riskAssessment.enabled': false, + }, undefined, '\t')); + + await preseedChatExtensionEnablement(userDataDir); +} + async function openSession(app: Application, session: { readonly command: string; readonly kind: 'editor' | 'view' }): Promise { await app.workbench.quickaccess.runCommand(session.command); if (session.kind === 'editor') { @@ -130,48 +162,7 @@ export function setup(logger: Logger) { ...copilotEnv, }, }; - }); - - before(async function () { - const app = this.app as Application; - logger.log(`[Chat Sessions] writing user settings (mock URL=${mockServer.url}); requestCount=${mockServer.requestCount()}`); - - // overrideProxyUrl/overrideCapiUrl redirect Copilot SDK + CAPI traffic - // to the mock server. allowAnonymousAccess skips the token-validation - // gate when there is no real GitHub session. The MCP/githubMcpServer - // settings prevent real-network MCP connections during the test. - await app.workbench.settingsEditor.addUserSettings([ - ['github.copilot.advanced.debug.overrideProxyUrl', JSON.stringify(mockServer.url)], - ['github.copilot.advanced.debug.overrideCapiUrl', JSON.stringify(mockServer.url)], - // Use token auth (not HMAC) so the CLI SDK can call /models and - // /models/session against the mock server without HMAC validation. - ['github.copilot.advanced.debug.overrideAuthType', '"token"'], - ['chat.allowAnonymousAccess', 'true'], - ['github.copilot.chat.githubMcpServer.enabled', 'false'], - ['chat.mcp.discovery.enabled', 'false'], - ['chat.mcp.enabled', 'false'], - // Pre-enable the chat session types that the tests open. Writing - // these here (directly to settings.json) instead of from the - // smoketest extension avoids racing with copilot-chat registering - // its configuration schema — the original cause of the Chat - // Sessions smoke test flake. - ['chat.disableAIFeatures', 'false'], - ['github.copilot.chat.backgroundAgent.enabled', 'true'], - ['github.copilot.chat.claudeAgent.enabled', 'true'], - // Force the bundled Claude Agent SDK (avoid the experiment that - // would route through the ms-vscode.vscode-claude-sdk extension, - // which would attempt a network install during the smoke run). - ['github.copilot.chat.claudeAgent.useSdkExtension', 'false'], - // Disable the LLM-generated tool risk assessment. It issues a - // separate model request whose mock reply ("OK") never resolves - // to a valid Safe/Caution/Review verdict, which would otherwise - // leave the terminal confirmation stuck in the "Assessing risk…" - // state so its "Allow" button never becomes available. The - // shell-tool tests need to click "Allow" to proceed. - ['chat.tools.riskAssessment.enabled', 'false'], - ]); - logger.log(`[Chat Sessions] user settings written; requestCount=${mockServer.requestCount()}`); - }); + }, app => preseedChatSessionProfile(app.userDataPath, mockServer.url)); after(async function () { await mockServer?.close(); diff --git a/test/smoke/src/utils.ts b/test/smoke/src/utils.ts index 4699fafa1d8ebf..fef631e97fdc75 100644 --- a/test/smoke/src/utils.ts +++ b/test/smoke/src/utils.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { execFileSync } from 'child_process'; +import * as sqlite3 from '@vscode/sqlite3'; import * as fs from 'fs'; import { Suite, Context } from 'mocha'; import { dirname, join } from 'path'; @@ -136,24 +136,25 @@ export function createApp(options: ApplicationOptions, optionsTransform?: (opts: * installed") and surfaces a "try again" dialog before the retry recovers. * * Mirrors the perf:chat harness (`scripts/chat-simulation/common/utils.js`). - * Requires the `sqlite3` CLI on PATH; best-effort, so a missing CLI just falls - * back to the (working) retry path. */ -export function preseedChatExtensionEnablement(userDataDir: string | undefined): void { +export async function preseedChatExtensionEnablement(userDataDir: string | undefined): Promise { if (!userDataDir) { return; } + + const globalStorageDir = join(userDataDir, 'User', 'globalStorage'); + fs.mkdirSync(globalStorageDir, { recursive: true }); + const dbPath = join(globalStorageDir, 'state.vscdb'); + const database = await new Promise((resolve, reject) => { + const instance = new sqlite3.Database(dbPath, error => error ? reject(error) : resolve(instance)); + }); try { - const globalStorageDir = join(userDataDir, 'User', 'globalStorage'); - fs.mkdirSync(globalStorageDir, { recursive: true }); - const dbPath = join(globalStorageDir, 'state.vscdb'); - const sql = [ + await new Promise((resolve, reject) => database.exec([ 'CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB);', 'INSERT INTO ItemTable (key, value) VALUES (\'builtinChatExtensionEnablementMigration\', \'true\');', - ].join(' '); - execFileSync('sqlite3', [dbPath, sql]); - } catch { - // best-effort: a missing `sqlite3` CLI just falls back to the retry path + ].join(' '), error => error ? reject(error) : resolve())); + } finally { + await new Promise((resolve, reject) => database.close(error => error ? reject(error) : resolve())); } } From 094c17c6c4e9a60ba204b9c0d52980ad9e21fb37 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Wed, 22 Jul 2026 12:45:53 +0100 Subject: [PATCH 04/34] style override: Add top spacing for progress container in non-merged header panes (#326950) style: add top spacing for progress container in non-merged header panes Co-authored-by: mrleemurray --- .../contrib/styleOverrides/browser/media/padding.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css index 8b76df452e7112..6ec9c97534d98d 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css @@ -233,3 +233,7 @@ .style-override.monaco-workbench .sidebar .has-composite-bar.header-or-footer .monaco-action-bar .actions-container { gap: 2px; } + +.style-override.monaco-workbench .monaco-pane-view .pane:not(.merged-header) .monaco-progress-container { + top: var(--vscode-spacing-size240); +} From 7a2320f2239faed30290a87a5c9aeb25f2970cdd Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:22:17 +0200 Subject: [PATCH 05/34] Agents - fix isolation mode regression (#326973) --- .../agentHost/node/agentSideEffects.ts | 9 +++ .../node/shared/worktreeIsolation.ts | 4 +- .../agentHost/test/node/agentService.test.ts | 80 ++++++++++++++++++- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 7953976ce20347..92f57d090b6697 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -1311,6 +1311,15 @@ export class AgentSideEffects extends Disposable { if (values) { this._persistSessionFlag(channel, 'configValues', JSON.stringify(values)); } + if (this._worktree && sessionState?.lifecycle === SessionLifecycle.Creating) { + const sessionId = AgentSession.id(channel); + const isolation = values?.[SessionConfigKey.Isolation]; + if (isolation === 'worktree') { + this._worktree.notePending(sessionId); + } else if (isolation === 'folder') { + this._worktree.clearPending(sessionId); + } + } // This case is reached only for client-dispatched config changes // (a user picker edit); internal server-side writes use // `dispatchServerAction` and never land here. So the provider can diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index 2a3e24a5aa4a6d..0355d21522d4c7 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -245,8 +245,8 @@ export class WorktreeIsolation extends Disposable { /** * Marks a fresh worktree-isolation session as pending — its worktree is - * deferred to the first send. Called by the host from `createSession` when - * the resolved session config selects `worktree` isolation. + * deferred to the first send. Called by the host while a creating session's + * resolved config selects `worktree` isolation. */ notePending(sessionId: string): void { this._pending.add(sessionId); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index dcaea071175262..e64fc633322499 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -22,7 +22,7 @@ import { hasKey } from '../../../../base/common/types.js'; import { NullLogService } from '../../../log/common/log.js'; import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IRestoredSubagentSession, SubagentChatSignal, type IAgent, type IAgentChatDataChange, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionResult, type IAgentLegacyChat, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agentService.js'; +import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IRestoredSubagentSession, SubagentChatSignal, type IAgent, type IAgentChatDataChange, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentLegacyChat, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; @@ -427,6 +427,84 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('reconciles pending worktree isolation when creating session config changes', async () => { + const gitService = createNoopGitService(); + const sessionDataService = createSessionDataService(new TestSessionDatabase()); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + const isolation = disposables.add(new WorktreeIsolation( + { generateBranchName: async () => 'agents/test' }, + gitService, + new TestCopilotApiService(), + sessionDataService, + new NullLogService(), + )); + localService.setWorktreeIsolation(isolation); + + class ProvisionalAgent extends MockAgent { + override async createSession(config?: IAgentCreateSessionConfig): Promise { + return { ...await super.createSession(config), provisional: true }; + } + } + + const provisionalAgent = new ProvisionalAgent('codex'); + const readyAgent = new MockAgent('copilot'); + disposables.add(toDisposable(() => provisionalAgent.dispose())); + disposables.add(toDisposable(() => readyAgent.dispose())); + localService.registerProvider(provisionalAgent); + localService.registerProvider(readyAgent); + + const creatingSession = await localService.createSession({ + provider: 'codex', + workingDirectory: URI.file('/workspace/repo'), + config: { [SessionConfigKey.Isolation]: 'folder' }, + }); + const readySession = await localService.createSession({ + provider: 'copilot', + workingDirectory: URI.file('/workspace/repo'), + config: { [SessionConfigKey.Isolation]: 'folder' }, + }); + const creatingInitially = localService.configurationService.isWorkingDirectoryPending(creatingSession.toString()); + const readyInitially = localService.configurationService.isWorkingDirectoryPending(readySession.toString()); + const creatingLifecycle = localService.stateManager.getSessionState(creatingSession.toString())?.lifecycle; + const readyLifecycle = localService.stateManager.getSessionState(readySession.toString())?.lifecycle; + + localService.dispatchAction(creatingSession.toString(), { + type: ActionType.SessionConfigChanged, + config: { [SessionConfigKey.Isolation]: 'worktree' }, + }, 'test-client', 1); + const creatingAfterWorktree = localService.configurationService.isWorkingDirectoryPending(creatingSession.toString()); + + localService.dispatchAction(creatingSession.toString(), { + type: ActionType.SessionConfigChanged, + config: { [SessionConfigKey.Isolation]: 'folder' }, + }, 'test-client', 2); + const creatingAfterFolder = localService.configurationService.isWorkingDirectoryPending(creatingSession.toString()); + + localService.dispatchAction(readySession.toString(), { + type: ActionType.SessionConfigChanged, + config: { [SessionConfigKey.Isolation]: 'worktree' }, + }, 'test-client', 3); + const readyAfterWorktree = localService.configurationService.isWorkingDirectoryPending(readySession.toString()); + + assert.deepStrictEqual({ + creatingInitially, + readyInitially, + creatingLifecycle, + readyLifecycle, + creatingAfterWorktree, + creatingAfterFolder, + readyAfterWorktree, + }, { + creatingInitially: false, + readyInitially: false, + creatingLifecycle: SessionLifecycle.Creating, + readyLifecycle: SessionLifecycle.Ready, + creatingAfterWorktree: true, + creatingAfterFolder: false, + readyAfterWorktree: false, + }); + }); + suite('resourceRead', () => { test('maps missing files to NotFound', async () => { From 7274ab518cee1b7dcf32c77c0baff0083932ff0b Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Jul 2026 10:23:55 -0400 Subject: [PATCH 06/34] Single chat mic: built-in on-device dictation wins over the speech extension (#326971) --- .../chat/browser/chat.shared.contribution.ts | 2 - .../redundantDictationExtensionNotifier.ts | 154 ------------------ .../actions/voiceChatActions.ts | 4 +- 3 files changed, 3 insertions(+), 157 deletions(-) delete mode 100644 src/vs/workbench/contrib/chat/browser/speechToText/redundantDictationExtensionNotifier.ts 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 69df191de7c9ce..0aa5800c95571b 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -103,7 +103,6 @@ import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js' import { registerChatExecuteActions } from './actions/chatExecuteActions.js'; import { registerChatSpeechToTextActions } from './actions/chatSpeechToTextActions.js'; import { ChatSpeechToTextService, IChatSpeechToTextService } from './speechToText/chatSpeechToTextService.js'; -import { RedundantDictationExtensionNotifier } from './speechToText/redundantDictationExtensionNotifier.js'; import { registerChatFileTreeActions } from './actions/chatFileTreeActions.js'; import { ChatGettingStartedContribution } from './actions/chatGettingStarted.js'; import { registerChatExportActions } from './actions/chatImportExport.js'; @@ -2744,7 +2743,6 @@ Registry.as(EditorExtensions.EditorFactory).registerEdit registerWorkbenchContribution2(CopilotTelemetryContribution.ID, CopilotTelemetryContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatSpeechToTextInitContribution.ID, ChatSpeechToTextInitContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(RedundantDictationExtensionNotifier.ID, RedundantDictationExtensionNotifier, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(ChatDebugResolverContribution.ID, ChatDebugResolverContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(PromptsDebugContribution.ID, PromptsDebugContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/redundantDictationExtensionNotifier.ts b/src/vs/workbench/contrib/chat/browser/speechToText/redundantDictationExtensionNotifier.ts deleted file mode 100644 index 9ba47ea04bf8cd..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/speechToText/redundantDictationExtensionNotifier.ts +++ /dev/null @@ -1,154 +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 { localize } from '../../../../../nls.js'; -import { Event } from '../../../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { ILocalTranscriptionService } from '../../../../../platform/localTranscription/common/localTranscription.js'; -import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { IWorkbenchContribution } from '../../../../common/contributions.js'; -import { EnablementState } from '../../../../services/extensionManagement/common/extensionManagement.js'; -import { IExtension, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; - -type RedundantDictationExtensionPromptEvent = { - action: string; -}; - -type RedundantDictationExtensionPromptClassification = { - action: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The user response to the prompt: shown, disable, keep, or dismissed.' }; - owner: 'meganrogge'; - comment: 'Tracks how users respond to the prompt suggesting they disable the redundant VS Code Speech extension once built-in dictation is available.'; -}; - -/** - * When on-device chat dictation is supported on the current platform, the - * third-party VS Code Speech extension is redundant. This contribution shows a - * one-time notification prompting the user to disable it, but only when: - * 1. the platform supports built-in dictation (`ILocalTranscriptionService.isSupported`, - * the same predicate that shows/hides the built-in mic button), and - * 2. the extension is installed and currently enabled. - * The prompt is shown at most once ever (guarded by an application-scoped storage flag). - */ -export class RedundantDictationExtensionNotifier extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.redundantDictationExtensionNotifier'; - - /** Identifier of the extension made redundant by built-in dictation. */ - private static readonly EXTENSION_ID = 'ms-vscode.vscode-speech'; - - /** Application-scoped flag so the prompt fires at most once per machine. */ - private static readonly STORAGE_KEY = 'chat.dictation.redundantExtensionPromptWasShown'; - - constructor( - @ILocalTranscriptionService private readonly localTranscriptionService: ILocalTranscriptionService, - @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, - @INotificationService private readonly notificationService: INotificationService, - @IStorageService private readonly storageService: IStorageService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @ILogService private readonly logService: ILogService, - @ITelemetryService private readonly telemetryService: ITelemetryService, - ) { - super(); - this.check(); - } - - private async check(): Promise { - // (1) Only on platforms where built-in dictation actually works. - if (!this.localTranscriptionService.isSupported) { - return; - } - - // (2) Only when built-in dictation is actually enabled, so we never suggest - // disabling a working extension before its replacement is turned on. - if (this.configurationService.getValue('chat.speechToText.enabled') !== true) { - return; - } - - // (3) Only ever prompt once. - if (this.storageService.getBoolean(RedundantDictationExtensionNotifier.STORAGE_KEY, StorageScope.APPLICATION, false)) { - return; - } - - // (4) Only when the extension is installed. - let extension: IExtension | undefined; - try { - const installed = await this.extensionsWorkbenchService.queryLocal(); - extension = installed.find(e => ExtensionIdentifier.equals(e.identifier.id, RedundantDictationExtensionNotifier.EXTENSION_ID)); - } catch (error) { - this.logService.error('[dictation] failed to query installed extensions for redundant dictation prompt', error); - return; - } - - if (!extension) { - return; - } - - // (5) Only when the extension is enabled in a state that `setEnablement` can disable. - // Environment-enabled extensions are excluded because `setEnablement` cannot disable - // them, so the prompt's primary action would be guaranteed to fail. - const isEnabled = - extension.enablementState === EnablementState.EnabledGlobally || - extension.enablementState === EnablementState.EnabledWorkspace; - if (!isEnabled) { - return; - } - - this.telemetryService.publicLog2('chat.dictation.redundantExtensionPrompt', { action: 'shown' }); - - const displayName = extension.displayName || extension.identifier.id; - const handle = this.notificationService.prompt( - Severity.Info, - localize('redundantDictationExtension', "VS Code now has built-in dictation. Disable the '{0}' extension to avoid conflicts?", displayName), - [ - { - label: localize('disableExtension', "Disable Extension"), - run: async () => { - this.markShown(); - this.telemetryService.publicLog2('chat.dictation.redundantExtensionPrompt', { action: 'disable' }); - try { - await this.extensionsWorkbenchService.setEnablement(extension!, EnablementState.DisabledGlobally); - } catch (error) { - this.logService.error('[dictation] failed to disable redundant dictation extension', error); - } - } - }, - { - label: localize('keepExtension', "Keep"), - run: () => { - this.markShown(); - this.telemetryService.publicLog2('chat.dictation.redundantExtensionPrompt', { action: 'keep' }); - } - } - ], - { - sticky: true, - onCancel: () => { - this.markShown(); - this.telemetryService.publicLog2('chat.dictation.redundantExtensionPrompt', { action: 'dismissed' }); - } - } - ); - - // Multi-window coordination: the flag is only recorded once the user responds, so - // every already-open window can show its own prompt. When any window records a - // response, close the prompts still open in the others so it is answered once. - const listeners = this._register(new DisposableStore()); - listeners.add(this.storageService.onDidChangeValue(StorageScope.APPLICATION, RedundantDictationExtensionNotifier.STORAGE_KEY, listeners)(() => { - if (this.storageService.getBoolean(RedundantDictationExtensionNotifier.STORAGE_KEY, StorageScope.APPLICATION, false)) { - handle.close(); - } - })); - listeners.add(Event.once(handle.onDidClose)(() => listeners.dispose())); - } - - private markShown(): void { - this.storageService.store(RedundantDictationExtensionNotifier.STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - } -} diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/voiceChatActions.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/voiceChatActions.ts index 1af6ac2b281ee2..01adde61b53a44 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/voiceChatActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/voiceChatActions.ts @@ -558,7 +558,8 @@ export class StartVoiceChatAction extends Action2 { when: ContextKeyExpr.and( FocusInChatInput, // scope this action to chat input fields only EditorContextKeys.focus.negate(), // do not steal the editor inline-chat keybinding - NOTEBOOK_EDITOR_FOCUSED.negate() // do not steal the notebook inline-chat keybinding + NOTEBOOK_EDITOR_FOCUSED.negate(), // do not steal the notebook inline-chat keybinding + ChatContextKeys.speechToTextConfigured.negate() // built-in on-device dictation wins: yield the keybinding when it's available so it does not collide with the built-in dictation keybinding ), primary: KeyMod.CtrlCmd | KeyCode.KeyI }, @@ -570,6 +571,7 @@ export class StartVoiceChatAction extends Action2 { ), menu: primaryVoiceActionMenu(ContextKeyExpr.and( HasSpeechProvider, + ChatContextKeys.speechToTextConfigured.negate(), // built-in on-device dictation wins: hide the extension mic when it's available so only one mic shows ScopedChatSynthesisInProgress.negate(), // hide when text to speech is in progress AnyScopedVoiceChatInProgress?.negate(), // hide when voice chat is in progress )) From d3951d44defda5682f6464662bb3ecec85dbdb2b Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 23 Jul 2026 00:26:10 +1000 Subject: [PATCH 07/34] Reliably restore the last-used chat model when it loads late (#326970) The workbench only re-checked for a remembered model when its vendor reported the empty pool as "pending". If the model instead resolved as "unavailable" (the pool was non-empty but did not yet include it), the input applied a fallback (Auto) and armed no wait, so the remembered model was lost when it loaded a moment later. Restructure `initialize` to arm a single catalog wait whenever a remembered model has not been honored yet (both the "pending" and the provisional-fallback cases), settling on the vendor-aware resolution so the wait cannot linger. Consolidate the three restore waits behind one `_watchModelChanges` helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chatInputModelSelectionController.ts | 130 ++++++++---- .../input/chatModelSelectionModel.test.ts | 197 ++++++++++++++++++ 2 files changed, 283 insertions(+), 44 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts index fb222b6849fe54..874816feefe490 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts @@ -117,6 +117,34 @@ export class ChatInputModelSelectionController extends Disposable { const selection = resolveSelection(); onInitialSelection(selection); this._reportInitialization(this._runtime.getConfiguredModelValue(), rememberedModelId, selection); + this._applyInitialSelection(selection); + + // The remembered model has not been honored yet when we only applied a provisional fallback + // (`FirstAvailable`) or are still waiting for the pool to load (`pending`). In both cases, + // watch the catalog and swap the remembered model in once it appears; a configured or + // otherwise-available model — or an explicit user pick — supersedes it. Routing both cases + // through one wait (instead of arming it only for `pending`) means a remembered model that + // lands *after* the first, possibly non-empty, catalog batch is no longer lost. + if (rememberedModelId && (selection.kind === 'pending' || (selection.kind === 'apply' && selection.reason === ModelSelectionReason.FirstAvailable))) { + const desiredIdentifier = rememberedModelId; + this._watchModelChanges(this._authoritativeModelWait, () => true, () => { + const lateSelection = resolveSelection(); + if (lateSelection.kind === 'apply' && lateSelection.reason !== ModelSelectionReason.FirstAvailable) { + this._provisionalModelId = undefined; + this._model.setSelectionReason(lateSelection.reason); + this._runtime.applyModel(lateSelection.model); + this.ensureCurrentModelSupported(); + return 'settled'; + } + // Keep waiting only while the remembered model's vendor still reports its absence as + // transient (`pending`); once it is conclusively gone (`unavailable`/`notRequested`) + // settle on the applied fallback so the wait cannot linger forever. + return this._runtime.resolveModelIdentifier(desiredIdentifier).kind === 'pending' ? 'waiting' : 'settled'; + }); + } + } + + private _applyInitialSelection(selection: InitialModelSelectionResult): void { if (selection.kind === 'apply') { this._model.setSelectionReason(selection.reason); this._provisionalModelId = selection.reason === ModelSelectionReason.FirstAvailable @@ -134,21 +162,32 @@ export class ChatInputModelSelectionController extends Disposable { : fallbackModel.identifier; this._runtime.applyModel(fallbackModel); } - this._authoritativeModelWait.value = this._runtime.subscribeToModelChanges(() => { - const lateSelection = resolveSelection(); - if (lateSelection.kind === 'apply') { - this.clearAuthoritativeModelWait(); - this._provisionalModelId = undefined; - this._model.setSelectionReason(lateSelection.reason); - this._runtime.applyModel(lateSelection.model); - this.ensureCurrentModelSupported(); - } else if (lateSelection.kind === 'none') { - this.clearAuthoritativeModelWait(); - } - }); } } + /** + * Subscribes to catalog changes and re-runs `reevaluate` on each one, tearing the subscription + * down as soon as it reports `'settled'` — or when `isRelevant()` becomes false (the bound/visible + * conversation moved on) or the controller is disposed. Shared by the restore waits so the + * subscription lifecycle and relevance guard live in one place; `reevaluate` is responsible for + * applying any model change and returning whether the wait is done. + */ + private _watchModelChanges( + wait: MutableDisposable, + isRelevant: () => boolean, + reevaluate: () => 'settled' | 'waiting', + ): void { + wait.value = this._runtime.subscribeToModelChanges(() => { + if (!isRelevant()) { + wait.clear(); + return; + } + if (reevaluate() === 'settled') { + wait.clear(); + } + }); + } + ensureCurrentModelSupported(): void { const currentModel = this._model.currentModel.get(); const sessionType = this._runtime.getCurrentSessionType(); @@ -271,28 +310,30 @@ export class ChatInputModelSelectionController extends Disposable { this._model.setSelectionReason(ModelSelectionReason.SessionRestore); this._runtime.applyModel(match); } else if (resolution.kind === 'pending' && shouldWaitForSessionModel(desiredModel, sessionType, allModels)) { - this._authoritativeModelWait.value = this._runtime.subscribeToModelChanges(() => { - if (this._runtime.getBoundConversationKey() !== conversationKey) { - this.clearAuthoritativeModelWait(); - return; - } - const lateResolution = this._runtime.resolveModelIdentifier(desiredModel.identifier); - if (lateResolution.kind === 'available') { - this.clearAuthoritativeModelWait(); - this._model.setSelectionReason(ModelSelectionReason.SessionRestore); - this._runtime.restoreModelConfiguration(desiredModel.identifier, modelConfiguration); - this._runtime.applyModel(lateResolution.model); - } else if (lateResolution.kind === 'unavailable') { - this.clearAuthoritativeModelWait(); - const lateMatch = findBestMatchingModel(desiredModel, this._runtime.getModels(sessionType)); - if (lateMatch) { + this._watchModelChanges( + this._authoritativeModelWait, + () => this._runtime.getBoundConversationKey() === conversationKey, + () => { + const lateResolution = this._runtime.resolveModelIdentifier(desiredModel.identifier); + if (lateResolution.kind === 'available') { this._model.setSelectionReason(ModelSelectionReason.SessionRestore); - this._runtime.applyModel(lateMatch); - } else { - this.selectDefault(sessionType); + this._runtime.restoreModelConfiguration(desiredModel.identifier, modelConfiguration); + this._runtime.applyModel(lateResolution.model); + return 'settled'; } - } - }); + if (lateResolution.kind === 'unavailable') { + const lateMatch = findBestMatchingModel(desiredModel, this._runtime.getModels(sessionType)); + if (lateMatch) { + this._model.setSelectionReason(ModelSelectionReason.SessionRestore); + this._runtime.applyModel(lateMatch); + } else { + this.selectDefault(sessionType); + } + return 'settled'; + } + return 'waiting'; + }, + ); } else { this.clearAuthoritativeModelWait(); this.selectDefault(sessionType); @@ -340,18 +381,19 @@ export class ChatInputModelSelectionController extends Disposable { this._runtime.applyModel(match); return; } - this._historyModelWait.value = this._runtime.subscribeToModelChanges(() => { - if (this._runtime.getVisibleConversationKey() !== conversationKey) { - this.clearHistoryModelWait(); - return; - } - const lateMatch = tryMatch(); - if (lateMatch) { - this.clearHistoryModelWait(); - this._model.setSelectionReason(ModelSelectionReason.SessionRestore); - this._runtime.applyModel(lateMatch); - } - }); + this._watchModelChanges( + this._historyModelWait, + () => this._runtime.getVisibleConversationKey() === conversationKey, + () => { + const lateMatch = tryMatch(); + if (lateMatch) { + this._model.setSelectionReason(ModelSelectionReason.SessionRestore); + this._runtime.applyModel(lateMatch); + return 'settled'; + } + return 'waiting'; + }, + ); } resolveDraftModel( diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts index fa2f26b931cbc0..0b5e78a33c83a1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts @@ -833,4 +833,201 @@ suite('ChatModelSelectionModel', () => { current: targeted.identifier, }); }); + + test('initialize waits for a conclusively-absent remembered model and swaps it in when it appears', () => { + // Grace-independent restore. With the simple (conclusive) resolver the remembered model is + // `unavailable` at cold start, so `initialize` applies a provisional fallback. The refactor + // watches the catalog from the provisional-fallback path too (not only the `pending` path), + // so when the remembered model shows up on a later change it is swapped in — instead of being + // lost. Reverting the `initialize` restructure (arming the wait only for `pending`) leaves no + // wait armed here, so `pendingAfterInit` is false and the remembered model is never applied. + const selection = new ChatModelSelectionModel(); + const modelChanges = disposables.add(new Emitter()); + const fallback = model('test/fallback'); + const remembered = model('test/remembered'); + let models = [fallback]; + const applied: string[] = []; + const runtime: IChatInputModelSelectionRuntime = { + location: ChatAgentLocation.Chat, + getCurrentModeKind: () => ChatModeKind.Ask, + getCurrentSessionType: () => undefined, + isEmpty: () => true, + getModels: () => models, + getAllModels: () => models, + requiresCustomModels: () => false, + getConfiguredModelValue: () => undefined, + resolveModelIdentifier: identifier => resolveModelIdentifier(models, identifier, true), + subscribeToModelChanges: listener => modelChanges.event(listener), + getBoundConversationKey: () => 'chat:one', + getVisibleConversationKey: () => 'chat:one', + restoreModelConfiguration: () => { }, + applyModel: selected => { + applied.push(selected.identifier); + selection.setCurrentModel(selected, false); + }, + }; + const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + + controller.initialize(remembered.identifier, () => { }); + const pendingAfterInit = controller.hasAuthoritativeModelWait(); + const provisional = controller.provisionalModelId; + models = [fallback, remembered]; + modelChanges.fire('loaded'); + + assert.deepStrictEqual({ + pendingAfterInit, + provisional, + pendingAfterLoad: controller.hasAuthoritativeModelWait(), + applied, + current: selection.currentModel.get()?.identifier, + }, { + pendingAfterInit: true, + provisional: fallback.identifier, + pendingAfterLoad: false, + applied: [fallback.identifier, remembered.identifier], + current: remembered.identifier, + }); + }); + + test('initialize stops waiting when the pool loads without the remembered model', () => { + // Termination guard: the wait must not linger. Once the remembered model is conclusively + // absent (the pool loaded with other models but not it), settle on the already-applied + // fallback and tear the subscription down — without re-applying the fallback. + const selection = new ChatModelSelectionModel(); + const modelChanges = disposables.add(new Emitter()); + const fallback = model('test/fallback'); + const other = model('test/other'); + const remembered = model('test/remembered'); + let models = [fallback]; + const applied: string[] = []; + const runtime: IChatInputModelSelectionRuntime = { + location: ChatAgentLocation.Chat, + getCurrentModeKind: () => ChatModeKind.Ask, + getCurrentSessionType: () => undefined, + isEmpty: () => true, + getModels: () => models, + getAllModels: () => models, + requiresCustomModels: () => false, + getConfiguredModelValue: () => undefined, + resolveModelIdentifier: identifier => resolveModelIdentifier(models, identifier, true), + subscribeToModelChanges: listener => modelChanges.event(listener), + getBoundConversationKey: () => 'chat:one', + getVisibleConversationKey: () => 'chat:one', + restoreModelConfiguration: () => { }, + applyModel: selected => { + applied.push(selected.identifier); + selection.setCurrentModel(selected, false); + }, + }; + const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + + controller.initialize(remembered.identifier, () => { }); + const pendingAfterInit = controller.hasAuthoritativeModelWait(); + models = [fallback, other]; + modelChanges.fire('loaded-without-remembered'); + + assert.deepStrictEqual({ + pendingAfterInit, + pendingAfterLoad: controller.hasAuthoritativeModelWait(), + applied, + current: selection.currentModel.get()?.identifier, + }, { + pendingAfterInit: true, + pendingAfterLoad: false, + applied: [fallback.identifier], + current: fallback.identifier, + }); + }); + + test('initialize does not arm a restore wait when there is nothing to wait for', () => { + // Guard against over-arming: no remembered model, or a remembered model that is already + // available, must not leave a catalog subscription armed. + const build = (rememberedId: string | undefined, models: ILanguageModelChatMetadataAndIdentifier[]) => { + const selection = new ChatModelSelectionModel(); + const applied: string[] = []; + const runtime: IChatInputModelSelectionRuntime = { + location: ChatAgentLocation.Chat, + getCurrentModeKind: () => ChatModeKind.Ask, + getCurrentSessionType: () => undefined, + isEmpty: () => true, + getModels: () => models, + getAllModels: () => models, + requiresCustomModels: () => false, + getConfiguredModelValue: () => undefined, + resolveModelIdentifier: identifier => resolveModelIdentifier(models, identifier, true), + subscribeToModelChanges: () => toDisposable(() => { }), + getBoundConversationKey: () => 'chat:one', + getVisibleConversationKey: () => 'chat:one', + restoreModelConfiguration: () => { }, + applyModel: selected => { + applied.push(selected.identifier); + selection.setCurrentModel(selected, false); + }, + }; + const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + controller.initialize(rememberedId, () => { }); + return controller.hasAuthoritativeModelWait(); + }; + const first = model('test/first'); + const remembered = model('test/remembered'); + + assert.deepStrictEqual({ + noRememberedModel: build(undefined, [first]), + rememberedAlreadyAvailable: build(remembered.identifier, [first, remembered]), + }, { + noRememberedModel: false, + rememberedAlreadyAvailable: false, + }); + }); + + test('an explicit selection cancels the initialize restore wait', () => { + // While the wait is armed, an explicit user pick must win permanently: the wait is cancelled + // and a later appearance of the remembered model does not override the explicit selection. + const selection = new ChatModelSelectionModel(); + const modelChanges = disposables.add(new Emitter()); + const fallback = model('test/fallback'); + const explicit = model('test/explicit'); + const remembered = model('test/remembered'); + let models = [fallback, explicit]; + const applied: string[] = []; + const runtime: IChatInputModelSelectionRuntime = { + location: ChatAgentLocation.Chat, + getCurrentModeKind: () => ChatModeKind.Ask, + getCurrentSessionType: () => undefined, + isEmpty: () => true, + getModels: () => models, + getAllModels: () => models, + requiresCustomModels: () => false, + getConfiguredModelValue: () => undefined, + resolveModelIdentifier: identifier => resolveModelIdentifier(models, identifier, true), + subscribeToModelChanges: listener => modelChanges.event(listener), + getBoundConversationKey: () => 'chat:one', + getVisibleConversationKey: () => 'chat:one', + restoreModelConfiguration: () => { }, + applyModel: selected => { + applied.push(selected.identifier); + selection.setCurrentModel(selected, false); + }, + }; + const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + + controller.initialize(remembered.identifier, () => { }); + const pendingAfterInit = controller.hasAuthoritativeModelWait(); + controller.applyExplicitSelection(explicit, undefined, 'chat:one', () => applied.push(explicit.identifier), false); + const pendingAfterExplicit = controller.hasAuthoritativeModelWait(); + models = [fallback, explicit, remembered]; + modelChanges.fire('loaded'); + + assert.deepStrictEqual({ + pendingAfterInit, + pendingAfterExplicit, + applied, + current: selection.currentModel.get()?.identifier, + }, { + pendingAfterInit: true, + pendingAfterExplicit: false, + applied: [fallback.identifier, explicit.identifier], + current: explicit.identifier, + }); + }); }); From 224b261da4f3ec47cc18e367ad21c1dfa975ae1f Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:45:54 -0700 Subject: [PATCH 08/34] Add `list_browser_pages` tool (#326976) --- .../singlefolder-tests/browser.tools.test.ts | 12 ++++ .../tools/browserToolHelpers.ts | 36 ++++++++++++ .../tools/browserTools.contribution.ts | 31 +++------- .../tools/listBrowserPagesTool.ts | 56 +++++++++++++++++++ 4 files changed, 112 insertions(+), 23 deletions(-) create mode 100644 src/vs/workbench/contrib/browserView/electron-browser/tools/listBrowserPagesTool.ts diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts index fb66c28d340f4c..def6d45fd1096f 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.tools.test.ts @@ -90,6 +90,18 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { assert.match(output, /Page ID:/, `Expected output to contain "Page ID:", got: ${output}`); }); + test('list_browser_pages returns pages opened through the browser tools', async function () { + this.timeout(60000); + + const openOutput = await invokeTool('open_browser_page', { url: 'about:blank', forceNew: true }); + const pageId = openOutput.match(/Page ID:\s*(\S+)/)?.[1]; + assert.ok(pageId, `Could not extract Page ID from: ${openOutput}`); + + const listOutput = await invokeTool('list_browser_pages', {}); + + assert.match(listOutput, new RegExp(`^- \\[${pageId}\\]`, 'm'), `Expected list output to contain page ID "${pageId}", got: ${listOutput}`); + }); + test('Open a page from the web', async function () { this.timeout(60000); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts index f5200e229699cb..db43522c51a1cf 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -71,6 +71,42 @@ export function formatBrowserEditorList(editorService: IEditorService, editors: }).join('\n'); } +export function getBrowserPagesContext( + editorService: IEditorService, + browserViewService: IBrowserViewWorkbenchService, + agentNetworkFilterService: IAgentNetworkFilterService, + options?: { + activeSessionId?: string; + canPromptUser?: boolean; + }, +): string | undefined { + const views = [...browserViewService.getContextualBrowserViews({ activeSessionId: options?.activeSessionId }).values()]; + const sharedViews = views.filter(view => view.model?.sharingState === BrowserViewSharingState.Shared); + const unsharedCount = views.length - sharedViews.length; + + if (sharedViews.length === 0 && unsharedCount === 0) { + return undefined; + } + + let value: string; + if (sharedViews.length > 0) { + value = 'The following browser pages are currently shared with you and can be interacted with using the browser tools:'; + value += '\n' + formatBrowserEditorList(editorService, sharedViews, { agentNetworkFilterService }); + } else { + value = 'No browser pages are currently shared with you.'; + } + + if (unsharedCount > 0) { + value += '\n\n'; + value += `${unsharedCount} ${unsharedCount === 1 ? 'page is' : 'pages are'} open but not shared.`; + value += options?.canPromptUser + ? `\nUse the 'open_browser_page' tool to open a new page or to help the user share an existing page.` + : `\nUse the 'open_browser_page' tool to open a new page.`; + } + + return value; +} + /** * Creates a markdown link to a browser page. */ diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts index 100be0bb9b862c..aef4f6bf39a921 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts @@ -14,12 +14,13 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { IChatContextService } from '../../../chat/browser/contextContrib/chatContextService.js'; import { IChatService } from '../../../chat/common/chatService/chatService.js'; import { ILanguageModelToolsService, ToolDataSource, ToolSet } from '../../../chat/common/tools/languageModelToolsService.js'; -import { BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../common/browserView.js'; -import { formatBrowserEditorList } from './browserToolHelpers.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; +import { getBrowserPagesContext } from './browserToolHelpers.js'; import { ClickBrowserTool, ClickBrowserToolData } from './clickBrowserTool.js'; import { DragElementTool, DragElementToolData } from './dragElementTool.js'; import { HandleDialogBrowserTool, HandleDialogBrowserToolData } from './handleDialogBrowserTool.js'; import { HoverElementTool, HoverElementToolData } from './hoverElementTool.js'; +import { ListBrowserPagesTool, ListBrowserPagesToolData } from './listBrowserPagesTool.js'; import { NavigateBrowserTool, NavigateBrowserToolData } from './navigateBrowserTool.js'; import { OpenBrowserTool, OpenBrowserToolData } from './openBrowserTool.js'; import { OpenBrowserToolNonAgentic, OpenBrowserToolNonAgenticData } from './openBrowserToolNonAgentic.js'; @@ -99,6 +100,9 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench this._toolsStore.add(this.toolsService.registerTool(RunPlaywrightCodeToolData, this.instantiationService.createInstance(RunPlaywrightCodeTool))); this._toolsStore.add(this.toolsService.registerTool(HandleDialogBrowserToolData, this.instantiationService.createInstance(HandleDialogBrowserTool))); + // Note: this is not currently exposed directly to models. It is mostly exposed so extensions can use it to provide model context via the API. + this._toolsStore.add(this.toolsService.registerTool(ListBrowserPagesToolData, this.instantiationService.createInstance(ListBrowserPagesTool))); + this._toolsStore.add(this._browserToolSet.addTool(OpenBrowserToolData)); this._toolsStore.add(this._browserToolSet.addTool(ReadBrowserToolData)); this._toolsStore.add(this._browserToolSet.addTool(ScreenshotBrowserToolData)); @@ -147,31 +151,12 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench } private _updateBrowserContext(): void { - const views = [...this.browserViewService.getContextualBrowserViews().values()]; - const sharedViews = views.filter(v => v.model?.sharingState === BrowserViewSharingState.Shared); - const unsharedCount = views.length - sharedViews.length; - - if (sharedViews.length === 0 && unsharedCount === 0) { + const value = getBrowserPagesContext(this.editorService, this.browserViewService, this.agentNetworkFilterService, { canPromptUser: true }); + if (!value) { this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, []); return; } - let value = ''; - if (sharedViews.length > 0) { - value = 'The following browser pages are currently shared with you and can be interacted with using the browser tools:'; - value += '\n' + formatBrowserEditorList(this.editorService, sharedViews, { agentNetworkFilterService: this.agentNetworkFilterService }); - } else { - value = 'No browser pages are currently shared with you.'; - } - - if (unsharedCount > 0) { - if (value) { - value += '\n\n'; - } - value += `${unsharedCount} ${unsharedCount === 1 ? 'page is' : 'pages are'} open but not shared.`; - value += `\nUse the 'open_browser_page' tool to open a new page or to help the user share an existing page.`; - } - this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, [{ handle: 0, label: localize('browserContext.label', "Browser Pages"), diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/listBrowserPagesTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/listBrowserPagesTool.ts new file mode 100644 index 00000000000000..421ceb00f8eb9e --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/listBrowserPagesTool.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { localize } from '../../../../../nls.js'; +import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { CountTokensCallback, IToolData, IToolImpl, IToolInvocation, IToolResult, ToolDataSource, ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; +import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; +import { getBrowserPagesContext } from './browserToolHelpers.js'; + +export const ListBrowserPagesToolData: IToolData = { + id: 'list_browser_pages', + displayName: localize('listBrowserPagesTool.displayName', 'List Browser Pages'), + userDescription: localize('listBrowserPagesTool.userDescription', 'List browser pages that are shared with the agent'), + modelDescription: 'Lists the browser pages that are currently shared with the agent.', + source: ToolDataSource.Internal, + + // Note: this tool has no toolReferenceName and cannot be referenced in prompts. + // It is not intended to be used by models directly since browser pages are supplied as context. + canBeReferencedInPrompt: false, + + inputSchema: { + type: 'object', + properties: {}, + }, +}; + +export class ListBrowserPagesTool implements IToolImpl { + constructor( + @IEditorService private readonly editorService: IEditorService, + @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, + @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, + ) { } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const activeSessionId = invocation.context?.sessionResource.toString(); + const value = getBrowserPagesContext( + this.editorService, + this.browserViewService, + this.agentNetworkFilterService, + { + activeSessionId, + canPromptUser: activeSessionId !== undefined, + }, + ); + return { + content: [{ + kind: 'text', + value: value ?? 'No browser pages are currently open.', + }], + }; + } +} From 45221aaa99781a8d1b823664999d01e91ee5e03d Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Wed, 22 Jul 2026 14:51:17 +0000 Subject: [PATCH 09/34] fix: memory leak in abstractRuntimeExtensionsEditor (#326890) --- .../extensions/browser/abstractRuntimeExtensionsEditor.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/extensions/browser/abstractRuntimeExtensionsEditor.ts b/src/vs/workbench/contrib/extensions/browser/abstractRuntimeExtensionsEditor.ts index d41d41c9c409e4..978f4e8f688e2c 100644 --- a/src/vs/workbench/contrib/extensions/browser/abstractRuntimeExtensionsEditor.ts +++ b/src/vs/workbench/contrib/extensions/browser/abstractRuntimeExtensionsEditor.ts @@ -439,10 +439,11 @@ export abstract class AbstractRuntimeExtensionsEditor extends EditorPane { disposeTemplate: (data: IRuntimeExtensionTemplateData): void => { data.disposables = dispose(data.disposables); + data.elementDisposables = dispose(data.elementDisposables); } }; - this._list = this._instantiationService.createInstance(WorkbenchList, + this._list = this._register(this._instantiationService.createInstance(WorkbenchList, 'RuntimeExtensions', parent, delegate, [renderer], { multipleSelectionSupport: false, @@ -459,7 +460,7 @@ export abstract class AbstractRuntimeExtensionsEditor extends EditorPane { return element.description.name; } } - }); + })); this._list.splice(0, this._list.length, this._elements || undefined); From dd299c41b1aeb5843d20567862dc710ad4f25d4f Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Wed, 22 Jul 2026 14:51:39 +0000 Subject: [PATCH 10/34] fix: memory leak in abstractTaskService (#326934) fix: dispose task problem reporter listener --- src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts index 36209197e9259b..18311c1cd1da54 100644 --- a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts +++ b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts @@ -2633,8 +2633,9 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer await ProblemMatcherRegistry.onReady(); const taskSystemInfo: ITaskSystemInfo | undefined = this._getTaskSystemInfo(workspaceFolder.uri.scheme); const problemReporter = new ProblemReporter(this._outputChannel); - this._register(problemReporter.onDidError(error => this._showOutput(runSource, undefined, error))); + const problemReporterListener = problemReporter.onDidError(error => this._showOutput(runSource, undefined, error)); const parseResult = TaskConfig.parse(workspaceFolder, undefined, taskSystemInfo ? taskSystemInfo.platform : Platform.platform, workspaceFolderConfiguration.config, problemReporter, TaskConfig.TaskConfigSource.TasksJson, this._contextKeyService); + problemReporterListener.dispose(); let hasErrors = false; if (!parseResult.validationStatus.isOK() && (parseResult.validationStatus.state !== ValidationState.Info)) { hasErrors = true; From 53ebd210b71bc021b27daef642f36445f1cf1c47 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 22 Jul 2026 08:19:47 -0700 Subject: [PATCH 11/34] Adopt AHP 0.7.0 workingDirectories + primaryWorkingDirectory in agent host (#326847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adopt AHP 0.6.0 workingDirectories property in agent host Sync the generated agent host protocol copy to spec version 0.6.0 (agent-host-protocol @ 11f1a65e), which renames the singular `workingDirectory` on session/chat state to a `workingDirectories` array in preparation for multiroot session support. This change is a pure, behaviour-preserving property adoption across all consumers: sessions continue to use a single working directory. Reads of a single directory now use `workingDirectories?.[0]`, field-copy sites pass the array through unchanged, and writes from a single URI produce a one-element array. No multiroot client support is added here (no capability advertising, state actions, multi-folder workspaces, or UI); those land in a follow-up milestone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback - Reject the not-yet-supported multiroot working-directory client actions (session|chat/workingDirectorySet|Removed) in the handwritten dispatch path so a client cannot mutate the synchronized working-directory set without the agent actually reconfiguring its directory access. The protocol declarations remain; only the operational dispatch is deferred until multiroot lands. - Compare workingDirectories by array identity (not just the primary entry) in the state manager summary-equality check, matching the immutable reducers and SessionSummaryNotifier so secondary-directory changes still dirty the summary. - Update ISessionWithDefaultChat / mergeSessionWithDefaultChat API docs to describe a chat's workingDirectories subset overriding or inheriting the session's full set, fixing links to the removed singular members. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix agentHost session-filter test for workingDirectories migration The `sessionAdded notification filters out sessions outside the workspace` test constructed session summaries with the removed singular `workingDirectory` field, so the production filter (which now reads `workingDirectories[0]`) saw no directory and dropped the in-workspace session. Update the two directly-built summaries to the `workingDirectories` array form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Migrate test createSession wire calls to workingDirectories The createSession dispatch handler now reads the working directory from the renamed `CreateSessionParams.workingDirectories` array (AHP 0.6.0), but several node integration / e2e test call sites still sent the removed singular `workingDirectory` field. Since the field is silently ignored, sessions were created without a working directory and fell back to the default chats folder, failing the Agent Host E2E workspace/fileOperations/hostFeatures suites across all providers (wrong cwd cascades into file completions, renames, worktree resolution, and cd-prefix stripping). Send `workingDirectories: [dir]` from the shared `createProviderSession` helper and the remaining direct wire call sites so the requested directory reaches the session state again. The real VS Code client already sent the array form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Adopt AHP 0.7.0 primaryWorkingDirectory + requiresPrimary Re-sync the generated protocol copy to the multiroot spec at 148c716b (spec version 0.7.0) and adopt the two follow-up changes: - Capability flag renamed `MultipleWorkingDirectoriesCapability.immutablePrimary` -> `requiresPrimary`, with the new "agent needs one directory designated as its primary root" semantics. No consumers referenced the old name. - New optional `primaryWorkingDirectory` field at both the session level (CreateSessionParams / SessionMetadata -> SessionState + SessionSummary) and the chat level (CreateChatParams / ChatState + ChatSummary). Mirror it through the state<->summary projection layer exactly like `workingDirectories`: createSessionState / createChatState / chatSummaryFromState / mergeSessionWithDefaultChat, plus the state manager's summary projection, field-equality check, SessionSummaryNotifier diff, and markSessionPersisted propagation. The generated SessionChatUpdated partial-summary merge is field-agnostic, so it carries the new field automatically. Purely additive optional fields; no client multiroot behavior is added (consumers still read `workingDirectories[0]` as the single effective root). Also preserve the VS Code-local `CompletionItem.label` field (added in #326807, ahead of the spec) which the verbatim re-sync would otherwise drop. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Reject unsupported working-directory actions via reconciliation path Address PR review feedback on the multiroot working-directory action gate: - Instead of silently dropping the four not-yet-supported session|chat/workingDirectorySet|Removed client actions (which never echoed the origin, leaving the client's optimistic write-ahead action pending until reconnect), emit a rejection envelope through the normal reconciliation path. Added AgentHostStateManager.rejectClientAction, which emits an ActionEnvelope carrying the original ActionOrigin and a rejectionReason without running the reducer (no synchronized state change), so the originating client rolls back its optimistic action. - Guard the write-ahead client reconcile (SessionStateSubscription / ChatStateSubscription) so a rejected envelope is never applied to confirmed state in any branch — this also prevents a broadcast rejection from leaking the rejected action into a non-origin client's state. - Add a table-driven test covering all four action types asserting no dispatch and exactly one rejection envelope preserving the original origin. - Simplify the create-session working-directory read to `URI.parse(params.workingDirectories[0])` now the branch proves index 0 exists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refine primaryWorkingDirectory to per-chat read-only (AHP ea279d99) Re-sync the protocol copy to spec ea279d99 (0.7.0) and adopt the refined primaryWorkingDirectory design: - Session has NO primary: `primaryWorkingDirectory` is removed from SessionState / SessionSummary. The session is just the equal-peer `workingDirectories` set. Dropped all session-level primary handling (createSessionState, the SessionSummaryNotifier diff, _toSummary, _summaryFieldsEqual, and markSessionPersisted propagation). - Primary is per-chat, read-only, fixed at chat creation: kept the ChatState <-> ChatSummary mirroring (createChatState / chatSummaryFromState) and carry the chat's own primary through the session+default-chat composite (ISessionWithDefaultChat gains its own primaryWorkingDirectory; the merge no longer falls back to a session primary). It is not sent via `session/chatUpdated` (the state manager only ever puts status/activity/title in those changes), so it never mutates post-creation. - Inputs `CreateSessionParams.primaryWorkingDirectory` (seeds the default chat's primary) and `CreateChatParams.primaryWorkingDirectory` are synced; capability `requiresPrimary` unchanged. Also re-preserve the VS Code-local `CompletionItem.label` field (#326807, ahead of the spec) that the verbatim re-sync drops. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/remoteAgentHostProtocolClient.ts | 4 +- .../common/state/agentSubscription.ts | 28 +++++----- .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/action-origin.generated.ts | 14 ++++- .../state/protocol/channels-chat/actions.ts | 43 +++++++++++++++- .../state/protocol/channels-chat/commands.ts | 23 +++++++++ .../state/protocol/channels-chat/reducer.ts | 24 +++++++++ .../state/protocol/channels-chat/state.ts | 47 ++++++++++++----- .../state/protocol/channels-root/state.ts | 34 +++++++++++++ .../protocol/channels-session/actions.ts | 42 +++++++++++++++ .../protocol/channels-session/commands.ts | 33 +++++++++++- .../protocol/channels-session/reducer.ts | 24 +++++++++ .../state/protocol/channels-session/state.ts | 22 ++++---- .../common/state/protocol/common/actions.ts | 12 ++++- .../common/state/protocol/common/commands.ts | 2 +- .../common/state/protocol/version/registry.ts | 7 ++- .../agentHost/common/state/sessionState.ts | 51 +++++++++++-------- .../node/agentConfigurationService.ts | 4 +- .../node/agentHostChangesetService.ts | 6 +-- .../node/agentHostCommitOperationHandler.ts | 2 +- ...agentHostDiscardChangesOperationHandler.ts | 2 +- .../node/agentHostFileCompletionProvider.ts | 2 +- .../node/agentHostGitStateService.ts | 2 +- .../agentHostPullRequestOperationHandler.ts | 2 +- .../agentHost/node/agentHostReviewService.ts | 6 +-- .../agentHost/node/agentHostStateManager.ts | 28 ++++++++-- .../node/agentHostSyncOperationHandler.ts | 2 +- .../platform/agentHost/node/agentService.ts | 23 +++++---- .../node/localCommands/bangLocalCommand.ts | 2 +- .../agentHost/node/protocolServerHandler.ts | 34 ++++++++++++- .../node/agentConfigurationService.test.ts | 2 +- .../agentHostChangesetCoordinator.test.ts | 6 +-- .../node/agentHostChangesetService.test.ts | 26 +++++----- .../agentHostCommitOperationHandler.test.ts | 2 +- ...HostDiscardChangesOperationHandler.test.ts | 2 +- .../agentHostFileCompletionProvider.test.ts | 2 +- .../node/agentHostGitStateService.test.ts | 4 +- ...entHostPullRequestOperationHandler.test.ts | 2 +- .../test/node/agentHostStateManager.test.ts | 18 +++---- .../agentHost/test/node/agentService.test.ts | 8 +-- .../test/node/agentSideEffects.test.ts | 2 +- .../test/node/e2e/suites/workspaceSuite.ts | 15 +++--- .../protocol/sessionConfig.integrationTest.ts | 4 +- .../protocol/sessionDiffs.integrationTest.ts | 2 +- .../test/node/protocolServerHandler.test.ts | 42 ++++++++++++++- .../node/providerIntegrationTestHelpers.ts | 2 +- .../test/node/serverIntegrationTestHelpers.ts | 2 +- .../test/node/sessionPermissions.test.ts | 2 +- .../browser/baseAgentHostSessionsProvider.ts | 6 +-- .../localAgentHostSessionsProvider.test.ts | 2 +- .../remoteAgentHostSessionsProvider.test.ts | 2 +- .../agentHost/agentHostChatInputPicker.ts | 2 +- .../agentHostCustomizationService.ts | 2 +- .../agentHost/agentHostGenericConfigChips.ts | 2 +- .../agentHost/agentHostSessionHandler.ts | 2 +- .../agentHostSessionListController.ts | 2 +- .../agentHost/agentHostSessionListStore.ts | 6 +-- .../agentHostChatContribution.test.ts | 11 ++-- 58 files changed, 542 insertions(+), 165 deletions(-) diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 5dd2f87ae2c878..6a1496eda96b8f 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -818,7 +818,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC const promise = this._sendRequest('createSession', { channel: session.toString(), provider, - workingDirectory: config?.workingDirectory ? fromAgentHostUri(config.workingDirectory).toString() : undefined, + workingDirectories: config?.workingDirectory ? [fromAgentHostUri(config.workingDirectory).toString()] : undefined, config: config?.config, activeClient: config?.activeClient, }).then(() => session); @@ -962,7 +962,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC summary: s.title, status: s.status, activity: s.activity, - workingDirectory: typeof s.workingDirectory === 'string' ? toAgentHostUri(URI.parse(s.workingDirectory), this._connectionAuthority) : undefined, + workingDirectory: typeof s.workingDirectories?.[0] === 'string' ? toAgentHostUri(URI.parse(s.workingDirectories?.[0]), this._connectionAuthority) : undefined, isRead: !!(s.status & SessionStatus.IsRead), isArchived: !!(s.status & SessionStatus.IsArchived), changes: s.changes, diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index 95e40f34b75f4b..cdb52bc7bb4bbd 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -313,19 +313,21 @@ export class SessionStateSubscription extends BaseAgentSubscription p.clientSeq === envelope.origin!.clientSeq); if (idx !== -1) { - if (envelope.rejectionReason) { - this._pendingActions.splice(idx, 1); - } else { + if (!envelope.rejectionReason) { this._confirmedApply(envelope.action); - this._pendingActions.splice(idx, 1); } - } else { + this._pendingActions.splice(idx, 1); + } else if (!envelope.rejectionReason) { this._confirmedApply(envelope.action); } - } else { + } else if (!envelope.rejectionReason) { this._confirmedApply(envelope.action); } this._recomputeOptimistic(); @@ -457,19 +459,21 @@ export class ChatStateSubscription extends BaseAgentSubscription { } protected override _reconcile(envelope: ActionEnvelope, isOwnAction: boolean): void { + // A rejected envelope must never mutate confirmed state — it only rolls + // back the originating client's matching optimistic action. Guarding all + // apply branches also prevents a broadcast rejection from leaking the + // rejected action into a non-origin client's state. if (isOwnAction && envelope.origin) { const idx = this._pendingActions.findIndex(p => p.clientSeq === envelope.origin!.clientSeq); if (idx !== -1) { - if (envelope.rejectionReason) { - this._pendingActions.splice(idx, 1); - } else { + if (!envelope.rejectionReason) { this._confirmedApply(envelope.action); - this._pendingActions.splice(idx, 1); } - } else { + this._pendingActions.splice(idx, 1); + } else if (!envelope.rejectionReason) { this._confirmedApply(envelope.action); } - } else { + } else if (!envelope.rejectionReason) { this._promotePendingTurnStartIfTerminal(envelope.action); this._confirmedApply(envelope.action); } diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 20baede03d4a57..4076cdf14429b4 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -4e554d0e +ea279d99 diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index 96bafcddbcb679..f089bd62481c4a 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,7 +9,7 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatActivityChangedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatActivityChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction } from './actions.js'; // ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ───────────────── @@ -46,6 +46,8 @@ export type SessionAction = | SessionServerToolsChangedAction | SessionActiveClientSetAction | SessionActiveClientRemovedAction + | SessionWorkingDirectorySetAction + | SessionWorkingDirectoryRemovedAction | SessionInputNeededSetAction | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction @@ -68,6 +70,8 @@ export type ClientSessionAction = | SessionTitleChangedAction | SessionActiveClientSetAction | SessionActiveClientRemovedAction + | SessionWorkingDirectorySetAction + | SessionWorkingDirectoryRemovedAction | SessionCustomizationToggledAction | SessionMcpServerStartRequestedAction | SessionMcpServerStopRequestedAction @@ -114,6 +118,8 @@ export type ChatAction = | ChatTurnCancelledAction | ChatErrorAction | ChatActivityChangedAction + | ChatWorkingDirectorySetAction + | ChatWorkingDirectoryRemovedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction @@ -135,6 +141,8 @@ export type ClientChatAction = | ChatToolCallResultConfirmedAction | ChatToolCallContentChangedAction | ChatTurnCancelledAction + | ChatWorkingDirectorySetAction + | ChatWorkingDirectoryRemovedAction | ChatPendingMessageSetAction | ChatPendingMessageRemovedAction | ChatQueuedMessagesReorderedAction @@ -283,6 +291,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionServerToolsChanged]: false, [ActionType.SessionActiveClientSet]: true, [ActionType.SessionActiveClientRemoved]: true, + [ActionType.SessionWorkingDirectorySet]: true, + [ActionType.SessionWorkingDirectoryRemoved]: true, [ActionType.SessionInputNeededSet]: false, [ActionType.SessionInputNeededRemoved]: false, [ActionType.SessionCustomizationsChanged]: false, @@ -314,6 +324,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatTurnCancelled]: true, [ActionType.ChatError]: false, [ActionType.ChatActivityChanged]: false, + [ActionType.ChatWorkingDirectorySet]: true, + [ActionType.ChatWorkingDirectoryRemoved]: true, [ActionType.ChatUsage]: false, [ActionType.ChatReasoning]: false, [ActionType.ChatPendingMessageSet]: true, diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts index 426b26a2dc108d..3ddcc7d7ef27fe 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts @@ -7,7 +7,7 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo } from '../common/state.js'; +import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo, URI } from '../common/state.js'; import type { McpAuthRequirement } from '../channels-session/state.js'; import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ChatInputAnswer, type ChatInputRequest, type ChatInputResponseKind, type ConfirmationOption, type ToolCallContributor, type ToolCallRiskAssessment, type Turn } from './state.js'; @@ -488,6 +488,45 @@ export interface ChatActivityChangedAction { activity?: string; } +/** + * A working directory was added to this chat's + * {@link ChatState.workingDirectories} subset. + * + * Membership semantics keyed by the directory URI: the reducer appends + * `directory` when the chat's subset does not already contain it (creating the + * subset if absent) and is a no-op when it is already present. `directory` MUST + * be one of the owning session's {@link SessionState.workingDirectories}; a host + * MUST reject a directory that is not. Only valid when the agent advertises + * {@link AgentCapabilities.multipleWorkingDirectories}. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatWorkingDirectorySetAction { + type: ActionType.ChatWorkingDirectorySet; + /** The working directory to add to this chat's subset. */ + directory: URI; +} + +/** + * A working directory was removed from this chat's + * {@link ChatState.workingDirectories} subset. + * + * Removes `directory` from the chat's subset; a no-op when it is not present. + * Idempotent, mirroring `session/workingDirectoryRemoved`. Only affects the + * chat's subset — the directory remains in the session's set. + * + * @category Chat Actions + * @version 1 + * @clientDispatchable + */ +export interface ChatWorkingDirectoryRemovedAction { + type: ActionType.ChatWorkingDirectoryRemoved; + /** The working directory to remove from this chat's subset. */ + directory: URI; +} + /** * Token usage report for a turn. * @@ -751,6 +790,8 @@ export type ChatAction = | ChatTurnCancelledAction | ChatErrorAction | ChatActivityChangedAction + | ChatWorkingDirectorySetAction + | ChatWorkingDirectoryRemovedAction | ChatUsageAction | ChatReasoningAction | ChatTruncatedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts index a727004d1ea2c3..296f0efe8f05a2 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/commands.ts @@ -40,6 +40,29 @@ export interface CreateChatParams extends BaseParams { initialMessage?: Message; /** Optional source chat and turn to fork from. */ source?: ChatForkSource; + /** + * Initial working-directory subset for this chat. Every entry MUST be + * present in the owning session's `workingDirectories`; the server MUST + * reject any entry that is not. When absent, the chat inherits the full + * session set. Forked chats (`source`) inherit the source chat's + * `workingDirectories`; this field is ignored for forked chats. + * + * A client MUST NOT supply this field unless the agent advertises + * {@link AgentCapabilities.multipleWorkingDirectories}. + */ + workingDirectories?: URI[]; + /** + * The chat's primary working directory — the distinguished root this chat is + * centered on. When set, it MUST be one of the chat's effective working + * directories ({@link workingDirectories}, or the session's set when that is + * omitted). A client SHOULD supply this when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a host MAY + * reject creation that omits it, or fall back to the first of the chat's + * directories. Fixed at creation and reported (read-only) on + * {@link ChatState.primaryWorkingDirectory}. Ignored for forked chats (a fork + * inherits the source chat's primary). + */ + primaryWorkingDirectory?: URI; } // ─── disposeChat ───────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts index 7ccaeb4d0860b7..231ecbfc5c524a 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts @@ -365,6 +365,30 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st case ActionType.ChatActivityChanged: return { ...state, activity: action.activity }; + // ── Working Directories ─────────────────────────────────────────────── + + case ActionType.ChatWorkingDirectorySet: { + const list = state.workingDirectories ?? []; + if (list.includes(action.directory)) { + return state; + } + return { ...state, workingDirectories: [...list, action.directory] }; + } + + case ActionType.ChatWorkingDirectoryRemoved: { + const list = state.workingDirectories; + if (!list) { + return state; + } + const idx = list.indexOf(action.directory); + if (idx < 0) { + return state; + } + const updated = list.slice(); + updated.splice(idx, 1); + return { ...state, workingDirectories: updated }; + } + // ── Tool Call State Machine ─────────────────────────────────────────── case ActionType.ChatToolCallStart: diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index f24c5526269b83..d39a4a88c3d982 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -50,15 +50,34 @@ export interface ChatState { */ interactivity?: ChatInteractivity; /** - * Optional per-chat working directory. + * The subset of the session's + * {@link SessionState.workingDirectories | `workingDirectories`} that this + * chat's agent has tool access to. Every entry MUST be present in the owning + * session's `workingDirectories`; servers MUST reject a + * `chat/workingDirectorySet` action that violates this constraint. * - * If absent, the chat inherits - * {@link SessionState.workingDirectory | the session's working directory}. - * Hosts MAY override this for individual chats — for example, to give a - * subordinate chat its own git worktree so multiple chats in a session can - * make independent edits that the orchestrator later merges back. + * When absent, the chat inherits the full session set. When present but empty + * (not recommended), the chat has no working-directory tool access at all. + * + * Dispatch `chat/workingDirectorySet` / `chat/workingDirectoryRemoved` to + * update the subset on a running chat. + */ + workingDirectories?: URI[]; + /** + * The chat's primary working directory — the distinguished root this chat is + * centered on (e.g. the agent's process root for this chat, the default + * location for relative paths). MUST be one of this chat's effective working + * directories ({@link workingDirectories}, or the session's set when that is + * absent). Present when the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}. + * + * **Read-only and fixed at creation.** It is set from + * {@link CreateChatParams.primaryWorkingDirectory} (or, for the session's + * default chat, {@link CreateSessionParams.primaryWorkingDirectory}) and does + * not change over the chat's lifetime — there is no action to mutate it, and + * it does not participate in `session/chatUpdated`. */ - workingDirectory?: URI; + primaryWorkingDirectory?: URI; // ── Conversation contents ────────────────────────────────────────── /** Completed turns */ @@ -127,13 +146,15 @@ export interface ChatSummary { */ interactivity?: ChatInteractivity; /** - * Optional per-chat working directory. - * - * If absent, the chat inherits - * {@link SessionSummary.workingDirectory | the session's working directory}. - * See {@link ChatState.workingDirectory} for usage notes. + * The subset of the session's working directories this chat uses. + * See {@link ChatState.workingDirectories} for the full semantics. + */ + workingDirectories?: URI[]; + /** + * The chat's primary working directory. + * See {@link ChatState.primaryWorkingDirectory} for the full semantics. */ - workingDirectory?: URI; + primaryWorkingDirectory?: URI; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index f0d28f53e009f2..f9aafad9a759b7 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -108,6 +108,17 @@ export interface AgentCapabilities { * forking; set {@link MultipleChatsCapability.fork} to also allow forking. */ multipleChats?: MultipleChatsCapability; + /** + * The session's agent can be granted tool access to more than one working + * directory. The directories are treated as equal peers except where the + * agent advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary} + * (some backends need one directory designated as a primary root). + * + * When absent, clients MUST NOT mutate a session's or chat's working-directory + * set and MUST NOT set more than one entry in + * {@link CreateSessionParams.workingDirectories}. + */ + multipleWorkingDirectories?: MultipleWorkingDirectoriesCapability; } /** @@ -124,6 +135,29 @@ export interface MultipleChatsCapability { fork?: boolean; } +/** + * Options for the {@link AgentCapabilities.multipleWorkingDirectories} capability. + * + * @category Root State + */ +export interface MultipleWorkingDirectoriesCapability { + /** + * The agent requires each chat to designate one of its working directories as + * the **primary** — a distinguished root the chat is centered on (e.g. the + * agent's process root for that chat, the default location for relative + * paths). Primary is a **per-chat** notion, fixed at chat creation. When + * `true`, a client SHOULD supply {@link CreateChatParams.primaryWorkingDirectory} + * (and {@link CreateSessionParams.primaryWorkingDirectory}, which seeds the + * session's default chat); a host MAY reject creation that omits it, or fall + * back to the first entry of the chat's working directories. The chosen + * primary is reported (read-only) on {@link ChatState.primaryWorkingDirectory}. + * + * When absent or `false`, the agent has no primary — all directories are + * equal peers and clients need not designate one. + */ + requiresPrimary?: boolean; +} + /** * @category Root State */ diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index aeab330bf8678f..aa4a813f04cc48 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -250,6 +250,48 @@ export interface SessionActiveClientRemovedAction { clientId: string; } +// ─── Working Directory Actions ─────────────────────────────────────────────── + +/** + * A working directory was added to the session's + * {@link SessionState.workingDirectories} set. + * + * Membership semantics keyed by the directory URI: the reducer appends + * `directory` when the set does not already contain it (creating the set if + * absent) and is a no-op when it is already present. Only valid when the agent + * advertises {@link AgentCapabilities.multipleWorkingDirectories}. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface SessionWorkingDirectorySetAction { + type: ActionType.SessionWorkingDirectorySet; + /** The working directory to grant the session's agent tool access to. */ + directory: URI; +} + +/** + * A working directory was removed from the session's + * {@link SessionState.workingDirectories} set. + * + * Removes `directory` from the set; a no-op when it is not present. There is no + * atomic backend "remove one" primitive — a host reconfigures its agent to the + * reduced set — so this action is safe to model as idempotent. A host MAY + * decline to apply the removal (e.g. a directory still designated as some + * chat's {@link ChatState.primaryWorkingDirectory | primary}); it then leaves + * the set unchanged. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface SessionWorkingDirectoryRemovedAction { + type: ActionType.SessionWorkingDirectoryRemoved; + /** The working directory to revoke the session's agent tool access to. */ + directory: URI; +} + // ─── Input Needed Actions ──────────────────────────────────────────────────── /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index f70d6af3a30c3c..ff7d4a69595f02 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -63,8 +63,37 @@ export interface CreateSessionParams extends BaseParams { channel: URI; /** Agent provider ID */ provider?: string; - /** Working directory for the session */ - workingDirectory?: URI; + /** + * The working directories the session's agent is granted tool access to. + * A session may span multiple directories; they are equal peers except when + * the agent advertises + * {@link MultipleWorkingDirectoriesCapability.requiresPrimary}, in which case + * one of them should be designated the primary via + * {@link primaryWorkingDirectory}. + * + * A client MUST NOT supply more than one entry unless the agent advertises + * {@link AgentCapabilities.multipleWorkingDirectories}; a server without that + * capability treats only the first entry as the session's working directory + * and ignores the rest. Dispatch `session/workingDirectorySet` / + * `session/workingDirectoryRemoved` to change the set after the session has + * started. + * + * Ignored for forked sessions — a fork inherits its working directories + * from the source session identified by `fork`. + */ + workingDirectories?: URI[]; + /** + * The primary working directory for the session's **default chat** — the + * distinguished root that chat is centered on (see + * {@link ChatState.primaryWorkingDirectory}). A session has no primary of its + * own; this seeds the default chat's primary. When set, it MUST be one of + * {@link workingDirectories}. A client SHOULD supply this when the agent + * advertises {@link MultipleWorkingDirectoriesCapability.requiresPrimary}; a + * host MAY reject creation that omits it, or fall back to the first entry of + * `workingDirectories`. Ignored for forked sessions (a fork inherits the + * source session's chats and their primaries). + */ + primaryWorkingDirectory?: URI; /** * Fork from an existing session. The new session is populated with content * from the source session up to and including the specified turn's response. diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts index ffb36b09bf08bc..0de727342d8022 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts @@ -218,6 +218,30 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: return { ...state, activeClients: updated }; } + // ── Working Directories ───────────────────────────────────────────── + + case ActionType.SessionWorkingDirectorySet: { + const list = state.workingDirectories ?? []; + if (list.includes(action.directory)) { + return state; + } + return { ...state, workingDirectories: [...list, action.directory] }; + } + + case ActionType.SessionWorkingDirectoryRemoved: { + const list = state.workingDirectories; + if (!list) { + return state; + } + const idx = list.indexOf(action.directory); + if (idx < 0) { + return state; + } + const updated = list.slice(); + updated.splice(idx, 1); + return { ...state, workingDirectories: updated }; + } + // ── Input Needed ──────────────────────────────────────────────────── case ActionType.SessionInputNeededSet: { diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index f3341cb999ac80..56783cadb3af4b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -73,12 +73,16 @@ export interface SessionMetadata { /** Server-owned project for this session */ project?: ProjectInfo; /** - * The default working directory URI for this session. Individual chats - * MAY override via {@link ChatSummary.workingDirectory | their own - * `workingDirectory`}; this field acts as the fallback for any chat that - * does not. + * The working directories the session's agent has tool access to, as + * maintained by the `session/workingDirectorySet` / + * `session/workingDirectoryRemoved` actions. Directories are **equal peers** — + * the session has no primary. Individual chats MAY restrict to a subset via + * {@link ChatSummary.workingDirectories | their own `workingDirectories`} and + * designate one of their own directories as primary (see + * {@link ChatState.primaryWorkingDirectory}); a chat that sets no subset + * operates against this full set. */ - workingDirectory?: URI; + workingDirectories?: URI[]; /** * Lightweight summary of this session's inline annotations channel * (`ahp-session://annotations`). Surfaced so badge UI can render @@ -181,7 +185,7 @@ export interface SessionState extends SessionMetadata { * * Clients MAY look for well-known keys here to provide enhanced UI. * For example, a `git` key may provide extra git metadata about the session's - * workingDirectory. + * working directories. */ _meta?: Record; } @@ -406,9 +410,9 @@ export interface ProjectInfo { * chat currently driving the promoted status bits when a non-default chat * wins (e.g. the chat that raised `InputNeeded`). * - `modifiedAt`: the max of all chats' `modifiedAt`. - * - `workingDirectory`: the session-level **default**. Individual chats MAY - * override via {@link ChatSummary.workingDirectory}; aggregating these up - * is meaningless and SHOULD NOT be attempted. + * - `workingDirectories`: the session-level set. Individual chats MAY restrict + * to a subset via {@link ChatSummary.workingDirectories}; aggregating these + * up is meaningless and SHOULD NOT be attempted. * - `changes`: optional roll-up across all chats. Producers MAY sum the * per-chat changeset stats or report the most expensive chat's stats — * whichever is cheaper for the host to compute. diff --git a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts index 5a201d07df8e56..7e37f9223a5073 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -10,9 +10,9 @@ import type { URI } from './state.js'; import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerminalsChangedAction, RootConfigChangedAction } from '../channels-root/actions.js'; -import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; +import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionWorkingDirectorySetAction, SessionWorkingDirectoryRemovedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; -import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatActivityChangedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; +import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetFilesReviewChangedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js'; @@ -54,12 +54,16 @@ export const enum ActionType { ChatTurnCancelled = 'chat/turnCancelled', ChatError = 'chat/error', ChatActivityChanged = 'chat/activityChanged', + ChatWorkingDirectorySet = 'chat/workingDirectorySet', + ChatWorkingDirectoryRemoved = 'chat/workingDirectoryRemoved', SessionTitleChanged = 'session/titleChanged', ChatUsage = 'chat/usage', ChatReasoning = 'chat/reasoning', SessionServerToolsChanged = 'session/serverToolsChanged', SessionActiveClientSet = 'session/activeClientSet', SessionActiveClientRemoved = 'session/activeClientRemoved', + SessionWorkingDirectorySet = 'session/workingDirectorySet', + SessionWorkingDirectoryRemoved = 'session/workingDirectoryRemoved', SessionInputNeededSet = 'session/inputNeededSet', SessionInputNeededRemoved = 'session/inputNeededRemoved', ChatPendingMessageSet = 'chat/pendingMessageSet', @@ -161,6 +165,8 @@ export type StateAction = | SessionServerToolsChangedAction | SessionActiveClientSetAction | SessionActiveClientRemovedAction + | SessionWorkingDirectorySetAction + | SessionWorkingDirectoryRemovedAction | SessionInputNeededSetAction | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction @@ -192,6 +198,8 @@ export type StateAction = | ChatTurnCancelledAction | ChatErrorAction | ChatActivityChangedAction + | ChatWorkingDirectorySetAction + | ChatWorkingDirectoryRemovedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction diff --git a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts index d0bfeab121ab34..2041f2a23a1ba9 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts @@ -276,7 +276,7 @@ export interface InitializeResult { * @method ping * @direction Client → Server * @messageType Request - * @version 0.1.0 + * @version 1 */ export interface PingParams extends BaseParams { channel: 'ahp-root://'; diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 20ed37b0822c89..22e3b1fa702849 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -16,7 +16,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.6.0'; +export const PROTOCOL_VERSION = '0.7.0'; /** * Every protocol version a client built from this source tree is willing @@ -35,6 +35,7 @@ export const PROTOCOL_VERSION = '0.6.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ + '0.7.0', '0.6.0', '0.5.2', '0.5.1', @@ -90,6 +91,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.SessionServerToolsChanged]: '0.1.0', [ActionType.SessionActiveClientSet]: '0.5.0', [ActionType.SessionActiveClientRemoved]: '0.5.0', + [ActionType.SessionWorkingDirectorySet]: '0.7.0', + [ActionType.SessionWorkingDirectoryRemoved]: '0.7.0', [ActionType.SessionInputNeededSet]: '0.5.1', [ActionType.SessionInputNeededRemoved]: '0.5.1', [ActionType.SessionCustomizationsChanged]: '0.1.0', @@ -121,6 +124,8 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatTurnCancelled]: '0.4.0', [ActionType.ChatError]: '0.4.0', [ActionType.ChatActivityChanged]: '0.5.0', + [ActionType.ChatWorkingDirectorySet]: '0.7.0', + [ActionType.ChatWorkingDirectoryRemoved]: '0.7.0', [ActionType.ChatUsage]: '0.4.0', [ActionType.ChatReasoning]: '0.4.0', [ActionType.ChatPendingMessageSet]: '0.4.0', diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 0302739d5380e6..a8033ad07f04f1 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -617,7 +617,7 @@ export function createSessionState(summary: SessionSummary): SessionState { }; if (summary.activity !== undefined) { state.activity = summary.activity; } if (summary.project !== undefined) { state.project = summary.project; } - if (summary.workingDirectory !== undefined) { state.workingDirectory = summary.workingDirectory; } + if (summary.workingDirectories !== undefined) { state.workingDirectories = summary.workingDirectories; } if (summary.annotations !== undefined) { state.annotations = summary.annotations; } if (summary._meta !== undefined) { state._meta = summary._meta; } return state; @@ -637,7 +637,8 @@ export function createChatState(summary: ChatSummary): ChatState { modifiedAt: summary.modifiedAt, origin: summary.origin, interactivity: summary.interactivity, - workingDirectory: summary.workingDirectory, + workingDirectories: summary.workingDirectories, + primaryWorkingDirectory: summary.primaryWorkingDirectory, turns: [], activeTurn: undefined, }; @@ -659,12 +660,14 @@ export function createDefaultChatSummary(session: SessionSummary, chatUri: Proto origin: { kind: ChatOriginKind.User }, }; if (session.activity !== undefined) { summary.activity = session.activity; } - // `workingDirectory` is deliberately NOT copied: per the protocol it is a - // per-chat OVERRIDE and, when absent, the chat inherits the session's - // working directory (see `mergeSessionWithDefaultChat`). Seeding it here - // would denormalize the session default onto every chat as a fake override, - // which then goes stale when the session's working directory is resolved - // later (e.g. a worktree resolved at materialization). + // `workingDirectories` is deliberately NOT copied: per the protocol it is a + // per-chat SUBSET override and, when absent, the chat inherits the session's + // full set of working directories (see `mergeSessionWithDefaultChat`). + // Seeding it here would denormalize the session default onto every chat as a + // fake override, which then goes stale when the session's working + // directories are resolved later (e.g. a worktree resolved at + // materialization). `primaryWorkingDirectory` is per-chat and fixed at chat + // creation (the session has no primary), so it is likewise not seeded here. return summary; } @@ -683,7 +686,8 @@ export function chatSummaryFromState(state: ChatState): ChatSummary { if (state.activity !== undefined) { summary.activity = state.activity; } if (state.origin !== undefined) { summary.origin = state.origin; } if (state.interactivity !== undefined) { summary.interactivity = state.interactivity; } - if (state.workingDirectory !== undefined) { summary.workingDirectory = state.workingDirectory; } + if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; } + if (state.primaryWorkingDirectory !== undefined) { summary.primaryWorkingDirectory = state.primaryWorkingDirectory; } return summary; } @@ -877,19 +881,24 @@ export function isAhpChatChannel(uri: string): boolean { /** * A single chat's effective session context: the shared {@link SessionState} - * (working directory, active clients, config, customizations/MCP scope, …) + * (working directories, active clients, config, customizations/MCP scope, …) * resolved for one chat and merged with that chat's conversation contents. * * The protocol moved turns and pending state off the session and onto a - * per-chat channel, and lets a chat override session defaults (e.g. - * {@link ChatState.workingDirectory}). This composite recombines the session + * per-chat channel, and lets a chat override the session's working directories + * with a subset (e.g. {@link ChatState.workingDirectories}) and carry its own + * read-only {@link ChatState.primaryWorkingDirectory | primary} (fixed at chat + * creation — the session has no primary). This composite recombines the session * with one of its chats — default or peer — so consumers read the chat's * effective context and conversation through one object without walking back to - * the session to re-derive shared state. The inherited - * {@link SessionState.workingDirectory} carries the chat's *effective* working - * directory (its own override when present, else the session default). + * the session to re-derive shared state. The {@link ISessionWithDefaultChat.workingDirectories} + * carry the chat's *effective* working directories (its own subset override when + * present, else the session's full set); {@link ISessionWithDefaultChat.primaryWorkingDirectory} + * is the chat's own primary. */ export interface ISessionWithDefaultChat extends SessionState { + /** The chat's read-only primary working directory (fixed at chat creation). */ + primaryWorkingDirectory?: ProtocolURI; /** Completed turns of this chat. */ turns: Turn[]; /** Currently in-progress turn of this chat. */ @@ -905,15 +914,17 @@ export interface ISessionWithDefaultChat extends SessionState { /** * Projects a {@link SessionState} and one of its {@link ChatState | chats} * (default or peer) into that chat's {@link ISessionWithDefaultChat | effective - * session context}. Per-chat overrides (currently the working directory) are - * layered over the session defaults, and the conversation fields are taken from - * the chat. When the chat state is absent (e.g. not yet hydrated) the - * conversation fields default to empty and the session defaults apply. + * session context}. Per-chat overrides (the working-directories subset and the + * chat's own primary) are layered over the session defaults, and the + * conversation fields are taken from the chat. When the chat state is absent + * (e.g. not yet hydrated) the conversation fields default to empty and the + * session defaults apply. */ export function mergeSessionWithDefaultChat(session: SessionState, chat: ChatState | undefined): ISessionWithDefaultChat { return { ...session, - workingDirectory: chat?.workingDirectory ?? session.workingDirectory, + workingDirectories: chat?.workingDirectories ?? session.workingDirectories, + primaryWorkingDirectory: chat?.primaryWorkingDirectory, turns: chat?.turns ?? [], activeTurn: chat?.activeTurn, steeringMessage: chat?.steeringMessage, diff --git a/src/vs/platform/agentHost/node/agentConfigurationService.ts b/src/vs/platform/agentHost/node/agentConfigurationService.ts index 1a6b79e2bf28c6..0f59a1778d5075 100644 --- a/src/vs/platform/agentHost/node/agentConfigurationService.ts +++ b/src/vs/platform/agentHost/node/agentConfigurationService.ts @@ -211,13 +211,13 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi } getEffectiveWorkingDirectory(session: ProtocolURI): string | undefined { - const own = this._stateManager.getSessionState(session)?.workingDirectory; + const own = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; if (own !== undefined) { return own; } const parentInfo = parseSubagentSessionUri(session); if (parentInfo) { - return this._stateManager.getSessionState(parentInfo.parentSession.toString())?.workingDirectory; + return this._stateManager.getSessionState(parentInfo.parentSession.toString())?.workingDirectories?.[0]; } return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index bfb8552c6b1ca3..c2c21a178af9cb 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -569,7 +569,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } private async _computeUncommittedDiffs(session: ProtocolURI): Promise { - const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; if (!workingDirectory) { return undefined; } @@ -1003,7 +1003,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * branch git falls back to `HEAD`. */ private async _tryComputeGitDiffs(session: ProtocolURI, db: ISessionDatabase, kind: StaticChangesetKind): Promise { - const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; if (!workingDirectory) { return undefined; } @@ -1081,7 +1081,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * no git working directory (review status is then simply omitted). */ private async _computeReviewedInfo(session: ProtocolURI, db: ISessionDatabase): Promise<{ readonly repoRoot: URI; readonly paths: ReadonlySet } | undefined> { - const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; if (!workingDirectory) { return undefined; } diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts index c90b894cb366af..dee479e7a69b5e 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts @@ -60,7 +60,7 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); } - const workingDirectoryStr = sessionState.workingDirectory; + const workingDirectoryStr = sessionState.workingDirectories?.[0]; if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } diff --git a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts index e3ff4748437593..3efc282b907e4b 100644 --- a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationHandler.ts @@ -57,7 +57,7 @@ export class AgentHostDiscardChangesOperationHandler implements IChangesetOperat `Operation '${AgentHostDiscardChangesOperationHandler.OPERATION_DISCARD_CHANGES}' requires a resource target.`); } - const workingDirectoryStr = sessionState.workingDirectory; + const workingDirectoryStr = sessionState.workingDirectories?.[0]; if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } diff --git a/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts index 1b4ca1aaef9328..fe5080e0e214aa 100644 --- a/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostFileCompletionProvider.ts @@ -111,7 +111,7 @@ export class AgentHostFileCompletionProvider implements IAgentHostCompletionItem ) { } async provideCompletionItems(params: CompletionsParams, token: CancellationToken): Promise { - const workingDirectoryStr = this._stateManager.getSessionState(params.channel)?.workingDirectory; + const workingDirectoryStr = this._stateManager.getSessionState(params.channel)?.workingDirectories?.[0]; if (!workingDirectoryStr) { return []; } diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index 5231950355a467..122619759dc02e 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -100,7 +100,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi } if (!workingDirectory) { - const workingDirectoryStr = sessionState?.workingDirectory; + const workingDirectoryStr = sessionState?.workingDirectories?.[0]; if (workingDirectoryStr) { workingDirectory = URI.parse(workingDirectoryStr); } diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index 22f79de0dfb6e3..ca486da98fdd1b 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -104,7 +104,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); } - const workingDirectoryStr = sessionState.workingDirectory; + const workingDirectoryStr = sessionState.workingDirectories?.[0]; if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } diff --git a/src/vs/platform/agentHost/node/agentHostReviewService.ts b/src/vs/platform/agentHost/node/agentHostReviewService.ts index 24ad761e29536a..73b0c63766ab12 100644 --- a/src/vs/platform/agentHost/node/agentHostReviewService.ts +++ b/src/vs/platform/agentHost/node/agentHostReviewService.ts @@ -68,7 +68,7 @@ export class AgentHostReviewService extends Disposable implements IAgentHostRevi if (!sessionState) { throw new Error(`Session not found: ${parsed.sessionUri}`); } - if (!sessionState.workingDirectory) { + if (!sessionState.workingDirectories?.[0]) { throw new Error(`Session has no working directory: ${parsed.sessionUri}`); } @@ -80,7 +80,7 @@ export class AgentHostReviewService extends Disposable implements IAgentHostRevi databaseRef.dispose(); } - const workingDirectory = URI.parse(sessionState.workingDirectory); + const workingDirectory = URI.parse(sessionState.workingDirectories?.[0]); const baseBranch = resolveDiffBaseBranchName(persistedBaseBranch, readSessionGitState(sessionState._meta)?.baseBranchName); await this._sequencer.queue(parsed.sessionUri, async () => { for (const resource of resources) { @@ -234,7 +234,7 @@ export class AgentHostReviewService extends Disposable implements IAgentHostRevi } private async _disposeSessionData(session: ProtocolURI): Promise { - const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; if (!workingDirectory) { // No working directory means we can't resolve the repository root // (session was never git-backed, or its working directory is gone). diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 53fdd3a1585431..8cfc183e098a8a 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -137,7 +137,7 @@ class SessionSummaryNotifier extends Disposable { if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } if (current.project !== lastNotified.project) { changes.project = current.project; } if (current.changes !== lastNotified.changes) { changes.changes = current.changes; } - if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; } + if (current.workingDirectories !== lastNotified.workingDirectories) { changes.workingDirectories = current.workingDirectories; } if (current._meta !== lastNotified._meta) { changes._meta = current._meta; } this._lastNotified.set(session, current); @@ -344,7 +344,7 @@ export class AgentHostStateManager extends Disposable { }; if (state.activity !== undefined) { summary.activity = state.activity; } if (state.project !== undefined) { summary.project = state.project; } - if (state.workingDirectory !== undefined) { summary.workingDirectory = state.workingDirectory; } + if (state.workingDirectories !== undefined) { summary.workingDirectories = state.workingDirectories; } if (state.annotations !== undefined) { summary.annotations = state.annotations; } if (entry.changes !== undefined) { summary.changes = entry.changes; } if (state._meta !== undefined) { summary._meta = state._meta; } @@ -361,7 +361,7 @@ export class AgentHostStateManager extends Disposable { && a.status === b.status && a.activity === b.activity && a.project === b.project - && a.workingDirectory === b.workingDirectory + && a.workingDirectories === b.workingDirectories && a.annotations === b.annotations && a._meta === b._meta; } @@ -608,7 +608,7 @@ export class AgentHostStateManager extends Disposable { // directory / project. We don't need to schedule a // `SessionSummaryChanged` flush because the upcoming `SessionAdded` // notification carries the complete summary already. - entry.state = { ...entry.state, project: summary.project, workingDirectory: summary.workingDirectory }; + entry.state = { ...entry.state, project: summary.project, workingDirectories: summary.workingDirectories }; entry.modifiedAt = summary.modifiedAt; entry.changes = summary.changes; const full = this._toSummary(key, entry); @@ -1131,6 +1131,26 @@ export class AgentHostStateManager extends Disposable { return this._applyAndEmit(channel, action, origin); } + /** + * Reject a client-originated action without applying it to state. Emits an + * {@link ActionEnvelope} that carries the original {@link ActionOrigin} and a + * {@link ActionEnvelope.rejectionReason | rejectionReason} so the originating + * client can reconcile (roll back) its optimistic write-ahead action through + * the normal path instead of leaving it pending until reconnect. The reducer + * is deliberately NOT run, so no synchronized state changes. + */ + rejectClientAction(channel: URI, action: StateAction, origin: ActionOrigin, reason: string): void { + const envelope: ActionEnvelope = { + channel, + action, + serverSeq: ++this._serverSeq, + origin, + rejectionReason: reason, + }; + this._logService.trace(`[AgentHostStateManager] Emitting rejection envelope: seq=${envelope.serverSeq}, channel=${envelope.channel}, type=${action.type}, origin=${origin.clientId}:${origin.clientSeq}, reason=${reason}`); + this._onDidEmitEnvelope.fire(envelope); + } + // ---- Internal ----------------------------------------------------------- private _applyAndEmit(channel: URI, action: StateAction, origin: ActionOrigin | undefined): unknown { diff --git a/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts index 9ea663d006f23f..bba21e32eb9733 100644 --- a/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts @@ -38,7 +38,7 @@ export class AgentHostSyncOperationHandler implements IChangesetOperationHandler throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); } - const workingDirectoryStr = sessionState.workingDirectory; + const workingDirectoryStr = sessionState.workingDirectories?.[0]; if (!workingDirectoryStr) { throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 26d488aee5b7bf..705b523267ec6c 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -488,7 +488,7 @@ export class AgentService extends Disposable implements IAgentService { resolveWorkingDirectoryBeforeSend: params => this._resolveWorkingDirectoryBeforeSend(params), onTurnComplete: async session => { // Refresh the git state for the session. - const workingDirStr = this._stateManager.getSessionState(session)?.workingDirectory; + const workingDirStr = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; void this._gitStateService.refreshSessionGitState(session, workingDirStr ? URI.parse(workingDirStr) : undefined); // Check for a GitHub pull request associated with the session's branch. @@ -847,6 +847,7 @@ export class AgentService extends Disposable implements IAgentService { const _meta = liveSummary._meta !== undefined || s._meta !== undefined ? { ...s._meta, ...liveSummary._meta } : undefined; + const liveWorkingDir = liveSummary.workingDirectories?.[0]; return { ...s, summary: liveSummary.title || s.summary, @@ -856,8 +857,8 @@ export class AgentService extends Disposable implements IAgentService { project: liveSummary.project ? { uri: URI.parse(liveSummary.project.uri), displayName: liveSummary.project.displayName } : s.project, - workingDirectory: typeof liveSummary.workingDirectory === 'string' - ? URI.parse(liveSummary.workingDirectory) + workingDirectory: typeof liveWorkingDir === 'string' + ? URI.parse(liveWorkingDir) : s.workingDirectory, changes: liveSummary.changes ?? s.changes, changesets: this._stateManager.getSessionState(s.session.toString())?.changesets ?? s.changesets, @@ -890,6 +891,7 @@ export class AgentService extends Disposable implements IAgentService { continue; } + const summaryWorkingDir = summary.workingDirectories?.[0]; additions.push({ session: URI.parse(summary.resource), startTime: Date.parse(summary.createdAt), @@ -897,7 +899,7 @@ export class AgentService extends Disposable implements IAgentService { summary: summary.title, status: summary.status, activity: summary.activity, - workingDirectory: typeof summary.workingDirectory === 'string' ? URI.parse(summary.workingDirectory) : undefined, + workingDirectory: typeof summaryWorkingDir === 'string' ? URI.parse(summaryWorkingDir) : undefined, ...(summary.project ? { project: { uri: URI.parse(summary.project.uri), displayName: summary.project.displayName } } : {}), changes: summary.changes, // This overlay path never opens the session database (unlike the @@ -1400,6 +1402,7 @@ export class AgentService extends Disposable implements IAgentService { private _buildInitialSummary(provider: IAgent, session: URI, config: IAgentCreateSessionConfig | undefined, created: { project?: { uri: URI; displayName: string }; workingDirectory?: URI }, title: string): SessionSummary { const now = new Date().toISOString(); + const primaryWorkingDir = (created.workingDirectory ?? config?.workingDirectory)?.toString(); return { resource: session.toString(), provider: provider.id, @@ -1408,7 +1411,7 @@ export class AgentService extends Disposable implements IAgentService { createdAt: now, modifiedAt: now, ...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}), - workingDirectory: (created.workingDirectory ?? config?.workingDirectory)?.toString(), + workingDirectories: primaryWorkingDir ? [primaryWorkingDir] : undefined, // Workspace-less is inferred at create from an absent input // `workingDirectory` (the host assigns a scratch cwd, so it can't be // re-inferred later) and tagged on the generic `_meta` bag. @@ -1450,7 +1453,7 @@ export class AgentService extends Disposable implements IAgentService { const summary: SessionSummary = { ...currentSummary, ...(project ? { project: { uri: project.uri.toString(), displayName: project.displayName } } : {}), - workingDirectory: e.workingDirectory?.toString() ?? currentSummary.workingDirectory, + workingDirectories: e.workingDirectory ? [e.workingDirectory.toString()] : currentSummary.workingDirectories, modifiedAt: new Date().toISOString(), }; const configValues = state.config?.values; @@ -1761,8 +1764,8 @@ export class AgentService extends Disposable implements IAgentService { // the normal state-update stream. const sessionState = this._stateManager.getSessionState(resourceStr); if (!isAhpChatChannel(resourceStr) && sessionState && readSessionGitState(sessionState._meta) === undefined) { - const workingDirectory = sessionState.workingDirectory - ? URI.parse(sessionState.workingDirectory) + const workingDirectory = sessionState.workingDirectories?.[0] + ? URI.parse(sessionState.workingDirectories[0]) : undefined; void this._gitStateService.refreshSessionGitState(resourceStr, workingDirectory); } @@ -2408,7 +2411,7 @@ export class AgentService extends Disposable implements IAgentService { modifiedAt: new Date(meta.modifiedTime).toISOString(), ...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}), changes: meta.changes ?? changes, - workingDirectory: meta.workingDirectory?.toString(), + workingDirectories: meta.workingDirectory ? [meta.workingDirectory.toString()] : undefined, _meta: (sessionMetadata || meta._meta) ? { ...(meta._meta ?? {}), ...(sessionMetadata ?? {}) } : undefined, }; @@ -3482,7 +3485,7 @@ export class AgentService extends Disposable implements IAgentService { if (!this._gitService) { throw new ProtocolError(AhpErrorCodes.NotFound, `git service unavailable for: ${fields.repoRelativePath}`); } - const workingDirectory = this._stateManager.getSessionState(fields.sessionUri)?.workingDirectory; + const workingDirectory = this._stateManager.getSessionState(fields.sessionUri)?.workingDirectories?.[0]; if (!workingDirectory) { throw new ProtocolError(AhpErrorCodes.NotFound, `Session has no working directory for git-blob URI: ${fields.sessionUri}`); } diff --git a/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts b/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts index a0829625375757..78370088964da5 100644 --- a/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts +++ b/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts @@ -57,7 +57,7 @@ export class BangLocalCommand extends Disposable implements ILocalChatCommand { const displayName = localize('agentHostBang.terminal', "Terminal"); let terminalCreated = false; try { - const workingDirStr = ctx.getState(sessionChannel)?.workingDirectory; + const workingDirStr = ctx.getState(sessionChannel)?.workingDirectories?.[0]; const cwd = workingDirStr ? URI.parse(workingDirStr).fsPath : undefined; const shellPath = await ctx.terminalManager.getDefaultShell(); const shellType = shellTypeForExecutable(shellPath); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index abacd8d88dda6e..e3d13e6c738bcf 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -59,6 +59,21 @@ const REPLAY_BUFFER_CAPACITY = 1000; const CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT = 30_000; +/** + * Client-dispatchable actions that are declared in the protocol but not yet + * operational in this build. The multiroot working-directory mutations + * (`session|chat/workingDirectorySet|Removed`) would mutate the synchronized + * working-directory set without reconfiguring the agent's actual directory + * access, so they are rejected in the dispatch path until capability-backed + * multiroot support lands. + */ +const UNSUPPORTED_CLIENT_ACTION_TYPES: ReadonlySet = new Set([ + ActionType.SessionWorkingDirectorySet, + ActionType.SessionWorkingDirectoryRemoved, + ActionType.ChatWorkingDirectorySet, + ActionType.ChatWorkingDirectoryRemoved, +]); + /** A client tool call in any of these statuses is still awaiting its result. */ function isPendingToolCallStatus(status: ToolCallStatus): boolean { return status === ToolCallStatus.Streaming @@ -442,7 +457,22 @@ export class ProtocolServerHandler extends Disposable { this._logService.trace(`[ProtocolServer] dispatchAction: ${JSON.stringify(msg.params.action.type)}`); const action = msg.params.action as SessionAction | ChatAction | TerminalAction | IRootConfigChangedAction; const channel = msg.params.channel; - if (isSessionAction(action) || isChatAction(action) || isTerminalAction(action) || action.type === ActionType.RootConfigChanged) { + // Multiroot working-directory mutations are declared in the + // protocol but not yet supported: they would mutate the + // synchronized access set without reconfiguring the agent's + // actual directory access. Reject them through the normal + // reconciliation path (preserving the client's origin) so the + // client rolls back its optimistic action instead of leaving + // it pending, until capability-backed multiroot support lands. + if (UNSUPPORTED_CLIENT_ACTION_TYPES.has(action.type)) { + this._logService.warn(`[ProtocolServer] rejecting unsupported client action: ${action.type}`); + this._stateManager.rejectClientAction( + channel, + action, + { clientId: client.clientId, clientSeq: msg.params.clientSeq }, + `Unsupported action: ${action.type}`, + ); + } else if (isSessionAction(action) || isChatAction(action) || isTerminalAction(action) || action.type === ActionType.RootConfigChanged) { this._agentService.dispatchAction(channel, action, client.clientId, msg.params.clientSeq); } } @@ -1141,7 +1171,7 @@ export class ProtocolServerHandler extends Disposable { try { createdSession = await this._agentService.createSession({ provider: params.provider, - workingDirectory: params.workingDirectory ? URI.parse(params.workingDirectory) : undefined, + workingDirectory: params.workingDirectories?.[0] ? URI.parse(params.workingDirectories[0]) : undefined, session: URI.parse(params.channel), fork, config: params.config, diff --git a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts index 1ba04937943d49..a79017a1ba94f5 100644 --- a/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentConfigurationService.test.ts @@ -54,7 +54,7 @@ suite('AgentConfigurationService', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///project', displayName: 'Project' }, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }; } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index 386fef9b90500d..d7c6899ba6282c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -42,7 +42,7 @@ suite('ChangesetSessionCoordinator', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }, { emitNotification }); stateManager.setSessionChangesets(session, buildDefaultChangesetCatalog(session)); stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); @@ -148,7 +148,7 @@ suite('ChangesetSessionCoordinator', () => { assert.deepStrictEqual({ acquisitions: environment.monitor.acquisitions, rootLookups: environment.gitService.rootLookupCalls }, { acquisitions: [], rootLookups: [] }); const summary = environment.stateManager.getSessionSummary(session)!; - environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectory: 'file:///repo/worktree' }); + environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectories: ['file:///repo/worktree'] }); environment.coordinator.onSessionMaterialized(session); await environment.monitor.waitForAcquisitions(1); @@ -170,7 +170,7 @@ suite('ChangesetSessionCoordinator', () => { await tick(); const summary = environment.stateManager.getSessionSummary(session)!; - environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectory: 'file:///repo/worktree' }); + environment.stateManager.markSessionPersisted(session, { ...summary, workingDirectories: ['file:///repo/worktree'] }); environment.coordinator.onSessionMaterialized(session); await tick(); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index cb581ed2d29c30..7530e629396988 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -76,7 +76,7 @@ suite.skip('AgentHostChangesetService', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }); stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString())); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); @@ -287,7 +287,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); await sessionDb.setMetadata('agentHost.diffBaseBranch', 'main'); @@ -366,7 +366,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); localStateManager.setSessionMeta(sessionStr, withSessionGitState(undefined, { baseBranchName: 'main' })); @@ -402,7 +402,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); localStateManager.setSessionMeta(sessionStr, withSessionGitState(undefined, { baseBranchName: 'main' })); @@ -434,7 +434,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); const envelopes: ActionEnvelope[] = []; @@ -495,7 +495,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); await localChangesets.computeUncommittedChangeset(sessionStr); @@ -569,7 +569,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); await localChangesets.computeUncommittedChangeset(sessionStr); @@ -622,7 +622,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }); localStateManager.setSessionChangesets(sessionStr, buildDefaultChangesetCatalog(sessionStr)); return sessionStr; @@ -642,7 +642,7 @@ suite.skip('AgentHostChangesetService', () => { assert.deepStrictEqual(computes, [], 'nothing computed while the working directory is unknown'); const summary = localStateManager.getSessionSummary(sessionStr)!; - localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' }); + localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] }); service.onWorkingDirectoryAvailable(sessionStr); await timeout(0); assert.deepStrictEqual(computes.sort(), ['session', 'session']); @@ -657,7 +657,7 @@ suite.skip('AgentHostChangesetService', () => { assert.deepStrictEqual(computes, [], 'uncommitted compute deferred while the working directory is unknown'); const summary = localStateManager.getSessionSummary(sessionStr)!; - localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' }); + localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] }); service.onWorkingDirectoryAvailable(sessionStr); await timeout(0); assert.deepStrictEqual(computes, ['uncommitted']); @@ -673,7 +673,7 @@ suite.skip('AgentHostChangesetService', () => { subscriptions.delete(buildSessionChangesetUri(sessionStr)); const summary = localStateManager.getSessionSummary(sessionStr)!; - localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' }); + localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] }); service.onWorkingDirectoryAvailable(sessionStr); await timeout(0); assert.deepStrictEqual(computes, []); @@ -694,7 +694,7 @@ suite.skip('AgentHostChangesetService', () => { service.onSessionDisposed(sessionStr); const summary = localStateManager.getSessionSummary(sessionStr)!; - localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectory: 'file:///wd' }); + localStateManager.markSessionPersisted(sessionStr, { ...summary, workingDirectories: ['file:///wd'] }); service.onWorkingDirectoryAvailable(sessionStr); await timeout(0); assert.deepStrictEqual(computes, []); @@ -1048,7 +1048,7 @@ suite.skip('AgentHostChangesetService', () => { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: 'file:///wd', + workingDirectories: ['file:///wd'], }); const envelopes: ActionEnvelope[] = []; diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index e26f630b3c1e09..b2914a45d24e43 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -152,7 +152,7 @@ function setup(disposables: Pick, gitService: TestGitSer status: SessionStatus.Idle, createdAt: new Date(1).toISOString(), modifiedAt: new Date(1).toISOString(), - workingDirectory: URI.file('/repo').toString(), + workingDirectories: [URI.file('/repo').toString()], }); stateManager.setSessionMeta(session.toString(), withSessionGitState(undefined, { branchName: 'feature/test', diff --git a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts index cd4faec2df1a6c..a0d2cf80e08c2e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts @@ -74,7 +74,7 @@ function setup(disposables: Pick, opts?: { readonly with status: SessionStatus.Idle, createdAt: new Date(1).toISOString(), modifiedAt: new Date(1).toISOString(), - workingDirectory: opts?.withWorkingDirectory === false ? undefined : URI.file('/repo').toString(), + workingDirectories: opts?.withWorkingDirectory === false ? undefined : [URI.file('/repo').toString()], }); } const handler = new AgentHostDiscardChangesOperationHandler( diff --git a/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts index 6563db6c9143e7..aaecb276cf2838 100644 --- a/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostFileCompletionProvider.test.ts @@ -114,7 +114,7 @@ suite('AgentHostFileCompletionProvider', () => { createdAt: new Date(0).toISOString(), modifiedAt: new Date(0).toISOString(), project: { uri: 'file:///project', displayName: 'Project' }, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }; } diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index 33e09f24fc4870..36c16b322af61c 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -78,7 +78,7 @@ suite('AgentHostGitStateService', () => { status: SessionStatus.Idle, createdAt: new Date(0).toISOString(), modifiedAt: new Date(0).toISOString(), - workingDirectory: options?.workingDirectory, + workingDirectories: options?.workingDirectory ? [options.workingDirectory] : undefined, }; // `restoreSession` materializes the session in `ready` lifecycle so the // persistence path (which skips `creating` sessions) actually runs. @@ -113,7 +113,7 @@ suite('AgentHostGitStateService', () => { status: SessionStatus.Idle, createdAt: new Date(0).toISOString(), modifiedAt: new Date(0).toISOString(), - workingDirectory: 'file:///original', + workingDirectories: ['file:///original'], }, { emitNotification: false }); h.setGitResult({ branchName: 'feature' }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index a5ab639fa9fe59..3ed2a739e275f9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -168,7 +168,7 @@ function setup(disposables: Pick, gitService: TestGitSer status: SessionStatus.Idle, createdAt: new Date(1).toISOString(), modifiedAt: new Date(1).toISOString(), - workingDirectory: URI.file('/repo').toString(), + workingDirectories: [URI.file('/repo').toString()], }); // Git state and GitHub state now share the single `_meta` bag. const sessionMeta = withSessionGitHubState(withSessionGitState(undefined, { diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 9a82dcb4c4fae6..3f638925868a4d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -227,12 +227,12 @@ suite('AgentHostStateManager', () => { // has no per-chat working-directory override, so getSessionState must // project the RESOLVED session working directory, never the stale // create-time value that was seeded onto the default chat. - manager.createSession({ ...makeSessionSummary(), workingDirectory: 'file:///provisional' }, { emitNotification: false }); - manager.markSessionPersisted(sessionUri, { ...makeSessionSummary(), workingDirectory: 'file:///resolved-worktree' }); + manager.createSession({ ...makeSessionSummary(), workingDirectories: ['file:///provisional'] }, { emitNotification: false }); + manager.markSessionPersisted(sessionUri, { ...makeSessionSummary(), workingDirectories: ['file:///resolved-worktree'] }); assert.deepStrictEqual({ - session: manager.getSessionState(sessionUri)?.workingDirectory, - defaultChat: manager.getSessionState(sessionChatUri)?.workingDirectory, + session: manager.getSessionState(sessionUri)?.workingDirectories?.[0], + defaultChat: manager.getSessionState(sessionChatUri)?.workingDirectories?.[0], }, { session: 'file:///resolved-worktree', defaultChat: 'file:///resolved-worktree', @@ -1681,7 +1681,7 @@ suite('Subagent URI helpers', () => { lifecycle: SessionLifecycle.Ready, activeClients: [], chats: [], - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }; } @@ -1691,7 +1691,7 @@ suite('Subagent URI helpers', () => { title: 'Peer', status: SessionStatus.Idle, modifiedAt: new Date().toISOString(), - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, turns: [], }; } @@ -1701,7 +1701,7 @@ suite('Subagent URI helpers', () => { makeSessionState('file:///session-wd'), makeChatState('file:///peer-worktree'), ); - assert.strictEqual(merged.workingDirectory, 'file:///peer-worktree'); + assert.strictEqual(merged.workingDirectories?.[0], 'file:///peer-worktree'); }); test('falls back to the session working directory when the chat does not override it', () => { @@ -1709,12 +1709,12 @@ suite('Subagent URI helpers', () => { makeSessionState('file:///session-wd'), makeChatState(undefined), ); - assert.strictEqual(merged.workingDirectory, 'file:///session-wd'); + assert.strictEqual(merged.workingDirectories?.[0], 'file:///session-wd'); }); test('falls back to the session working directory when no chat state is hydrated', () => { const merged = mergeSessionWithDefaultChat(makeSessionState('file:///session-wd'), undefined); - assert.strictEqual(merged.workingDirectory, 'file:///session-wd'); + assert.strictEqual(merged.workingDirectories?.[0], 'file:///session-wd'); assert.deepStrictEqual(merged.turns, []); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index e64fc633322499..d155457681e0d6 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -1323,7 +1323,7 @@ suite('AgentService (node dispatcher)', () => { createdAt: new Date(1000).toISOString(), modifiedAt: new Date(2000).toISOString(), project: { uri: URI.file('/project').toString(), displayName: 'project' }, - workingDirectory: URI.file('/worktree').toString(), + workingDirectories: [URI.file('/worktree').toString()], }, []); agent.releaseList.complete(); @@ -4799,7 +4799,7 @@ suite('AgentService (node dispatcher)', () => { // The state manager should have the worktree path, not the source path const state = service.stateManager.getSessionState(session.toString()); - assert.strictEqual(state?.workingDirectory, worktreeDir.toString()); + assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString()); }); test('createSession falls back to config working directory when agent does not resolve', async () => { @@ -4811,7 +4811,7 @@ suite('AgentService (node dispatcher)', () => { const session = await service.createSession({ provider: 'copilot', workingDirectory: sourceDir }); const state = service.stateManager.getSessionState(session.toString()); - assert.strictEqual(state?.workingDirectory, sourceDir.toString()); + assert.strictEqual(state?.workingDirectories?.[0], sourceDir.toString()); }); test('restoreSession uses agent working directory in state', async () => { @@ -4830,7 +4830,7 @@ suite('AgentService (node dispatcher)', () => { await service.restoreSession(session); const state = service.stateManager.getSessionState(session.toString()); - assert.strictEqual(state?.workingDirectory, worktreeDir.toString()); + assert.strictEqual(state?.workingDirectories?.[0], worktreeDir.toString()); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 76ba4526923f8e..4010a6a6c890b1 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -167,7 +167,7 @@ suite('AgentSideEffects', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }); stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString())); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/workspaceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/workspaceSuite.ts index 8f316ad58a8054..d98abf9da51b41 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/workspaceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/workspaceSuite.ts @@ -40,12 +40,12 @@ export function defineWorkspaceTests(context: IAgentHostE2ETestContext): void { await context.client.call('authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: resolveGitHubToken() }, 30_000); const sessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); - await context.client.call('createSession', { channel: sessionUri, provider: config.provider, workingDirectory: workingDirUri }, 30_000); + await context.client.call('createSession', { channel: sessionUri, provider: config.provider, workingDirectories: [workingDirUri] }, 30_000); createdSessions.push(sessionUri); const subscribeResult = await context.client.call('subscribe', { channel: sessionUri }, 30_000); const sessionState = subscribeResult.snapshot!.state as SessionState; - assert.strictEqual(sessionState.workingDirectory, workingDirUri, + assert.strictEqual(sessionState.workingDirectories?.[0], workingDirUri, `subscribe snapshot summary should carry the requested working directory`); }); @@ -86,7 +86,7 @@ export function defineWorkspaceTests(context: IAgentHostE2ETestContext): void { const sessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); await context.client.call('createSession', { - channel: sessionUri, provider: config.provider, workingDirectory: workingDirUri, + channel: sessionUri, provider: config.provider, workingDirectories: [workingDirUri], config: { isolation: 'worktree', branch: defaultBranch }, }); createdSessions.push(sessionUri); @@ -124,10 +124,11 @@ export function defineWorkspaceTests(context: IAgentHostE2ETestContext): void { ); const addedSummary = (addedNotif.params as SessionAddedParams).summary; - assert.ok(addedSummary.workingDirectory, 'sessionAdded notification should have a workingDirectory'); - assert.ok(addedSummary.workingDirectory!.includes('.worktrees'), - `workingDirectory should be under the .worktrees folder, got: ${addedSummary.workingDirectory}`); - const resolvedWorkingDirectoryPath = URI.parse(addedSummary.workingDirectory!).fsPath; + const addedWorkingDirectory = addedSummary.workingDirectories?.[0]; + assert.ok(addedWorkingDirectory, 'sessionAdded notification should have a workingDirectory'); + assert.ok(addedWorkingDirectory.includes('.worktrees'), + `workingDirectory should be under the .worktrees folder, got: ${addedWorkingDirectory}`); + const resolvedWorkingDirectoryPath = URI.parse(addedWorkingDirectory).fsPath; await context.client.waitForNotification( n => isActionNotification(n, 'chat/turnComplete') || isActionNotification(n, 'chat/error'), diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts index fb12db0046f81f..93ac3840796936 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionConfig.integrationTest.ts @@ -98,7 +98,7 @@ suite('Protocol WebSocket - Session Config', function () { await client.call('createSession', { channel: nextSessionUri(), provider: 'mock', - workingDirectory: URI.file('/mock/workspace').toString(), + workingDirectories: [URI.file('/mock/workspace').toString()], config, }); @@ -181,7 +181,7 @@ suite('Protocol WebSocket - Session Config persistence across restarts', functio await client1.call('createSession', { channel: nextSessionUri(), provider: 'mock', - workingDirectory: URI.file('/mock/workspace').toString(), + workingDirectories: [URI.file('/mock/workspace').toString()], config: initialConfig, }); const addedNotif = await client1.waitForNotification(n => diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts index 96a07a844bc2e9..3d0ad44bc062c0 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionDiffs.integrationTest.ts @@ -81,7 +81,7 @@ const hasGit = (() => { await client.call('initialize', { protocolVersions: [PROTOCOL_VERSION], clientId: 'test-git-diffs' }); const workingDirectory = URI.file(tmpRoot).toString(); - await client.call('createSession', { channel: nextSessionUri(), provider: 'mock', workingDirectory }); + await client.call('createSession', { channel: nextSessionUri(), provider: 'mock', workingDirectories: [workingDirectory] }); const addedNotif = await client.waitForNotification(n => n.method === 'root/sessionAdded' diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index eb7f1ad08cd787..07b236cc8f5fed 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -117,7 +117,7 @@ class MockAgentService implements IAgentService { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///created-project', displayName: 'Created Project' }, - workingDirectory: config?.workingDirectory?.toString(), + workingDirectories: config?.workingDirectory ? [config.workingDirectory.toString()] : undefined, }); return session; } @@ -488,6 +488,46 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(envelope.origin.clientSeq, 1); }); + test('unsupported working-directory actions are rejected, not dispatched', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); + + const cases: readonly { readonly type: ActionType; readonly channel: string }[] = [ + { type: ActionType.SessionWorkingDirectorySet, channel: sessionUri }, + { type: ActionType.SessionWorkingDirectoryRemoved, channel: sessionUri }, + { type: ActionType.ChatWorkingDirectorySet, channel: defaultChatUri }, + { type: ActionType.ChatWorkingDirectoryRemoved, channel: defaultChatUri }, + ]; + + for (const [index, { type, channel }] of cases.entries()) { + const clientId = `wd-client-${index}`; + const clientSeq = 100 + index; + const transport = connectClient(clientId, [sessionUri, defaultChatUri]); + transport.sent.length = 0; + agentService.handledActions.length = 0; + + transport.simulateMessage(notification('dispatchAction', { + channel, + clientSeq, + action: { type, directory: 'file:///tmp/extra-root' }, + })); + + // No dispatch: the gate intercepts before reaching the agent service, + // so the reducer never runs and synchronized state is untouched. + assert.deepStrictEqual(agentService.handledActions, [], `${type} must not be dispatched`); + + // Exactly one rejection envelope, preserving the original origin so the + // client can reconcile its optimistic action. + const actionMsgs = findNotifications(transport.sent, 'action'); + assert.strictEqual(actionMsgs.length, 1, `${type} should emit exactly one envelope`); + const envelope = actionMsgs[0].params as unknown as { action: { type: string }; origin: { clientId: string; clientSeq: number }; rejectionReason?: string }; + assert.strictEqual(envelope.action.type, type); + assert.ok(envelope.rejectionReason, `${type} envelope should carry a rejectionReason`); + assert.strictEqual(envelope.origin.clientId, clientId); + assert.strictEqual(envelope.origin.clientSeq, clientSeq); + } + }); + test('actions are scoped to subscribed sessions', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); diff --git a/src/vs/platform/agentHost/test/node/providerIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/providerIntegrationTestHelpers.ts index ec862ed8790c54..92b860ef7cb90c 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegrationTestHelpers.ts @@ -32,7 +32,7 @@ export async function createProviderSession( await client.call('createSession', { channel: sessionUri, provider: config.provider, - workingDirectory: workingDirectory.toString(), + workingDirectories: [workingDirectory.toString()], config: { isolation: 'folder' }, }, 30_000); trackingList.push(sessionUri); diff --git a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts index 62194565d241ce..a2a964a75c5598 100644 --- a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts @@ -859,7 +859,7 @@ export function getActionEnvelope(n: AhpNotification): ActionEnvelope { export async function createAndSubscribeSession(c: TestProtocolClient, clientId: string, workingDirectory?: string): Promise { await c.call('initialize', { channel: 'ahp-root://', protocolVersions: [PROTOCOL_VERSION], clientId }); - await c.call('createSession', { channel: nextSessionUri(), provider: 'mock', workingDirectory }); + await c.call('createSession', { channel: nextSessionUri(), provider: 'mock', workingDirectories: workingDirectory ? [workingDirectory] : undefined }); const notif = await c.waitForNotification(n => n.method === 'root/sessionAdded' diff --git a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts index ac533239454a72..8c6de10b75f686 100644 --- a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts @@ -43,7 +43,7 @@ suite('SessionPermissionManager', () => { createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), project: { uri: 'file:///project', displayName: 'Project' }, - workingDirectory, + workingDirectories: workingDirectory ? [workingDirectory] : undefined, }; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index e72261072cf551..e06981547f6fe6 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -2934,7 +2934,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement getWorkingDirectory(sessionId: string): string | undefined { const sessionState = this._lastSessionStates.get(sessionId); - return sessionState?.workingDirectory; + return sessionState?.workingDirectories?.[0]; } getMcpServers(sessionId: string): readonly IAgentHostMcpServer[] { @@ -4224,8 +4224,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private _handleSessionAdded(summary: SessionSummary): void { const sessionUri = URI.parse(summary.resource); const rawId = AgentSession.id(sessionUri); - const workingDir = typeof summary.workingDirectory === 'string' - ? this.mapWorkingDirectoryUri(URI.parse(summary.workingDirectory)) + const workingDir = typeof summary.workingDirectories?.[0] === 'string' + ? this.mapWorkingDirectoryUri(URI.parse(summary.workingDirectories?.[0])) : undefined; const meta: IAgentSessionMetadata = { session: sessionUri, diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index 91a5ae46e4a37d..8e750e2323f440 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -436,7 +436,7 @@ function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: createdAt: opts?.createdAt ?? new Date().toISOString(), modifiedAt: opts?.modifiedAt ?? new Date().toISOString(), project: opts?.project, - workingDirectory: opts?.workingDirectory, + workingDirectories: opts?.workingDirectory ? [opts.workingDirectory] : undefined, changes: opts?.changes, ...(opts?.workspaceless ? { _meta: withSessionWorkspaceless(undefined, true) } : {}), }, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 9295326918f890..0454b3bac17596 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -280,7 +280,7 @@ function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: createdAt: opts?.createdAt ?? new Date().toISOString(), modifiedAt: opts?.modifiedAt ?? new Date().toISOString(), project: opts?.project, - workingDirectory: opts?.workingDirectory, + workingDirectories: opts?.workingDirectory ? [opts.workingDirectory] : undefined, }, }); } 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 e1b37d9a1ff213..c9bb50cab44871 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -681,7 +681,7 @@ export class AgentHostChatInputPicker extends Disposable { private _readWorkingDirectory(): URI | undefined { const state = this._subRef.value?.sub.value; if (state && !(state instanceof Error)) { - const cwd = state.workingDirectory; + const cwd = state.workingDirectories?.[0]; return typeof cwd === 'string' ? URI.parse(cwd) : cwd; } const sessionResource = this._widget.viewModel?.sessionResource; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index c0a662ec20eda1..acdc6b36774086 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -454,7 +454,7 @@ class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCustomizat const channel = target.backendSession.toString(); return { customizations: sessionState?.customizations ?? [], - workingDirectory: sessionState?.workingDirectory, + workingDirectory: sessionState?.workingDirectories?.[0], rootConfig: rootState && !(rootState instanceof Error) ? rootState.config : undefined, authenticate: request => target.connection.authenticate(request), setCustomizationEnabled: (rawId, enabled) => { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts index a0bf40b87b4a36..7130daf9d29b60 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostGenericConfigChips.ts @@ -140,7 +140,7 @@ export class AgentHostGenericConfigChips extends Disposable { private _readWorkingDirectory(): URI | undefined { const state = this._subRef.value?.sub.value; if (state && !(state instanceof Error)) { - const cwd = state.workingDirectory; + const cwd = state.workingDirectories?.[0]; return typeof cwd === 'string' ? URI.parse(cwd) : cwd; } const sessionResource = this._widget.viewModel?.sessionResource; 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 9a12802edf4402..2af3d651c23155 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -4647,7 +4647,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return uri; } const backendSession = this._resolveSessionUri(sessionResource); - const rawResolvedDir = this._getSessionState(backendSession.toString())?.workingDirectory; + const rawResolvedDir = this._getSessionState(backendSession.toString())?.workingDirectories?.[0]; const resolvedDir = typeof rawResolvedDir === 'string' ? URI.parse(rawResolvedDir) : rawResolvedDir; if (!resolvedDir || resolvedDir.scheme !== 'file') { return uri; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index 8bd0a37b38d756..c5afb96d21f0fa 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -175,7 +175,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS } private _makeItemFromSummary(rawId: string, summary: SessionSummary): IChatSessionItem { - const workingDir = typeof summary.workingDirectory === 'string' ? URI.parse(summary.workingDirectory) : summary.workingDirectory; + const workingDir = typeof summary.workingDirectories?.[0] === 'string' ? URI.parse(summary.workingDirectories?.[0]) : summary.workingDirectories?.[0]; return this._makeItem(rawId, { title: summary.title, status: summary.status, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 41b12a8d17454f..6007edb6a972f0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -251,7 +251,7 @@ export class AgentHostSessionListStore extends Disposable { private _onNotification(notification: INotification): void { if (notification.type === 'root/sessionAdded') { - if (!this._isWorkingDirectoryInWorkspace(notification.summary.workingDirectory)) { + if (!this._isWorkingDirectoryInWorkspace(notification.summary.workingDirectories?.[0])) { return; } const entry = this._makeEntryFromSummary(notification.summary); @@ -284,7 +284,7 @@ export class AgentHostSessionListStore extends Disposable { } const updatedSummary = { ...cached.summary, ...notification.changes }; - if (!this._isWorkingDirectoryInWorkspace(updatedSummary.workingDirectory)) { + if (!this._isWorkingDirectoryInWorkspace(updatedSummary.workingDirectories?.[0])) { this.removeSession(provider, rawId); return; } @@ -323,7 +323,7 @@ export class AgentHostSessionListStore extends Disposable { createdAt: new Date(session.startTime).toISOString(), modifiedAt: new Date(session.modifiedTime).toISOString(), changes: session.changes, - workingDirectory: session.workingDirectory?.toString(), + workingDirectories: session.workingDirectory ? [session.workingDirectory.toString()] : undefined, }, }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 9b94311a59e0c3..fbf5f0dc9fa65a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -225,6 +225,7 @@ class MockAgentHostService extends mock() { // Simulate the server's eager active-client claim: if the caller // provided activeClient, seed the session state so subscribers see it. if (config?.activeClient) { + const resolvedWorkingDir = (this.nextResolvedWorkingDirectory ?? config.workingDirectory)?.toString(); const summary: SessionSummary = { resource: session.toString(), provider: 'copilot', @@ -232,7 +233,7 @@ class MockAgentHostService extends mock() { status: SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: (this.nextResolvedWorkingDirectory ?? config.workingDirectory)?.toString(), + workingDirectories: resolvedWorkingDir ? [resolvedWorkingDir] : undefined, }; const state: SessionState = { ...this._withDefaultChatCatalog(createSessionState(summary), session.toString()), @@ -503,7 +504,7 @@ class MockAgentHostService extends mock() { status: seeded?.status ?? SessionStatus.Idle, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: seeded?.workingDirectory, + workingDirectories: seeded?.workingDirectories, project: seeded?.project, }; const chatSummary = createDefaultChatSummary(sessionSummary, chatUriStr); @@ -529,7 +530,7 @@ class MockAgentHostService extends mock() { status: state.status, createdAt: new Date().toISOString(), modifiedAt: new Date().toISOString(), - workingDirectory: state.workingDirectory, + workingDirectories: state.workingDirectories, project: state.project, }; const chatUri = buildDefaultChatUri(resource); @@ -2546,7 +2547,7 @@ suite('AgentHostChatContribution', () => { status: SessionStatus.Idle, createdAt: new Date(1000).toISOString(), modifiedAt: new Date(2000).toISOString(), - workingDirectory: URI.file('/other/workspace').toString(), + workingDirectories: [URI.file('/other/workspace').toString()], }, } as INotification); @@ -2563,7 +2564,7 @@ suite('AgentHostChatContribution', () => { status: SessionStatus.Idle, createdAt: new Date(1000).toISOString(), modifiedAt: new Date(2000).toISOString(), - workingDirectory: URI.file('/workspace/root/sub').toString(), + workingDirectories: [URI.file('/workspace/root/sub').toString()], }, } as INotification); From 07745f1d764f390795e99d0d4e224918d149a49d Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Wed, 22 Jul 2026 16:26:28 +0100 Subject: [PATCH 12/34] Style overrides: Consolidate CSS selectors and unify surface color tokens (#326948) * perf: consolidate style-override CSS selectors Merge the duplicate .tab.sticky-compact geometry blocks in tabs.css and collapse the focus selector lists in keyboardFocusOnly.css with :is() (specificity preserved) to reduce the number of matched selector entries. * style: update surface color tokens and unify font weights across components Co-authored-by: Copilot --------- Co-authored-by: mrleemurray Co-authored-by: Copilot --- .../lib/stylelint/vscode-known-variables.json | 3 +++ .../theme-defaults/themes/2026-dark.json | 3 +++ .../theme-defaults/themes/2026-light.json | 3 +++ .../browser/media/floatingPanels.css | 13 +++++----- src/vs/workbench/common/theme.ts | 24 +++++++++++++++++++ .../browser/media/activityBar.css | 2 +- .../browser/media/editorBorder.css | 18 +++++++------- .../styleOverrides/browser/media/fontRamp.css | 4 ++-- .../browser/media/keyboardFocusOnly.css | 14 +++-------- .../browser/media/notificationsDialogs.css | 2 +- .../styleOverrides/browser/media/tabs.css | 21 +++++++--------- 11 files changed, 65 insertions(+), 42 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 17677971787ead..183ee230bb10ad 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -662,6 +662,9 @@ "--vscode-searchEditor-findMatchBorder", "--vscode-searchEditor-textInputBorder", "--vscode-selection-background", + "--vscode-surface-background", + "--vscode-surface-border", + "--vscode-surface-foreground", "--vscode-agentsPanel-background", "--vscode-agentsPanel-border", "--vscode-agentsPanel-foreground", diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index 79d155072fcfc6..1b0e5998a98370 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -284,6 +284,9 @@ "agentsPanel.background": "#191A1B", "agentsPanel.foreground": "#bfbfbf", "agentsPanel.border": "#2A2B2CFF", + "surface.background": "#191A1B", + "surface.foreground": "#bfbfbf", + "surface.border": "#2A2B2CFF", "agentsGradient.tintColor": "#297AA0", "agentsChatInput.background": "#202122", "agentsChatInput.foreground": "#bfbfbf", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index 5387b2cb1a5cc2..c69e10b9f194b2 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -292,6 +292,9 @@ "agentsPanel.background": "#FFFFFF", "agentsPanel.foreground": "#202020", "agentsPanel.border": "#E4E5E6FF", + "surface.background": "#FFFFFF", + "surface.foreground": "#202020", + "surface.border": "#E4E5E6FF", "agentsGradient.tintColor": "#0069CC", "agentsChatInput.background": "#F7F7FA", "agentsChatInput.foreground": "#202020", diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index 8dfecec90d36ba..abed648bbcfb84 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -13,7 +13,8 @@ * the inner layout stays in sync with this CSS. The margin and the 1px border * must match `FLOATING_PANEL_MARGIN` and the border inset used there. The outermost * card on a window edge gets a doubled outer gutter (`.floating-part-outer-*`). - * Colors mirror the agents window (`agentsPanel.*` tokens). + * Colors come from the generic `surface.*` tokens (shared with the agent + * sessions window). * * Uses `--vscode-spacing-size40` (4px); keep this in sync with * `FLOATING_PANEL_MARGIN` in code. @@ -22,19 +23,19 @@ .monaco-workbench.floating-panels .part.sidebar, .monaco-workbench.floating-panels .part.auxiliarybar { margin: 0 var(--vscode-spacing-size20) var(--vscode-spacing-size20) var(--vscode-spacing-size40); - border: 1px solid var(--vscode-agentsPanel-border, var(--vscode-widget-border, transparent)) !important; + border: 1px solid var(--vscode-surface-border, var(--vscode-widget-border, transparent)) !important; border-radius: var(--vscode-cornerRadius-large); - background-color: var(--vscode-agentsPanel-background) !important; - color: var(--vscode-agentsPanel-foreground); + background-color: var(--vscode-surface-background) !important; + color: var(--vscode-surface-foreground); } /* Keep the panel surface aligned with the panel theme background in light theme. */ .monaco-workbench.floating-panels .part.panel { margin: var(--vscode-spacing-size40) var(--vscode-spacing-size20) var(--vscode-spacing-size20) var(--vscode-spacing-size40); - border: 1px solid var(--vscode-agentsPanel-border, var(--vscode-widget-border, transparent)) !important; + border: 1px solid var(--vscode-surface-border, var(--vscode-widget-border, transparent)) !important; border-radius: var(--vscode-cornerRadius-large); background-color: var(--vscode-panel-background) !important; - color: var(--vscode-agentsPanel-foreground); + color: var(--vscode-surface-foreground); } .monaco-workbench.floating-panels .part.editor > .content { diff --git a/src/vs/workbench/common/theme.ts b/src/vs/workbench/common/theme.ts index 6288b6ff91d139..1622463f92fcce 100644 --- a/src/vs/workbench/common/theme.ts +++ b/src/vs/workbench/common/theme.ts @@ -5,6 +5,7 @@ import { localize } from '../../nls.js'; import { registerColor, editorBackground, contrastBorder, transparent, editorWidgetBackground, textLinkForeground, lighten, darken, focusBorder, activeContrastBorder, editorWidgetForeground, editorErrorForeground, editorWarningForeground, editorInfoForeground, treeIndentGuidesStroke, errorForeground, listActiveSelectionBackground, listActiveSelectionForeground, listInactiveSelectionBackground, editorForeground, toolbarHoverBackground, inputBorder, widgetBorder, scrollbarShadow } from '../../platform/theme/common/colorRegistry.js'; +import { foreground } from '../../platform/theme/common/colors/baseColors.js'; import { IColorTheme } from '../../platform/theme/common/themeService.js'; import { Color } from '../../base/common/color.js'; import { ColorScheme } from '../../platform/theme/common/theme.js'; @@ -658,6 +659,29 @@ export const SIDE_BAR_STICKY_SCROLL_BORDER = registerColor('sideBarStickyScroll. export const SIDE_BAR_STICKY_SCROLL_SHADOW = registerColor('sideBarStickyScroll.shadow', scrollbarShadow, localize('sideBarStickyScrollShadow', "Shadow color of sticky scroll in the side bar.")); +// < --- Surface --- > + +// Generic framed container surfaces ("cards"). Used by the modern workbench +// layout to frame the floating parts (side bar, panel, auxiliary bar, editor). +// Defaults mirror the agent sessions window's panel treatment so the look is +// shared, but themes can target these tokens independently. + +export const SURFACE_BACKGROUND = registerColor('surface.background', { + dark: SIDE_BAR_BACKGROUND, + light: editorBackground, + hcDark: SIDE_BAR_BACKGROUND, + hcLight: SIDE_BAR_BACKGROUND +}, localize('surfaceBackground', "Background color of framed container surfaces (\"cards\"), such as the floating workbench panels in the modern layout.")); + +export const SURFACE_FOREGROUND = registerColor('surface.foreground', SIDE_BAR_FOREGROUND, localize('surfaceForeground', "Foreground color of framed container surfaces (\"cards\"), such as the floating workbench panels in the modern layout.")); + +export const SURFACE_BORDER = registerColor('surface.border', { + dark: transparent(foreground, 0.15), + light: transparent(foreground, 0.15), + hcDark: contrastBorder, + hcLight: contrastBorder +}, localize('surfaceBorder', "Border color of framed container surfaces (\"cards\"), such as the floating workbench panels in the modern layout.")); + // < --- Title Bar --- > export const TITLE_BAR_ACTIVE_FOREGROUND = registerColor('titleBar.activeForeground', { diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css b/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css index f07ba154103f60..01ea5973ba7803 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css @@ -310,7 +310,7 @@ .style-override.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .codicon, .style-override.monaco-workbench .pane-composite-part.basepanel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .codicon { - font-weight: var(--vscode-agents-fontWeight-regular); + font-weight: var(--vscode-fontWeight-regular); } /* Space the composite items apart with a small gap in the auxiliary bar and base panel. */ diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css b/src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css index ac3bc3ffd7330d..ea6b6daca470f0 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css @@ -42,28 +42,28 @@ /* * Unify the surrounding chrome with the editor card: the floating side bar, * auxiliary bar and bottom panel cards (see `browser/media/floatingPanels.css`) - * default to the `agentsPanel` border token. Re-point them at the same -* `editorGroup-border` token the editor card uses above so every top-level card - * shares one border color. Only the color is overridden; the width and radius - * from floatingPanels.css are kept. The extra `.monaco-workbench` class (both - * `.style-override` and `.floating-panels` sit on the workbench element) raises - * specificity above the equally-`!important` rule in floatingPanels.css. + * use the generic `surface.border` token. Re-point every top-level card — + * including the editor — at that same token so they all share one border color. + * Only the color is overridden; the width and radius from floatingPanels.css are + * kept. The extra `.monaco-workbench` class (both `.style-override` and + * `.floating-panels` sit on the workbench element) raises specificity above the + * equally-`!important` rule in floatingPanels.css. */ .monaco-workbench.style-override.floating-panels .part.sidebar, .monaco-workbench.style-override.floating-panels .part.auxiliarybar, .monaco-workbench.style-override.floating-panels .part.panel, .monaco-workbench.style-override.floating-panels .part.editor { - border-color: var(--vscode-agentsPanel-border, color-mix(in srgb, var(--vscode-foreground) 12%, transparent)) !important; + border-color: var(--vscode-surface-border, color-mix(in srgb, var(--vscode-foreground) 12%, transparent)) !important; } .monaco-workbench.style-override .chat-view-position-left > .voice-agent-controls-wrapper { .agent-sessions-container { - border-right-color: var(--vscode-agentsPanel-border, color-mix(in srgb, var(--vscode-foreground) 12%, transparent)) !important; + border-right-color: var(--vscode-surface-border, color-mix(in srgb, var(--vscode-foreground) 12%, transparent)) !important; } } .monaco-workbench.style-override .chat-view-position-right > .voice-agent-controls-wrapper { .agent-sessions-container { - border-left-color: var(--vscode-agentsPanel-border, color-mix(in srgb, var(--vscode-foreground) 12%, transparent)) !important; + border-left-color: var(--vscode-surface-border, color-mix(in srgb, var(--vscode-foreground) 12%, 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 12d8ef74bbb3d4..d7205bccb1109a 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css @@ -145,7 +145,7 @@ * stays in one module. */ .style-override.monaco-workbench > .notifications-center > .notifications-center-header > .notifications-center-header-title { - font-weight: var(--vscode-agents-fontWeight-semiBold); + font-weight: var(--vscode-fontWeight-semiBold); } /* @@ -199,7 +199,7 @@ } .agent-session-item .agent-session-details-row { - font-size: var(--vscode-agents-fontSize-label2); + font-size: var(--vscode-fontSize-label2); } } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css b/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css index b887f6d7cac8f8..0e65b9873fba8c 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/keyboardFocusOnly.css @@ -27,9 +27,7 @@ * `:focus` but not `:focus-visible`, so drop the row ring in that case. Keyboard * focus (`:focus-visible`) keeps it. */ -.style-override .part.sidebar .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused, -.style-override .part.panel .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused, -.style-override .part.auxiliarybar .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused { +.style-override :is(.part.sidebar, .part.panel, .part.auxiliarybar) .monaco-list:focus:not(:focus-visible) .monaco-list-row.focused { outline: 0 !important; } @@ -40,13 +38,7 @@ * keyboard focus. The part element is listed explicitly because a descendant * combinator would not match the part itself. */ -.style-override .part.sidebar:focus:not(:focus-visible), -.style-override .part.sidebar :focus:not(:focus-visible), -.style-override .part.panel:focus:not(:focus-visible), -.style-override .part.panel :focus:not(:focus-visible), -.style-override .part.auxiliarybar:focus:not(:focus-visible), -.style-override .part.auxiliarybar :focus:not(:focus-visible), -.style-override .part.statusbar:focus:not(:focus-visible), -.style-override .part.statusbar :focus:not(:focus-visible) { +.style-override :is(.part.sidebar, .part.panel, .part.auxiliarybar, .part.statusbar):focus:not(:focus-visible), +.style-override :is(.part.sidebar, .part.panel, .part.auxiliarybar, .part.statusbar) :focus:not(:focus-visible) { outline-color: transparent !important; } diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/notificationsDialogs.css b/src/vs/workbench/contrib/styleOverrides/browser/media/notificationsDialogs.css index c401f74bcf4f3c..705e2fb2c7e8b7 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/notificationsDialogs.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/notificationsDialogs.css @@ -92,7 +92,7 @@ .style-override .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message { margin: 2px 0 12px 0; font-size: var(--vscode-fontSize-heading3); - font-weight: var(--vscode-agents-fontWeight-semiBold); + font-weight: var(--vscode-fontWeight-semiBold); } .style-override .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message-detail { diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css index 6cfefbf0f3704c..e484e7b06f4a43 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css @@ -47,6 +47,10 @@ .style-override .part.editor .tabs-container > .tab.sticky-compact { justify-content: center; padding: 0 !important; + width: 28px !important; + min-width: 28px !important; + max-width: 28px !important; + flex: 0 0 28px !important; } .style-override .part.editor .tabs-container > .tab.sticky-compact > .tab-label { @@ -87,13 +91,6 @@ flex: 0 0 auto !important; } -.style-override .part.editor .tabs-container > .tab.sticky-compact { - width: 28px !important; - min-width: 28px !important; - max-width: 28px !important; - flex: 0 0 28px !important; -} - /* Center tabs vertically and strip the bottom border so the pills float. */ .style-override .part.editor .tabs-and-actions-container { --tabs-border-bottom-color: transparent !important; @@ -229,7 +226,7 @@ */ .style-override .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { font-size: var(--vscode-fontSize-body1); - font-weight: var(--vscode-agents-fontWeight-semiBold); + font-weight: var(--vscode-fontWeight-semiBold); line-height: 22px; /* keep consistent with other 22px title/control heights */ } @@ -273,14 +270,14 @@ .style-override .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, .style-override .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { font-size: var(--vscode-fontSize-body1); - font-weight: var(--vscode-agents-fontWeight-semiBold); + font-weight: var(--vscode-fontWeight-semiBold); line-height: 22px; /* keep consistent with other 22px title/control heights */ } .style-override .part.sidebar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label, .style-override .part.sidebar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label { font-size: var(--vscode-fontSize-body1); - font-weight: var(--vscode-agents-fontWeight-semiBold); + font-weight: var(--vscode-fontWeight-semiBold); } .style-override .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked, @@ -307,7 +304,7 @@ .style-override .part.editor > .content .editor-group-container > .title .tabs-container > .tab .tab-label a { font-size: var(--vscode-fontSize-body1); - font-weight: var(--vscode-agents-fontWeight-semiBold); + font-weight: var(--vscode-fontWeight-semiBold); } /* @@ -349,7 +346,7 @@ border-radius: var(--vscode-cornerRadius-small) !important; border-bottom: none !important; font-size: var(--vscode-fontSize-body1); - font-weight: var(--vscode-agents-fontWeight-semiBold); + font-weight: var(--vscode-fontWeight-semiBold); padding: 2px 8px !important; } From e83f568ee93dd65785cada05e6d00160839bfd20 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Wed, 22 Jul 2026 16:26:31 +0100 Subject: [PATCH 13/34] Style override: Add top margin for compact activity bar in floating panels (#326986) style: add top margin for compact activity bar in floating panels Co-authored-by: mrleemurray --- src/vs/workbench/browser/media/floatingPanels.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index abed648bbcfb84..ad02e927f79ba3 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -168,6 +168,10 @@ margin-right: var(--vscode-spacing-size40); } +.monaco-workbench.floating-panels .activitybar.compact > .content > .composite-bar { + margin-top: var(--vscode-spacing-size20); +} + /* At the default (non-compact) size, separate the activity bar items with a 4px gap * so they read as distinct floating targets. Compact keeps the tighter default stack. */ .monaco-workbench.floating-panels .part.activitybar:not(.compact) > .content .monaco-action-bar .action-item + .action-item { From 7ecf4833cd5851d1f0457ac380183fa2dc40c197 Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Wed, 22 Jul 2026 11:59:29 -0400 Subject: [PATCH 14/34] Cache quota snapshot so missing values in CAPI's snapshot doesn't create UI flickering (#326991) * Cache quota snapshot so missing values in CAPI's snapshot doesn't create UI flickering * fix compile --- .../agentHost/agentHostSessionHandler.ts | 11 +-- .../common/chatEntitlementService.test.ts | 98 ++++++++++++++++++- .../chat/common/chatEntitlementService.ts | 23 ++++- 3 files changed, 122 insertions(+), 10 deletions(-) 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 2af3d651c23155..0ac5df5b8c0c73 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -2161,9 +2161,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Surface the account quota snapshots the agent host reports on each model-call usage event // into the entitlement service, keeping the quota UI current for agent-host sessions (mirrors - // the extension-host CLI path). `acceptQuotas` replaces state, so shallow-merge the top-level - // container and deep-merge each per-category snapshot to preserve fields the usage event - // doesn't carry (e.g. `hasQuota`, `usageBasedBilling` from a prior full entitlement fetch). + // the extension-host CLI path). `acceptQuotas` replaces top-level state and merges fields + // within each provided category snapshot. let lastQuotaSignature: string | undefined; store.add(autorun(reader => { const quotaUpdate = usageInfoToQuotas(usage$.read(reader)); @@ -2175,13 +2174,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return; } lastQuotaSignature = signature; - const existing = this._chatEntitlementService.quotas; this._chatEntitlementService.acceptQuotas({ - ...existing, + ...this._chatEntitlementService.quotas, ...quotaUpdate, - chat: quotaUpdate.chat ? { ...existing.chat, ...quotaUpdate.chat } : existing.chat, - completions: quotaUpdate.completions ? { ...existing.completions, ...quotaUpdate.completions } : existing.completions, - premiumChat: quotaUpdate.premiumChat ? { ...existing.premiumChat, ...quotaUpdate.premiumChat } : existing.premiumChat, }); })); diff --git a/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts index 32f8ac69d34610..e6998016bb6f42 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts @@ -5,8 +5,17 @@ import assert from 'assert'; import { IEntitlementsData } from '../../../../../base/common/defaultAccount.js'; +import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { parseQuotas } from '../../../../services/chat/common/chatEntitlementService.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { ChatEntitlementService, parseQuotas } from '../../../../services/chat/common/chatEntitlementService.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; +import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; suite('parseQuotas', () => { @@ -491,3 +500,90 @@ suite('parseQuotas', () => { assert.strictEqual(quotas.additionalUsageEnabled, true); }); }); + +suite('ChatEntitlementService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createService(): ChatEntitlementService { + return store.add(new ChatEntitlementService( + store.add(new TestInstantiationService()), + new class extends mock() { }, + new class extends mock() { }, + store.add(new MockContextKeyService()), + new TestConfigurationService(), + NullTelemetryService, + new NullLogService(), + store.add(new TestStorageService()), + )); + } + + test('merges defined snapshot fields until the snapshot is removed', () => { + const service = createService(); + service.acceptQuotas({ + premiumChat: { + percentRemaining: 90, + unlimited: true, + hasQuota: true, + resetAt: 100, + usageBasedBilling: true, + entitlement: 1000, + quotaRemaining: 900, + creditsUsed: 100, + }, + }); + + service.acceptQuotas({ + premiumChat: { + percentRemaining: 80, + unlimited: true, + hasQuota: undefined, + resetAt: undefined, + usageBasedBilling: undefined, + entitlement: undefined, + quotaRemaining: undefined, + creditsUsed: undefined, + }, + }); + const merged = service.quotas.premiumChat; + + service.acceptQuotas({ + premiumChat: { + percentRemaining: 70, + unlimited: false, + hasQuota: false, + resetAt: 200, + usageBasedBilling: false, + entitlement: 2000, + quotaRemaining: 1300, + creditsUsed: 700, + }, + }); + const updated = service.quotas.premiumChat; + + service.acceptQuotas({}); + + assert.deepStrictEqual({ merged, updated, removed: service.quotas.premiumChat }, { + merged: { + percentRemaining: 80, + unlimited: true, + hasQuota: true, + resetAt: 100, + usageBasedBilling: true, + entitlement: 1000, + quotaRemaining: 900, + creditsUsed: 100, + }, + updated: { + percentRemaining: 70, + unlimited: false, + hasQuota: false, + resetAt: 200, + usageBasedBilling: false, + entitlement: 2000, + quotaRemaining: 1300, + creditsUsed: 700, + }, + removed: undefined, + }); + }); +}); diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index edff01b34b2a5f..156058a96dcf99 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -534,6 +534,7 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme readonly onDidChangeUsageBasedBilling = this._onDidChangeUsageBasedBilling.event; private _quotas: IQuotas; + private quotaCopilotTrackingId: string | undefined; get quotas() { return this._quotas; } private readonly chatQuotaExceededContextKey: IContextKey; @@ -584,8 +585,18 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme this._register(this.onDidChangeSentiment(() => updateAnonymousUsage())); } - acceptQuotas(quotas: IQuotas): void { + acceptQuotas(incomingQuotas: IQuotas): void { const oldQuota = this._quotas; + const cachedQuota = this.quotaCopilotTrackingId === this.copilotTrackingId ? oldQuota : {}; + const quotas: IQuotas = { + ...incomingQuotas, + chat: incomingQuotas.chat ? mergeDefinedSnapshot(cachedQuota.chat, incomingQuotas.chat) : undefined, + completions: incomingQuotas.completions ? mergeDefinedSnapshot(cachedQuota.completions, incomingQuotas.completions) : undefined, + premiumChat: incomingQuotas.premiumChat ? mergeDefinedSnapshot(cachedQuota.premiumChat, incomingQuotas.premiumChat) : undefined, + sessionRateLimit: incomingQuotas.sessionRateLimit ? mergeDefinedSnapshot(cachedQuota.sessionRateLimit, incomingQuotas.sessionRateLimit) : undefined, + weeklyRateLimit: incomingQuotas.weeklyRateLimit ? mergeDefinedSnapshot(cachedQuota.weeklyRateLimit, incomingQuotas.weeklyRateLimit) : undefined, + }; + this.quotaCopilotTrackingId = this.copilotTrackingId; this._quotas = quotas; this.updateContextKeys(); @@ -824,6 +835,16 @@ interface IQuotas { readonly weeklyRateLimit?: IRateLimitSnapshot; } +function mergeDefinedSnapshot(previous: T | undefined, current: T): T { + const result = { ...previous, ...current }; + for (const key of Object.keys(current) as (keyof T)[]) { + if (current[key] === undefined && previous?.[key] !== undefined) { + result[key] = previous[key]; + } + } + return result; +} + export function parseQuotas(entitlementsData: IEntitlementsData): IQuotas { const quotas: Mutable = { resetDate: entitlementsData.quota_reset_date_utc ?? entitlementsData.quota_reset_date ?? entitlementsData.limited_user_reset_date, From 9fd648ee4e26f0fddcfea482d296b93ab2f12da6 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Wed, 22 Jul 2026 17:18:53 +0100 Subject: [PATCH 15/34] Update codicons version to 0.0.46-23 and add new icons (#326992) Update codicons version to 0.0.46-23 and add new icons to codicons library Co-authored-by: mrleemurray --- package-lock.json | 8 ++++---- package.json | 2 +- remote/web/package-lock.json | 8 ++++---- remote/web/package.json | 2 +- src/vs/base/common/codiconsLibrary.ts | 7 +++++++ 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0711289dc312d4..49af70d5fec888 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@microsoft/mxc-sdk": "0.6.1", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-21", + "@vscode/codicons": "^0.0.46-23", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", @@ -3930,9 +3930,9 @@ } }, "node_modules/@vscode/codicons": { - "version": "0.0.46-21", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-21.tgz", - "integrity": "sha512-HmSK8We0/P3epjr6Bpty8/6gs3N9TrLCU1KdL6CUmyOPvkdhZs+8BfBIX4DdxzrsOjz6zTrFp7LI3OMWcZtRqA==", + "version": "0.0.46-23", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-23.tgz", + "integrity": "sha512-rk7p4Jk5Pydm8akIMBPIIXXkpvK5vHXu8j/gxx72Fy5+yfZYHDH4MhENOlDjCtDmMS20UFeTxDqBcUlvP0w8mg==", "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { diff --git a/package.json b/package.json index 4ab907d940f8a8..dbb5da5bc8e03c 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ "@microsoft/mxc-sdk": "0.6.1", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-21", + "@vscode/codicons": "^0.0.46-23", "@vscode/copilot-api": "^0.4.2", "@vscode/deviceid": "^0.1.1", "@vscode/diff": "0.0.2-7", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 03356b782d6a75..18fc98fa361dd2 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-21", + "@vscode/codicons": "^0.0.46-23", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.46-21", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-21.tgz", - "integrity": "sha512-HmSK8We0/P3epjr6Bpty8/6gs3N9TrLCU1KdL6CUmyOPvkdhZs+8BfBIX4DdxzrsOjz6zTrFp7LI3OMWcZtRqA==", + "version": "0.0.46-23", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-23.tgz", + "integrity": "sha512-rk7p4Jk5Pydm8akIMBPIIXXkpvK5vHXu8j/gxx72Fy5+yfZYHDH4MhENOlDjCtDmMS20UFeTxDqBcUlvP0w8mg==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index 9426a17894c2d6..05af2c1da6ab7d 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-21", + "@vscode/codicons": "^0.0.46-23", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index dc21c50d3b92d1..d84f6713838ec9 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -728,4 +728,11 @@ export const codiconsLibrary = { vscodeOutline: register('vscode-outline', 0xecca), voiceMode: register('voice-mode', 0xeccb), voiceModeCompact: register('voice-mode-compact', 0xeccc), + micDownload: register('mic-download', 0xeccd), + micDownloadCompact: register('mic-download-compact', 0xecce), + voiceModeDownload: register('voice-mode-download', 0xeccf), + voiceModeDownloadCompact: register('voice-mode-download-compact', 0xecd0), + googleGemini: register('google-gemini', 0xecd1), + kimi: register('kimi', 0xecd2), + microsoft: register('microsoft', 0xecd3), } as const; From 24be8b3cb4b0ad4b6cd0cee68a79ab5e9d808a80 Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:23:06 -0700 Subject: [PATCH 16/34] chore: npm audit fix (#326903) Co-authored-by: Dmitriy Vasyura --- build/package-lock.json | 36 ++-- build/rspack/package-lock.json | 4 +- extensions/copilot/package-lock.json | 130 ++++++++------ .../extension-editing/package-lock.json | 6 +- .../package-lock.json | 18 +- .../package-lock.json | 6 +- extensions/npm/package-lock.json | 6 +- package-lock.json | 170 ++++++++++++------ test/automation/package-lock.json | 12 +- test/integration/browser/package-lock.json | 6 +- test/mcp/package-lock.json | 24 +-- test/monaco/package-lock.json | 6 +- 12 files changed, 257 insertions(+), 167 deletions(-) diff --git a/build/package-lock.json b/build/package-lock.json index 4271a5f06a58b1..e9178ced494f60 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -2145,9 +2145,9 @@ } }, "node_modules/@vscode/vsce/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -2568,9 +2568,9 @@ "license": "BSD-2-Clause" }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3491,9 +3491,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -4334,9 +4334,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -4494,9 +4494,9 @@ } }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "dev": true, "funding": [ { @@ -6778,9 +6778,9 @@ } }, "node_modules/vscode-universal-bundler/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/build/rspack/package-lock.json b/build/rspack/package-lock.json index 5631e280b28810..1e7f2a9ca66a75 100644 --- a/build/rspack/package-lock.json +++ b/build/rspack/package-lock.json @@ -871,7 +871,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.2", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index b1eeaca858b350..f53cead67afd31 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -3238,9 +3238,9 @@ "dev": true }, "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -6509,9 +6509,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -7273,9 +7273,9 @@ } }, "node_modules/@vscode/test-cli/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -8306,21 +8306,34 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", "engines": { "node": ">=18" }, @@ -8359,9 +8372,9 @@ "license": "BSD-2-Clause" }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -10233,9 +10246,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -10849,9 +10862,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -12421,9 +12434,9 @@ } }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "funding": [ { "type": "github", @@ -12823,9 +12836,9 @@ } }, "node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -12996,9 +13009,9 @@ } }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -14431,9 +14444,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", - "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -15466,9 +15479,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "dev": true, "license": "MIT", "engines": { @@ -16179,9 +16192,9 @@ } }, "node_modules/tar": { - "version": "7.5.16", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", - "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", + "version": "7.5.21", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.21.tgz", + "integrity": "sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -16545,17 +16558,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/type-is/node_modules/mime-db": { diff --git a/extensions/extension-editing/package-lock.json b/extensions/extension-editing/package-lock.json index 6dde8abc8f3c8f..d5b7ea024994b1 100644 --- a/extensions/extension-editing/package-lock.json +++ b/extensions/extension-editing/package-lock.json @@ -79,9 +79,9 @@ "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==" }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "funding": [ { "type": "github", diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index d25ee58a83a718..141cfbcf3398ed 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -675,9 +675,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1362,9 +1362,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -1488,9 +1488,9 @@ "license": "MIT" }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "funding": [ { "type": "github", diff --git a/extensions/mermaid-markdown-features/package-lock.json b/extensions/mermaid-markdown-features/package-lock.json index 3ffd72c4c3f65e..aae91ce161222a 100644 --- a/extensions/mermaid-markdown-features/package-lock.json +++ b/extensions/mermaid-markdown-features/package-lock.json @@ -1845,9 +1845,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" diff --git a/extensions/npm/package-lock.json b/extensions/npm/package-lock.json index cb3279147734d8..f1e1de705b799c 100644 --- a/extensions/npm/package-lock.json +++ b/extensions/npm/package-lock.json @@ -63,9 +63,9 @@ "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c= sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==" }, "node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" diff --git a/package-lock.json b/package-lock.json index 49af70d5fec888..faa6f4f1e7fb2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2922,9 +2922,9 @@ } }, "node_modules/@ts-morph/common/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -3443,9 +3443,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -3947,9 +3947,9 @@ } }, "node_modules/@vscode/component-explorer-cli": { - "version": "0.2.1-63", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.2.1-63.tgz", - "integrity": "sha512-DzPPaUXU/xB4zN2Urx9CP07TG/hItWVEaeWKp9j52BMTev6fWkVdh3YBVQGAxIEvoAVAuQSUlPUGdf8e9IkePw==", + "version": "0.2.1-71", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.2.1-71.tgz", + "integrity": "sha512-wA3V4T8N4FFc4MLJTPCHitWdQA2OP6JY9GadPrAE0XC7P/nFDPAiSPWaWwABsGal9TrMnh6efDXQ2S7D8z7+gw==", "dev": true, "license": "MIT", "dependencies": { @@ -4117,9 +4117,9 @@ } }, "node_modules/@vscode/l10n-dev/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -4495,9 +4495,9 @@ } }, "node_modules/@vscode/test-cli/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -5558,16 +5558,42 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axios/node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -5846,21 +5872,21 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -5870,6 +5896,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -5877,9 +5917,9 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -8912,9 +8952,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -11714,9 +11754,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", "dev": true, "license": "MIT", "engines": { @@ -13041,9 +13081,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -14310,9 +14350,9 @@ } }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -17352,9 +17392,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -19127,18 +19167,36 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "dev": true, "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/type-is/node_modules/mime-db": { @@ -19875,9 +19933,9 @@ } }, "node_modules/vite-plugin-istanbul/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -19917,9 +19975,9 @@ "license": "MIT" }, "node_modules/vite-plugin-istanbul/node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/test/automation/package-lock.json b/test/automation/package-lock.json index e8dca87b0a3745..f7b64e5f70abe8 100644 --- a/test/automation/package-lock.json +++ b/test/automation/package-lock.json @@ -101,9 +101,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -830,9 +830,9 @@ ] }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "dev": true, "license": "MIT", "engines": { diff --git a/test/integration/browser/package-lock.json b/test/integration/browser/package-lock.json index 4c93085a4527c8..2e4623a460ba46 100644 --- a/test/integration/browser/package-lock.json +++ b/test/integration/browser/package-lock.json @@ -74,9 +74,9 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/test/mcp/package-lock.json b/test/mcp/package-lock.json index db0da9e4cb05c7..5e9522b078bce1 100644 --- a/test/mcp/package-lock.json +++ b/test/mcp/package-lock.json @@ -22,9 +22,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -526,9 +526,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -715,9 +715,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1302,9 +1302,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "dev": true, "license": "MIT", "engines": { diff --git a/test/monaco/package-lock.json b/test/monaco/package-lock.json index 1728e44466a938..6143f7d23e4fad 100644 --- a/test/monaco/package-lock.json +++ b/test/monaco/package-lock.json @@ -883,9 +883,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { From c54f7c66e80fc70659428ac27c3324825f3290da Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:23:52 -0700 Subject: [PATCH 17/34] build(deps): bump fast-uri from 3.1.2 to 3.1.4 in /extensions/copilot (#326898) Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From a4c5a1fc9efbab93a8635ade57765619da30628f Mon Sep 17 00:00:00 2001 From: Logan Ramos Date: Wed, 22 Jul 2026 12:24:56 -0400 Subject: [PATCH 18/34] Simplify model selection restoration (#326965) * Simplify model selection restoration * Restore chat widget main behavior * Simplify chat model selection reconciliation * Simplify model picker architecture --- src/vs/sessions/SESSIONS.md | 2 +- .../contrib/chat/browser/modelPicker.ts | 9 +- .../browser/sessionModelSelectionModel.ts | 109 ++- .../sessionModelSelectionModel.test.ts | 2 +- .../contrib/chat/browser/widget/chatWidget.ts | 15 +- .../chatInputModelSelectionController.ts | 447 ++++++---- .../widget/input/chatInputModelUtils.ts | 12 - .../browser/widget/input/chatInputPart.ts | 131 +-- .../widget/input/chatModelSelectionModel.ts | 208 ----- .../widget/input/chatPhoneInputPresenter.ts | 2 +- .../modelPicker/modelPickerActionItem.ts | 23 +- .../modelPicker/modelPickerItemPrimitives.ts | 236 +++++ .../modelPicker/modelPickerItemSections.ts | 389 +++++++++ .../input/modelPicker/modelPickerItemTypes.ts | 42 + .../input/modelPicker/modelPickerItems.ts | 822 +----------------- .../input/modelPicker/modelPickerWidget.ts | 56 +- .../common/chatService/chatServiceImpl.ts | 24 +- .../contrib/chat/common/modelSelection.ts | 36 +- .../widget/input/chatInputModelUtils.test.ts | 10 - .../input/chatModelSelectionModel.test.ts | 496 +++++++---- .../modelPicker/modelPickerItems.test.ts | 121 +-- .../common/chatService/chatService.test.ts | 60 +- .../chat/test/common/modelSelection.test.ts | 22 +- 23 files changed, 1624 insertions(+), 1650 deletions(-) delete mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionModel.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemSections.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemTypes.ts diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index fd31c86f1cecf0..72583e020b54ee 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -719,7 +719,7 @@ Context keys remain the correct tool for **declarative** `when` clauses on menu, **Example:** each `NewChatInputWidget` owns a scoped `SessionModelSelectionModel` (`contrib/chat/browser/sessionModelSelectionModel.ts`). The model reads the session or remembered model identifier before calling `provider.getModelsSnapshot(...)`, whose `desiredModelResolution` field reports `notRequested`, `pending`, `available`, or `unavailable` using the same catalog resolution helper as the workbench `ChatInputPart`. A pending desired model does not apply an available fallback, and send stays disabled until the model arrives or the user explicitly selects another model. With no desired model, an automatic first-available choice is provisional and is re-evaluated when the provider default arrives. The desktop and compact phone pickers consume the resulting models, selection, pending identifier, options, and send eligibility; opened-session phone sheets and notification actions perform explicit selections through the same provider/storage path. Menu `when` clauses only gate on genuinely declarative conditions such as phone layout and whether the provider offers a combined config picker. -Workbench `ChatInputPart` and the Sessions adapter both compose `ChatModelSelectionModel` from `vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionModel.ts`. The shared model owns observable selection state, pending catalog waits, explicit-selection transactions, and session/chat transition memory backed by `vs/workbench/contrib/chat/common/modelSelection.ts`. Workbench-only filtering, compatibility, draft cleanup, and `IInputModel` reconciliation live in `ChatInputModelSelectionController`; Sessions remains a provider snapshot/effect adapter. +Workbench and Sessions share only the pure transition policy in `vs/workbench/contrib/chat/common/modelSelection.ts`. Each surface owns its state and effects: `ChatInputModelSelectionController` owns Workbench selection and pending intent, while `SessionModelSelectionModel` owns Sessions reducer memory and provider effects. For a fresh conversation the precedence is `chat.defaultModel` (when resolvable), then the remembered explicit identifier, then the location default or first available model. A draft or existing conversation's own model is authoritative, and an explicit in-conversation pick is preserved until the next conversation. Only explicit user picks update `chat.currentLanguageModel.${location}[.${modelTarget}]` in profile/user storage; configured, restored, default, and first-available choices update conversation/provider state without rewriting that preference. The retired `.isDefault` companion value is removed lazily on read. Previous application-scoped workbench values and `sessions.modelPicker.${providerId}.${sessionType}.selectedModelId` values migrate lazily. Omitted `ISessionModelPickerOptions.showAutoModel` is normalized to `true`. diff --git a/src/vs/sessions/contrib/chat/browser/modelPicker.ts b/src/vs/sessions/contrib/chat/browser/modelPicker.ts index cad2ab67fd0016..b938c339070cc6 100644 --- a/src/vs/sessions/contrib/chat/browser/modelPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/modelPicker.ts @@ -67,11 +67,10 @@ export class ModelPicker extends Disposable { } }, getModels: () => [...this._selectionModel.state.get().models], - useGroupedModelPicker: () => this._selectionModel.state.get().options.useGroupedModelPicker, - showManageModelsAction: () => this._selectionModel.state.get().options.showManageModelsAction, - showUnavailableFeatured: () => this._selectionModel.state.get().options.showUnavailableFeatured, - showFeatured: () => this._selectionModel.state.get().options.showFeatured, - showAutoModel: () => this._selectionModel.state.get().options.showAutoModel, + getPresentationOptions: () => ({ + ...this._selectionModel.state.get().options, + useGenericModelIcon: false, + }), isCacheWarm: () => { const session = this._sessionContext.session.get(); // The session's prompt cache is warm once its first request has diff --git a/src/vs/sessions/contrib/chat/browser/sessionModelSelectionModel.ts b/src/vs/sessions/contrib/chat/browser/sessionModelSelectionModel.ts index 7d4c429047b0c9..abcdeb3b6f6120 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionModelSelectionModel.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionModelSelectionModel.ts @@ -12,9 +12,8 @@ import { IStorageService, StorageScope } from '../../../../platform/storage/comm import { getSelectedModelStorageKey, getStoredSelectedModel, storeSelectedModel } from '../../../../workbench/contrib/chat/common/chatSelectedModel.js'; import { ChatAgentLocation, ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../workbench/contrib/chat/common/languageModels.js'; -import { IPendingModelSelection } from '../../../../workbench/contrib/chat/common/modelSelection.js'; +import { IModelSelectionMemory, IModelSelectionSessionContext, IPendingModelSelection, ModelSelectionReason, transitionModelSelection } from '../../../../workbench/contrib/chat/common/modelSelection.js'; import { ChatModelSelectionDiagnostics } from '../../../../workbench/contrib/chat/browser/widget/input/chatModelSelectionDiagnostics.js'; -import { ChatModelSelectionModel } from '../../../../workbench/contrib/chat/browser/widget/input/chatModelSelectionModel.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionModelPickerOptions, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; import { SessionStatus } from '../../../services/sessions/common/session.js'; @@ -99,7 +98,12 @@ export class SessionModelSelectionModel extends Disposable implements ISessionMo readonly state: IObservable = this._state; private readonly _providerListener = this._register(new MutableDisposable()); private readonly _sharedDiagnostics: ChatModelSelectionDiagnostics; - private readonly _selection: ChatModelSelectionModel; + private _memory: IModelSelectionMemory = { + sessionKey: undefined, + lastPushedChatKey: undefined, + currentModel: undefined, + currentReason: undefined, + }; private _provider: ISessionsProvider | undefined; private _modelTarget: string | undefined; @@ -126,8 +130,6 @@ export class SessionModelSelectionModel extends Disposable implements ISessionMo }, }; }); - this._selection = new ChatModelSelectionModel(this._sharedDiagnostics); - this._register(autorun(reader => { const session = this._session.read(reader); session?.modelId.read(reader); @@ -141,7 +143,7 @@ export class SessionModelSelectionModel extends Disposable implements ISessionMo } })); this._register(this._sessionsProvidersService.onDidChangeProviders(() => this._refresh('providers'))); - this._register(this._storageService.onDidChangeValue(StorageScope.PROFILE, undefined, this._store)(event => this._sharedDiagnostics.logStorageChange(event, this._selection.currentModel.get()?.identifier))); + this._register(this._storageService.onDidChangeValue(StorageScope.PROFILE, undefined, this._store)(event => this._sharedDiagnostics.logStorageChange(event, this._state.get().currentModel?.identifier))); } selectModel(modelIdentifier: string): boolean { @@ -170,6 +172,7 @@ export class SessionModelSelectionModel extends Disposable implements ISessionMo const options = normalizeModelPickerOptions(provider.getModelPickerOptions(session.sessionId)); const previousState = this._state.get(); + const previousMemory = this._memory; const providerModelBefore = session.modelId.get(); const storageKey = getSelectedModelStorageKey(ChatAgentLocation.Chat, snapshot.modelTarget); this._state.set({ @@ -179,14 +182,19 @@ export class SessionModelSelectionModel extends Disposable implements ISessionMo currentModel: model, pendingSelection: undefined, }, undefined); + this._memory = { + sessionKey: this._sessionKey(session), + lastPushedChatKey: session.activeChat.get().resource.toString(), + currentModel: model, + currentReason: ModelSelectionReason.UserSelection, + }; + this._sharedDiagnostics.report('explicit-selection', { model: model.identifier }, 'info'); try { - this._selection.applyExplicitSelection( - model, - this._sessionKey(session), - session.activeChat.get().resource.toString(), - () => persistSessionModelSelection(session, provider, this._storageService, model, snapshot.modelTarget), - ); + persistSessionModelSelection(session, provider, this._storageService, model, snapshot.modelTarget); + this._sharedDiagnostics.report('explicit-selection-applied', { model: model.identifier }, 'info'); } catch (error) { + this._memory = previousMemory; + this._sharedDiagnostics.report('explicit-selection-failed', { model: model.identifier, error: String(error) }, 'error'); this._sharedDiagnostics.report('provider-selection-failed', { requestedModel: modelIdentifier, providerModelBefore, @@ -218,27 +226,43 @@ export class SessionModelSelectionModel extends Disposable implements ISessionMo const sessionKey = session ? this._sessionKey(session) : undefined; const sessionModelId = session?.modelId.get(); const previousState = this._state.get(); - const transition = this._selection.transitionFromCatalog({ - trigger, - session: session ? { - kind: session.status.get() === SessionStatus.Untitled ? 'untitled' : 'existing', - key: sessionKey!, - chatKey: session.activeChat.get().resource.toString(), - modelId: sessionModelId, - } : { kind: 'none' }, - location: ChatAgentLocation.Chat, - configuredModel: this._configurationService.getValue(ChatConfiguration.DefaultModel), - waitForConfiguredModel: false, - getSnapshot: desiredModelId => { - const snapshot = session && provider - ? provider.getModelsSnapshot(session.sessionId, desiredModelId) - : { models: [], desiredModelResolution: { kind: 'notRequested' } as const, modelTarget: undefined }; - this._modelTarget = snapshot.modelTarget; - return snapshot; + const previousMemory = this._memory; + const sessionContext: IModelSelectionSessionContext = session ? { + kind: session.status.get() === SessionStatus.Untitled ? 'untitled' : 'existing', + key: sessionKey!, + chatKey: session.activeChat.get().resource.toString(), + modelId: sessionModelId, + } : { kind: 'none' }; + const currentReason = sessionKey === this._memory.sessionKey ? this._memory.currentReason : undefined; + const initialSnapshot = session && provider + ? provider.getModelsSnapshot(session.sessionId, sessionModelId) + : { models: [], desiredModelResolution: { kind: 'notRequested' } as const, modelTarget: undefined }; + const rememberedSelection = session ? this._getRememberedModel(session, initialSnapshot.modelTarget) : undefined; + const rememberedModelId = rememberedSelection?.identifier; + const desiredModelIdentifier = sessionContext.kind === 'untitled' + ? (currentReason === ModelSelectionReason.FirstAvailable ? rememberedModelId : (sessionModelId ?? rememberedModelId)) + : sessionModelId; + const snapshot = desiredModelIdentifier !== sessionModelId && session && provider + ? provider.getModelsSnapshot(session.sessionId, desiredModelIdentifier) + : initialSnapshot; + const fallbackModel = snapshot.models.find(model => model.metadata.isDefaultForLocation[ChatAgentLocation.Chat]) ?? snapshot.models[0]; + const result = transitionModelSelection({ + session: sessionContext, + models: { + available: snapshot.models, + configuredModel: this._configurationService.getValue(ChatConfiguration.DefaultModel), + rememberedModelId, + desiredModelResolution: snapshot.desiredModelResolution, + fallbackModel, }, - getRememberedSelection: snapshot => session ? this._getRememberedModel(session, snapshot.modelTarget) : undefined, + previous: { ...this._memory, currentReason }, }); - const { previousState: previousSelectionState, snapshot, result } = transition; + this._memory = { + sessionKey: result.sessionKey, + lastPushedChatKey: result.lastPushedChatKey, + currentModel: result.currentModel, + currentReason: result.currentReason, + }; this._modelTarget = snapshot.modelTarget; const models = snapshot.models; const options = normalizeModelPickerOptions(session && provider ? provider.getModelPickerOptions(session.sessionId) : undefined); @@ -250,13 +274,34 @@ export class SessionModelSelectionModel extends Disposable implements ISessionMo currentModel: result.currentModel, pendingSelection: result.pendingSelection, }, undefined); + this._sharedDiagnostics.report('transition', { + trigger, + sessionKind: sessionContext.kind, + modelTarget: snapshot.modelTarget, + configuredModel: this._configurationService.getValue(ChatConfiguration.DefaultModel), + rememberedModel: rememberedModelId, + rememberedSource: rememberedSelection?.source, + desiredModel: desiredModelIdentifier, + desiredResolution: snapshot.desiredModelResolution.kind, + fallbackModel: fallbackModel?.identifier, + availableModels: snapshot.models.map(model => model.identifier).join(','), + previousModel: previousMemory.currentModel?.identifier, + previousReason: currentReason, + resultModel: result.currentModel?.identifier, + resultReason: result.currentReason, + pendingReference: result.pendingSelection?.reference, + effect: result.effect.kind, + effectModel: result.effect.kind === 'apply' ? result.effect.model.identifier : undefined, + effectReason: result.effect.kind === 'none' ? undefined : result.effect.reason, + }, result.effect.kind === 'none' && previousMemory.currentModel?.identifier === result.currentModel?.identifier ? 'debug' : 'info'); if (result.effect.kind === 'apply' && session && provider) { const effect = result.effect; const providerModelBefore = session.modelId.get(); try { - this._selection.applyTransitionEffect(previousSelectionState, () => provider.setModel(session.sessionId, effect.model.identifier)); + provider.setModel(session.sessionId, effect.model.identifier); } catch (error) { + this._memory = previousMemory; this._state.set(previousState, undefined); this._sharedDiagnostics.report('provider-automatic-selection-failed', { model: effect.model.identifier, diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionModelSelectionModel.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionModelSelectionModel.test.ts index 76878311bb9754..f7069f6b2a827d 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionModelSelectionModel.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionModelSelectionModel.test.ts @@ -455,7 +455,7 @@ suite('SessionModelSelectionModel', () => { }, { beforeResolve: { current: undefined, - pending: { source: 'desired', reference: second.identifier }, + pending: { reference: second.identifier }, stored: second.identifier, writes: [], desiredModelIds: [undefined, second.identifier], diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index fa85867a21b04d..1481ea81fc1c60 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1447,15 +1447,16 @@ export class ChatWidget extends Disposable implements IChatWidget { return; } // Switch to the specified model if provided - if (handoff.model) { - this.input.switchModelByQualifiedName([handoff.model]); - } + const modelReady = handoff.model ? this.input.requestModelByQualifiedName([handoff.model]) : undefined; // Insert the handoff prompt into the input this.input.setValue(promptToUse, false); this.input.focus(); // Auto-submit if send flag is true if (handoff.send) { + if (modelReady && !await modelReady) { + return; + } this.acceptInput().catch(e => this.logService.error(`[Handoff] Failed to submit handoff to '${handoff.agent}'`, e)); } } @@ -1832,7 +1833,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.inputPart.element.classList.add('editing'); } if (currentElement.modelId) { - this.input.switchModelByIdentifier(currentElement.modelId); + void this.input.requestModelByIdentifier(currentElement.modelId); } this.inputPart.toggleChatInputOverlay(!isInput); @@ -1932,7 +1933,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.inputPart.setPermissionLevel(this.input.currentModeInfo.permissionLevel ?? ChatPermissionLevel.Default); const editModelId = this.input.currentLanguageModel; if (editModelId) { - this.inputPart.switchModelByIdentifier(editModelId); + void this.inputPart.requestModelByIdentifier(editModelId); } this.inputPart?.toggleChatInputOverlay(false); @@ -2500,7 +2501,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } async acceptInput(query?: string, options?: IChatAcceptInputOptions): Promise { - if (this._readOnly) { + if (this._readOnly || this.input.hasPendingProgrammaticModelSelection) { return undefined; } @@ -3254,7 +3255,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } if (model !== undefined) { - this.input.switchModelByQualifiedName(model); + return this.input.requestModelByQualifiedName(model); } return true; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts index 874816feefe490..91c3e0c7d11809 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts @@ -3,13 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; -import { InitialModelSelectionResult, ModelIdentifierResolution, ModelSelectionReason, resolveConfiguredModel, resolveInitialModelSelection } from '../../../common/modelSelection.js'; -import { findBestMatchingModel, findDefaultModel, findReplacementForProvisionalModel, hasModelsTargetingSession, isModelValidForSession, resolveModelFromSyncState, shouldDropAgnosticDraftModel, shouldResetModelToDefault, shouldResetOnModelListChange, shouldWaitForSessionModel } from './chatInputModelUtils.js'; +import { InitialModelSelectionResult, isAuthoritativeModelSelectionReason, ModelIdentifierResolution, ModelSelectionApplyReason, ModelSelectionReason, resolveConfiguredModel, resolveInitialModelSelection, resolveModelIdentifier } from '../../../common/modelSelection.js'; +import { findBestMatchingModel, findDefaultModel, hasModelsTargetingSession, isModelValidForSession, resolveModelFromSyncState, shouldDropAgnosticDraftModel, shouldResetModelToDefault, shouldResetOnModelListChange, shouldWaitForSessionModel } from './chatInputModelUtils.js'; import { IChatModelSelectionDiagnostics, NullChatModelSelectionDiagnostics } from './chatModelSelectionDiagnostics.js'; -import { ChatModelSelectionModel } from './chatModelSelectionModel.js'; /** Supplies Workbench chat's filtered model catalog and conversation effects. */ export interface IChatInputModelSelectionRuntime { @@ -34,79 +34,135 @@ interface IResolvedDraftModelSelection { readonly changed: boolean; } +type ModelSelectionIntent = + | { readonly kind: 'remembered'; readonly modelId: string } + | { readonly kind: 'programmatic'; readonly resolveModel: () => ILanguageModelChatMetadataAndIdentifier | undefined; readonly conversationKey: string | undefined; readonly complete: (applied: boolean) => void } + | { readonly kind: 'session'; readonly model: ILanguageModelChatMetadataAndIdentifier; readonly configuration: Record | undefined; readonly sessionType: string | undefined; readonly conversationKey: string } + | { readonly kind: 'history'; readonly modelId: string; readonly conversationKey: string }; + /** Reconciles the shared selection model with Workbench-specific input and catalog state. */ export class ChatInputModelSelectionController extends Disposable { - private readonly _authoritativeModelWait = this._register(new MutableDisposable()); - private readonly _historyModelWait = this._register(new MutableDisposable()); - private _provisionalModelId: string | undefined; + private readonly _currentModel = observableValue(this, undefined); + readonly currentModel: IObservable = this._currentModel; + private _selectionReason: ModelSelectionApplyReason | undefined; + private _intent: ModelSelectionIntent | undefined; private _restorePerTypeModel = false; constructor( - private readonly _model: ChatModelSelectionModel, private readonly _runtime: IChatInputModelSelectionRuntime, private readonly _diagnostics: IChatModelSelectionDiagnostics = NullChatModelSelectionDiagnostics, ) { super(); + this._register(this._runtime.subscribeToModelChanges(() => this.reconcileModelListChange(this._runtime.getModels(this._runtime.getCurrentSessionType())))); + this._register(toDisposable(() => this._clearIntent())); } get restorePerTypeModel(): boolean { return this._restorePerTypeModel; } - get provisionalModelId(): string | undefined { - return this._provisionalModelId; + get selectionReason(): ModelSelectionApplyReason | undefined { + return this._selectionReason; + } + + get userExplicitlySelectedModel(): boolean { + return this._selectionReason === ModelSelectionReason.UserSelection; } beginSessionSwitch(isEmpty: boolean, ownsPool: boolean, hadIncomingModel: boolean): void { - this._model.resetSelectionOrigin(); - this._provisionalModelId = undefined; + this._selectionReason = undefined; this._restorePerTypeModel = isEmpty && ownsPool && !hadIncomingModel; - this.clearAuthoritativeModelWait(); - this.clearHistoryModelWait(); + this._clearIntent(); } endSessionSwitch(): void { this._restorePerTypeModel = false; } - hasAuthoritativeModelWait(): boolean { - return !!this._authoritativeModelWait.value; + hasPendingIntent(): boolean { + return !!this._intent; + } + + hasPendingProgrammaticSelection(): boolean { + return this._intent?.kind === 'programmatic'; } - clearAuthoritativeModelWait(): void { - this._authoritativeModelWait.clear(); + clearIntent(): void { + this._clearIntent(); } - clearHistoryModelWait(): void { - this._historyModelWait.clear(); + clearHistoryIntent(): void { + if (this._intent?.kind === 'history') { + this._clearIntent(); + } } applyExplicitSelection( model: ILanguageModelChatMetadataAndIdentifier, - sessionKey: string | undefined, - conversationKey: string | undefined, apply: () => void, rollbackOnError: boolean, ): void { - this.clearAuthoritativeModelWait(); - this.clearHistoryModelWait(); - this._provisionalModelId = undefined; - this._model.applyExplicitSelection(model, sessionKey, conversationKey, apply, rollbackOnError); + this._clearIntent(); + const previousModel = this._currentModel.get(); + const previousReason = this._selectionReason; + this._currentModel.set(model, undefined); + this._selectionReason = ModelSelectionReason.UserSelection; + this._diagnostics.report('explicit-selection', { model: model.identifier }, 'info'); + try { + apply(); + this._diagnostics.report('explicit-selection-applied', { model: model.identifier }, 'info'); + } catch (error) { + if (rollbackOnError) { + this._currentModel.set(previousModel, undefined); + this._selectionReason = previousReason; + } + this._diagnostics.report('explicit-selection-failed', { model: model.identifier, error: String(error) }, 'error'); + throw error; + } + } + + applyAutomaticSelection(model: ILanguageModelChatMetadataAndIdentifier, apply: () => void): void { + this._currentModel.set(model, undefined); + apply(); + } + + applyProgrammaticSelection(model: ILanguageModelChatMetadataAndIdentifier): void { + this._clearIntent(); + this._selectionReason = ModelSelectionReason.ProgrammaticSelection; + this._applyModel(model); + } + + requestProgrammaticSelection( + resolveModel: () => ILanguageModelChatMetadataAndIdentifier | undefined, + conversationKey: string | undefined, + ): Promise { + this._clearIntent(); + this._selectionReason = ModelSelectionReason.ProgrammaticSelection; + return new Promise(resolve => { + let complete = resolve; + this._intent = { + kind: 'programmatic', + resolveModel, + conversationKey, + complete: applied => { + complete(applied); + complete = () => { }; + }, + }; + this._reconcileIntent(); + }); } initialize(rememberedModelId: string | undefined, onInitialSelection: (selection: InitialModelSelectionResult) => void): void { - this.clearAuthoritativeModelWait(); - this._provisionalModelId = undefined; + this._clearIntent(); const resolveSelection = (): InitialModelSelectionResult => { const configuredModelValue = this._runtime.getConfiguredModelValue(); const models = this._runtime.getModels(this._runtime.getCurrentSessionType()); const configuredModel = resolveConfiguredModel(configuredModelValue, models); - const resolution = rememberedModelId ? this._runtime.resolveModelIdentifier(rememberedModelId) : { kind: 'notRequested' } as const; + const resolution = resolveModelIdentifier(models, rememberedModelId, false); return resolveInitialModelSelection({ - configuredModelValue, configuredModel, - waitForConfiguredModel: false, desiredModelResolution: resolution, desiredReason: ModelSelectionReason.Remembered, fallbackModel: findDefaultModel(models, this._runtime.location), @@ -117,79 +173,22 @@ export class ChatInputModelSelectionController extends Disposable { const selection = resolveSelection(); onInitialSelection(selection); this._reportInitialization(this._runtime.getConfiguredModelValue(), rememberedModelId, selection); - this._applyInitialSelection(selection); - - // The remembered model has not been honored yet when we only applied a provisional fallback - // (`FirstAvailable`) or are still waiting for the pool to load (`pending`). In both cases, - // watch the catalog and swap the remembered model in once it appears; a configured or - // otherwise-available model — or an explicit user pick — supersedes it. Routing both cases - // through one wait (instead of arming it only for `pending`) means a remembered model that - // lands *after* the first, possibly non-empty, catalog batch is no longer lost. - if (rememberedModelId && (selection.kind === 'pending' || (selection.kind === 'apply' && selection.reason === ModelSelectionReason.FirstAvailable))) { - const desiredIdentifier = rememberedModelId; - this._watchModelChanges(this._authoritativeModelWait, () => true, () => { - const lateSelection = resolveSelection(); - if (lateSelection.kind === 'apply' && lateSelection.reason !== ModelSelectionReason.FirstAvailable) { - this._provisionalModelId = undefined; - this._model.setSelectionReason(lateSelection.reason); - this._runtime.applyModel(lateSelection.model); - this.ensureCurrentModelSupported(); - return 'settled'; - } - // Keep waiting only while the remembered model's vendor still reports its absence as - // transient (`pending`); once it is conclusively gone (`unavailable`/`notRequested`) - // settle on the applied fallback so the wait cannot linger forever. - return this._runtime.resolveModelIdentifier(desiredIdentifier).kind === 'pending' ? 'waiting' : 'settled'; - }); - } - } - - private _applyInitialSelection(selection: InitialModelSelectionResult): void { if (selection.kind === 'apply') { - this._model.setSelectionReason(selection.reason); - this._provisionalModelId = selection.reason === ModelSelectionReason.FirstAvailable - && !selection.model.metadata.isDefaultForLocation[this._runtime.location] - ? selection.model.identifier - : undefined; - this._runtime.applyModel(selection.model); + this._selectionReason = selection.reason; + this._applyModel(selection.model); this.ensureCurrentModelSupported(); } else if (selection.kind === 'pending') { + this._intent = { kind: 'remembered', modelId: selection.selection.reference }; const fallbackModel = findDefaultModel(this._runtime.getModels(this._runtime.getCurrentSessionType()), this._runtime.location); if (fallbackModel) { - this._model.setSelectionReason(ModelSelectionReason.FirstAvailable); - this._provisionalModelId = fallbackModel.metadata.isDefaultForLocation[this._runtime.location] - ? undefined - : fallbackModel.identifier; - this._runtime.applyModel(fallbackModel); + this._selectionReason = ModelSelectionReason.FirstAvailable; + this._applyModel(fallbackModel); } } } - /** - * Subscribes to catalog changes and re-runs `reevaluate` on each one, tearing the subscription - * down as soon as it reports `'settled'` — or when `isRelevant()` becomes false (the bound/visible - * conversation moved on) or the controller is disposed. Shared by the restore waits so the - * subscription lifecycle and relevance guard live in one place; `reevaluate` is responsible for - * applying any model change and returning whether the wait is done. - */ - private _watchModelChanges( - wait: MutableDisposable, - isRelevant: () => boolean, - reevaluate: () => 'settled' | 'waiting', - ): void { - wait.value = this._runtime.subscribeToModelChanges(() => { - if (!isRelevant()) { - wait.clear(); - return; - } - if (reevaluate() === 'settled') { - wait.clear(); - } - }); - } - ensureCurrentModelSupported(): void { - const currentModel = this._model.currentModel.get(); + const currentModel = this._currentModel.get(); const sessionType = this._runtime.getCurrentSessionType(); const models = this._runtime.getModels(sessionType); const context = { @@ -220,24 +219,21 @@ export class ChatInputModelSelectionController extends Disposable { this._diagnostics.report('select-default', { configuredModel: configuredModel?.identifier, defaultModel: defaultModel?.identifier, - currentModel: this._model.currentModel.get()?.identifier, + currentModel: this._currentModel.get()?.identifier, }, defaultModel ? 'info' : 'debug'); if (!defaultModel) { return; } - const isProvisional = this._runtime.isEmpty() - && !this._model.userExplicitlySelectedModel - && !configuredModel - && !defaultModel.metadata.isDefaultForLocation[this._runtime.location]; - this._model.setSelectionReason(configuredModel ? ModelSelectionReason.ConfiguredDefault : ModelSelectionReason.FirstAvailable); - this._provisionalModelId = isProvisional ? defaultModel.identifier : undefined; - this._runtime.applyModel(defaultModel); + if (!this.hasPendingProgrammaticSelection()) { + this._selectionReason = configuredModel ? ModelSelectionReason.ConfiguredDefault : ModelSelectionReason.FirstAvailable; + } + this._applyModel(defaultModel); } applyConfiguredDefault(): boolean { if (!this._runtime.isEmpty() - || this._model.userExplicitlySelectedModel - || this._model.selectionReason === ModelSelectionReason.SessionRestore) { + || isAuthoritativeModelSelectionReason(this._selectionReason) + || (this._intent && this._intent.kind !== 'remembered')) { return false; } const configuredValue = this._runtime.getConfiguredModelValue(); @@ -245,26 +241,46 @@ export class ChatInputModelSelectionController extends Disposable { return false; } const configuredModel = resolveConfiguredModel(configuredValue, this._runtime.getModels(this._runtime.getCurrentSessionType())); - if (!configuredModel || configuredModel.identifier === this._model.currentModel.get()?.identifier) { + if (!configuredModel) { return false; } - this.clearAuthoritativeModelWait(); - this._model.setSelectionReason(ModelSelectionReason.ConfiguredDefault); - this._runtime.applyModel(configuredModel); + const matchesCurrent = configuredModel.identifier === this._currentModel.get()?.identifier; + const supersededRememberedIntent = this._intent?.kind === 'remembered'; + if (supersededRememberedIntent) { + this._clearIntent(); + } + if (matchesCurrent) { + if (this._selectionReason !== ModelSelectionReason.ConfiguredDefault) { + this._selectionReason = ModelSelectionReason.ConfiguredDefault; + return true; + } + return supersededRememberedIntent; + } + this._selectionReason = ModelSelectionReason.ConfiguredDefault; + this._applyModel(configuredModel); this.ensureCurrentModelSupported(); return true; } reconcileModelListChange(models: readonly ILanguageModelChatMetadataAndIdentifier[]): void { - if (this.applyConfiguredDefault() || this._replaceProvisionalModelWithDefault(models)) { + if (this.applyConfiguredDefault() || this._reconcileIntent()) { return; } - if (!shouldResetOnModelListChange(this._model.currentModel.get()?.identifier, [...models])) { + const currentModel = this._currentModel.get(); + const locationDefault = models.find(model => model.metadata.isDefaultForLocation[this._runtime.location]); + if (this._runtime.isEmpty() + && this._selectionReason === ModelSelectionReason.FirstAvailable + && locationDefault + && currentModel?.identifier !== locationDefault.identifier) { + this._applyModel(locationDefault); return; } - const match = findBestMatchingModel(this._model.currentModel.get(), models); + if (!shouldResetOnModelListChange(currentModel?.identifier, [...models])) { + return; + } + const match = findBestMatchingModel(currentModel, models); if (match) { - this._runtime.applyModel(match); + this._applyModel(match); } else { this.selectDefault(); } @@ -276,9 +292,9 @@ export class ChatInputModelSelectionController extends Disposable { sessionType: string | undefined, conversationKey: string, ): void { - this.clearHistoryModelWait(); + this.clearHistoryIntent(); const allModels = this._runtime.getAllModels(); - const currentModel = this._model.currentModel.get(); + const currentModel = this._currentModel.get(); const resolution = this._runtime.resolveModelIdentifier(desiredModel.identifier); const syncResult = resolveModelFromSyncState(desiredModel, currentModel, allModels, sessionType, { location: this._runtime.location, @@ -292,81 +308,52 @@ export class ChatInputModelSelectionController extends Disposable { action: syncResult.action, }, syncResult.action === 'keep' ? 'debug' : 'info'); if (syncResult.action === 'apply' || syncResult.action === 'keep') { - this.clearAuthoritativeModelWait(); - this.clearHistoryModelWait(); - this._model.setSelectionReason(ModelSelectionReason.SessionRestore); - this._runtime.restoreModelConfiguration(desiredModel.identifier, modelConfiguration); - if (syncResult.action === 'apply') { - this._runtime.applyModel(desiredModel); - } + this._applySessionRestore(desiredModel, syncResult.action === 'apply', { + modelId: desiredModel.identifier, + configuration: modelConfiguration, + }); return; } const pool = this._runtime.getModels(sessionType); const match = findBestMatchingModel(desiredModel, pool) ?? findBestMatchingModel(currentModel, pool); if (match) { - this.clearAuthoritativeModelWait(); - this.clearHistoryModelWait(); - this._model.setSelectionReason(ModelSelectionReason.SessionRestore); - this._runtime.applyModel(match); + this._applySessionRestore(match, true); } else if (resolution.kind === 'pending' && shouldWaitForSessionModel(desiredModel, sessionType, allModels)) { - this._watchModelChanges( - this._authoritativeModelWait, - () => this._runtime.getBoundConversationKey() === conversationKey, - () => { - const lateResolution = this._runtime.resolveModelIdentifier(desiredModel.identifier); - if (lateResolution.kind === 'available') { - this._model.setSelectionReason(ModelSelectionReason.SessionRestore); - this._runtime.restoreModelConfiguration(desiredModel.identifier, modelConfiguration); - this._runtime.applyModel(lateResolution.model); - return 'settled'; - } - if (lateResolution.kind === 'unavailable') { - const lateMatch = findBestMatchingModel(desiredModel, this._runtime.getModels(sessionType)); - if (lateMatch) { - this._model.setSelectionReason(ModelSelectionReason.SessionRestore); - this._runtime.applyModel(lateMatch); - } else { - this.selectDefault(sessionType); - } - return 'settled'; - } - return 'waiting'; - }, - ); + this._clearIntent(); + this._intent = { kind: 'session', model: desiredModel, configuration: modelConfiguration, sessionType, conversationKey }; } else { - this.clearAuthoritativeModelWait(); + this._clearIntent(); this.selectDefault(sessionType); } } ensureCurrentModelInSessionPool(): void { - const currentModel = this._model.currentModel.get(); + const currentModel = this._currentModel.get(); if (currentModel && !isModelValidForSession(currentModel, this._runtime.getAllModels(), this._runtime.getCurrentSessionType())) { this.selectDefault(); } } revalidateForSessionType(initialize: () => void): void { - const previousModel = this._model.currentModel.get(); - this._model.resetSelectionOrigin(); + const previousModel = this._currentModel.get(); + this._selectionReason = undefined; initialize(); - const restoredModel = this._model.currentModel.get(); + const restoredModel = this._currentModel.get(); const sessionType = this._runtime.getCurrentSessionType(); if (restoredModel && isModelValidForSession(restoredModel, this._runtime.getAllModels(), sessionType)) { return; } const match = findBestMatchingModel(previousModel, this._runtime.getModels(sessionType)); if (match) { - this._runtime.applyModel(match); + this._applyModel(match); } else { this.selectDefault(sessionType); } } preselectFromHistory(modelId: string, conversationKey: string): void { - this.clearAuthoritativeModelWait(); - this.clearHistoryModelWait(); + this.clearIntent(); const tryMatch = (): ILanguageModelChatMetadataAndIdentifier | undefined => { const models = this._runtime.getModels(this._runtime.getCurrentSessionType()); if (models.length === 0 || (models.length === 1 && models[0].metadata.id.toLocaleLowerCase() === 'auto')) { @@ -377,23 +364,11 @@ export class ChatInputModelSelectionController extends Disposable { }; const match = tryMatch(); if (match) { - this._model.setSelectionReason(ModelSelectionReason.SessionRestore); - this._runtime.applyModel(match); + this._selectionReason = ModelSelectionReason.SessionRestore; + this._applyModel(match); return; } - this._watchModelChanges( - this._historyModelWait, - () => this._runtime.getVisibleConversationKey() === conversationKey, - () => { - const lateMatch = tryMatch(); - if (lateMatch) { - this._model.setSelectionReason(ModelSelectionReason.SessionRestore); - this._runtime.applyModel(lateMatch); - return 'settled'; - } - return 'waiting'; - }, - ); + this._intent = { kind: 'history', modelId, conversationKey }; } resolveDraftModel( @@ -412,24 +387,123 @@ export class ChatInputModelSelectionController extends Disposable { return { model, changed: model?.identifier !== draftModel?.identifier }; } - private _replaceProvisionalModelWithDefault(models: readonly ILanguageModelChatMetadataAndIdentifier[]): boolean { - if (!this._runtime.isEmpty() || this._model.userExplicitlySelectedModel || this.hasAuthoritativeModelWait()) { + private _applySessionRestore( + model: ILanguageModelChatMetadataAndIdentifier, + applyModel: boolean, + configuration?: { readonly modelId: string; readonly configuration: Record | undefined }, + ): void { + this._clearIntent(); + this._selectionReason = ModelSelectionReason.SessionRestore; + if (configuration) { + this._runtime.restoreModelConfiguration(configuration.modelId, configuration.configuration); + } + if (applyModel) { + this._applyModel(model); + } + } + + private _reconcileIntent(): boolean { + const intent = this._intent; + if (!intent) { + return false; + } + + if (intent.kind === 'programmatic') { + if (this._runtime.getBoundConversationKey() !== intent.conversationKey) { + this._clearIntent(); + return true; + } + const model = intent.resolveModel(); + if (!model) { + return false; + } + this._intent = undefined; + intent.complete(true); + this.applyProgrammaticSelection(model); + return true; + } + + if (intent.kind === 'remembered') { + const models = this._runtime.getModels(this._runtime.getCurrentSessionType()); + const model = models.find(model => model.identifier === intent.modelId); + if (model) { + this._intent = undefined; + this._selectionReason = ModelSelectionReason.Remembered; + this._applyModel(model); + this.ensureCurrentModelSupported(); + return true; + } + const currentModel = this._currentModel.get(); + const replacement = models.find(model => model.metadata.isDefaultForLocation[this._runtime.location]); + if (replacement && currentModel?.identifier !== replacement.identifier && !isAuthoritativeModelSelectionReason(this._selectionReason)) { + this._selectionReason = ModelSelectionReason.FirstAvailable; + this._applyModel(replacement); + this.ensureCurrentModelSupported(); + if (this._runtime.resolveModelIdentifier(intent.modelId).kind !== 'pending') { + this._intent = undefined; + } + return true; + } + if (this._runtime.resolveModelIdentifier(intent.modelId).kind !== 'pending') { + this._intent = undefined; + } return false; } - const replacement = findReplacementForProvisionalModel( - this._model.currentModel.get()?.identifier, - this._provisionalModelId, - models, - this._runtime.location, - ); - if (!replacement) { + + if (intent.kind === 'session') { + if (this._runtime.getBoundConversationKey() !== intent.conversationKey) { + this._clearIntent(); + return true; + } + const resolution = this._runtime.resolveModelIdentifier(intent.model.identifier); + if (resolution.kind === 'available') { + this._intent = undefined; + this._applySessionRestore(resolution.model, true, { modelId: intent.model.identifier, configuration: intent.configuration }); + return true; + } + if (resolution.kind === 'unavailable') { + this._intent = undefined; + const match = findBestMatchingModel(intent.model, this._runtime.getModels(intent.sessionType)); + if (match) { + this._applySessionRestore(match, true); + } else { + this.selectDefault(intent.sessionType); + } + return true; + } return false; } - this._provisionalModelId = undefined; - this._model.setSelectionReason(ModelSelectionReason.FirstAvailable); - this._runtime.applyModel(replacement); - this.ensureCurrentModelSupported(); - return true; + + if (this._runtime.getVisibleConversationKey() !== intent.conversationKey) { + this._clearIntent(); + return true; + } + const models = this._runtime.getModels(this._runtime.getCurrentSessionType()); + const model = models.find(model => model.identifier === intent.modelId) + ?? models.find(model => model.metadata.id === intent.modelId); + if (model && !(models.length === 1 && model.metadata.id.toLocaleLowerCase() === 'auto')) { + this._intent = undefined; + this._selectionReason = ModelSelectionReason.SessionRestore; + this._applyModel(model); + return true; + } + return false; + } + + private _clearIntent(): void { + const intent = this._intent; + this._intent = undefined; + if (intent?.kind === 'programmatic') { + intent.complete(false); + if (this._selectionReason === ModelSelectionReason.ProgrammaticSelection) { + this._selectionReason = undefined; + } + } + } + + private _applyModel(model: ILanguageModelChatMetadataAndIdentifier): void { + this._currentModel.set(model, undefined); + this._runtime.applyModel(model); } private _reportInitialization(configuredModel: string | undefined, rememberedModel: string | undefined, selection: InitialModelSelectionResult): void { @@ -440,7 +514,6 @@ export class ChatInputModelSelectionController extends Disposable { selection: selection.kind, resultModel: selection.kind === 'apply' ? selection.model.identifier : undefined, resultReason: selection.kind === 'apply' ? selection.reason : undefined, - pendingSource: selection.kind === 'pending' ? selection.selection.source : undefined, pendingReference: selection.kind === 'pending' ? selection.selection.reference : undefined, }, selection.kind === 'none' ? 'debug' : 'info'); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelUtils.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelUtils.ts index d65d43a50e0717..2309f2267ee1c6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelUtils.ts @@ -214,18 +214,6 @@ export function findDefaultModel( return models.find(m => m.metadata.isDefaultForLocation[location]) || models[0]; } -export function findReplacementForProvisionalModel( - currentModelId: string | undefined, - provisionalModelId: string | undefined, - models: readonly ILanguageModelChatMetadataAndIdentifier[], - location: ChatAgentLocation, -): ILanguageModelChatMetadataAndIdentifier | undefined { - if (!provisionalModelId || currentModelId !== provisionalModelId) { - return undefined; - } - return models.find(model => model.metadata.isDefaultForLocation[location]); -} - /** * Determines whether the current model should be reset because it is no longer * compatible with the current mode, session, or availability. diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 39e222057d0d72..ea7e10eedf3f9e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -98,7 +98,6 @@ import { ModelIdentifierResolution, getRegisteredLanguageModels, resolveModelIde import { ChatInputModelSelectionController, IChatInputModelSelectionRuntime } from './chatInputModelSelectionController.js'; import { ChatModelConfigurationStore } from './chatModelConfigurationStore.js'; import { ChatModelSelectionDiagnostics } from './chatModelSelectionDiagnostics.js'; -import { ChatModelSelectionModel } from './chatModelSelectionModel.js'; import { deserializeUntitledInputAttachments, deserializeUntitledInputState, serializeUntitledInputAttachments, serializeUntitledInputState } from './chatInputStatePersistence.js'; import { IChatModelInputState, IChatRequestModeInfo, IInputModel, logChangesToStateModel } from '../../../common/model/chatModel.js'; import { filterModelsForSession, hasModelsTargetingSession, isModelHiddenInPicker, mergeModelsWithCache, shouldResetOnModelListChange } from './chatInputModelUtils.js'; @@ -145,7 +144,7 @@ import { ChatInputNotificationWidget } from './chatInputNotificationWidget.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ChatSelectedTools } from './chatSelectedTools.js'; import { DelegationSessionPickerActionItem } from './delegationSessionPickerActionItem.js'; -import { ModelPickerActionItem, IModelPickerDelegate } from './modelPicker/modelPickerActionItem.js'; +import { ModelPickerActionItem, IModelPickerDelegate, IModelPickerPresentationOptions } from './modelPicker/modelPickerActionItem.js'; import { IModePickerDelegate, isModeConsideredBuiltIn, ModePickerActionItem } from './modePickerActionItem.js'; import { IPermissionPickerDelegate, PermissionPickerActionItem } from './permissionPickerActionItem.js'; import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; @@ -529,7 +528,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private readonly _optionContextKeys: Map> = new Map(); private readonly _modelSelectionDiagnostics: ChatModelSelectionDiagnostics; - private readonly _modelSelectionModel: ChatModelSelectionModel; private readonly _modelSelectionController: ChatInputModelSelectionController; private readonly _currentLanguageModel: IObservable; private readonly _modelSelectionRuntime: IChatInputModelSelectionRuntime; @@ -713,11 +711,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge conversationKey: this._inputModelSessionResource?.toString(), metadata: { widgetViewKind: this.options.widgetViewKindTag }, })); - this._modelSelectionModel = new ChatModelSelectionModel(this._modelSelectionDiagnostics); - this._currentLanguageModel = this._modelSelectionModel.currentModel; - this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, undefined, this._store)(event => { - this._modelSelectionDiagnostics.logStorageChange(event, this._currentLanguageModel.get()?.identifier); - })); this._modelSelectionRuntime = { location: this.location, getCurrentModeKind: () => this.currentModeKind, @@ -732,9 +725,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge getBoundConversationKey: () => this._inputModelSessionResource?.toString(), getVisibleConversationKey: () => this._widget?.viewModel?.model.sessionResource.toString(), restoreModelConfiguration: (modelId, configuration) => this.restoreModelConfiguration(modelId, configuration), - applyModel: model => this.setCurrentLanguageModel(model, false, false), + applyModel: () => { + if (this.cachedWidth) { + this.layout(this.cachedWidth); + } + this._syncInputStateToModel(); + }, }; - this._modelSelectionController = this._register(new ChatInputModelSelectionController(this._modelSelectionModel, this._modelSelectionRuntime, this._modelSelectionDiagnostics)); + this._modelSelectionController = this._register(new ChatInputModelSelectionController(this._modelSelectionRuntime, this._modelSelectionDiagnostics)); + this._currentLanguageModel = this._modelSelectionController.currentModel; + this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, undefined, this._store)(event => { + this._modelSelectionDiagnostics.logStorageChange(event, this._currentLanguageModel.get()?.identifier); + })); this._modelConfigStore = this._register(new ChatModelConfigurationStore( () => this.getModelConfigurationStorageKey(), @@ -896,7 +898,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.checkModelSupported(); })); - const resetCurrentLanguageModelIfUnavailable = () => { + const updateAfterModelListChange = (reconcileSelection: boolean) => { const modelIdentifier = this._currentLanguageModel.get()?.identifier; const models = this.getModels(); if (canLog(this.logService.getLevel(), LogLevel.Debug)) { @@ -932,14 +934,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge logChangesToStateModel(this._inputModel, messageparts.join(', '), undefined, undefined, this.logService); } - this._modelSelectionController.reconcileModelListChange(models); + if (reconcileSelection) { + this._modelSelectionController.reconcileModelListChange(models); + } // The available-model set changed: re-evaluate whether sending is // possible (a `requiresCustomModels` session may now have, or have // lost, its models). this._updateInputContentContextKeys(); }; - this._register(this.languageModelsService.onDidChangeLanguageModels(resetCurrentLanguageModelIfUnavailable)); - this._register(this.languageModelsService.onDidChangeModelVisibility(resetCurrentLanguageModelIfUnavailable)); + this._register(this.languageModelsService.onDidChangeLanguageModels(() => updateAfterModelListChange(false))); + this._register(this.languageModelsService.onDidChangeModelVisibility(() => updateAfterModelListChange(true))); this._register(this.onDidChangeCurrentChatMode(() => { this.accessibilityService.alert(this._currentModeObservable.get().label.get()); @@ -1052,7 +1056,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const models = this.getModels(); const model = models.find(m => m.identifier === identifier); if (model) { - this.setCurrentLanguageModel(model, isUserAction, storeSelection); + if (isUserAction) { + this.setCurrentLanguageModel(model, true, storeSelection); + } else { + this._applyProgrammaticLanguageModel(model); + } return true; } return false; @@ -1063,7 +1071,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge for (const qualifiedModelName of qualifiedModelNames) { const model = models.find(m => ILanguageModelChatMetadata.matchesQualifiedName(qualifiedModelName, m.metadata)); if (model) { - this.setCurrentLanguageModel(model); + this._applyProgrammaticLanguageModel(model); return true; } } @@ -1071,6 +1079,21 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return false; } + public requestModelByIdentifier(identifier: string): Promise { + return this._requestProgrammaticLanguageModel(() => this.getModels().find(model => model.identifier === identifier)); + } + + public requestModelByQualifiedName(qualifiedModelNames: readonly string[]): Promise { + return this._requestProgrammaticLanguageModel(() => { + const models = this.getModels(); + return qualifiedModelNames.map(name => models.find(model => ILanguageModelChatMetadata.matchesQualifiedName(name, model.metadata))).find(isDefined); + }); + } + + get hasPendingProgrammaticModelSelection(): boolean { + return this._modelSelectionController.hasPendingProgrammaticSelection(); + } + public switchToNextModel(): void { const models = this.getModels(); @@ -1138,38 +1161,24 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }, getModels: () => this.getModels(), isCacheWarm: () => (this._widget?.viewModel?.model.getRequests().length ?? 0) > 0, - useGroupedModelPicker: () => { - // Agent-host session types (local and remote) reuse the same - // grouped/featured model picker as the default chat session, so - // the editor matches the agents window experience. - const sessionType = this.getCurrentSessionType(); - return !sessionType || sessionType === localChatSessionType || isAgentHostTarget(sessionType); - }, - showManageModelsAction: () => { - // Agent-host session types (local and remote) also surface the - // "Manage Models" action so users can add/configure BYOK models - // directly from the picker, matching the agents window experience. - const sessionType = this.getCurrentSessionType(); - return !sessionType || sessionType === localChatSessionType || isAgentHostTarget(sessionType); - }, - showUnavailableFeatured: () => { - // Agent-host session types also surface unavailable featured - // models (upgrade/update/admin states) so the picker is as - // informative as the default chat session, matching the agents - // window experience. - const sessionType = this.getCurrentSessionType(); - return !sessionType || sessionType === localChatSessionType || isAgentHostTarget(sessionType); - }, - showFeatured: () => { - const sessionType = this.getCurrentSessionType(); - return !sessionType || sessionType === localChatSessionType || isAgentHostTarget(sessionType); - }, - useGenericModelIcon: () => !this.options.isSessionsWindow && this._usesHarnessProviderIcon(), - showAutoModel: () => this._showAutoModel(), + getPresentationOptions: () => this._getModelPickerPresentationOptions(), modelConfiguration: this._modelConfigStore, }; } + private _getModelPickerPresentationOptions(): IModelPickerPresentationOptions { + const sessionType = this.getCurrentSessionType(); + const useRichPicker = !sessionType || sessionType === localChatSessionType || isAgentHostTarget(sessionType); + return { + useGroupedModelPicker: useRichPicker, + showManageModelsAction: useRichPicker, + showUnavailableFeatured: useRichPicker, + showFeatured: useRichPicker, + showAutoModel: this._showAutoModel(), + useGenericModelIcon: !this.options.isSessionsWindow && this._usesHarnessProviderIcon(), + }; + } + private _usesHarnessProviderIcon(): boolean { const sessionType = this.getCurrentSessionType(); return sessionType === SessionType.ClaudeCode @@ -1394,9 +1403,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const ownsPool = !!this._currentSessionType && this.sessionTypeHasOwnModelPool(this._currentSessionType); const hadIncomingModel = !!model.state.get()?.selectedModel; this._modelSelectionController.beginSessionSwitch(chatSessionIsEmpty, ownsPool, hadIncomingModel); - // Drop any pending persisted-model wait from a PREVIOUS session so it can't later fire - // into this (or a subsequent) session's storage key. - this._modelSelectionController.clearAuthoritativeModelWait(); if (chatSessionIsEmpty) { const persistedState = model.state.get() ? undefined : this._getPersistedEmptyInputState(); @@ -1639,13 +1645,26 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._syncInputStateToModel(); }; if (isUserAction) { - this._modelSelectionController.applyExplicitSelection(model, this.getCurrentSessionType(), this._inputModelSessionResource?.toString(), apply, false); + this._modelSelectionController.applyExplicitSelection(model, apply, false); } else { - this._modelSelectionModel.setCurrentModel(model, false); - apply(); + this._modelSelectionController.applyAutomaticSelection(model, apply); } } + private _applyProgrammaticLanguageModel(model: ILanguageModelChatMetadataAndIdentifier): void { + this._modelSelectionController.applyProgrammaticSelection(model); + } + + private _requestProgrammaticLanguageModel(resolveModel: () => ILanguageModelChatMetadataAndIdentifier | undefined): Promise { + const result = this._modelSelectionController.requestProgrammaticSelection( + resolveModel, + this._inputModelSessionResource?.toString(), + ); + this._updateInputContentContextKeys(); + void result.finally(() => this._updateInputContentContextKeys()); + return result; + } + private checkModelSupported(): void { this._modelSelectionController.ensureCurrentModelSupported(); } @@ -1824,10 +1843,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge */ private preselectModelFromSessionHistory(): void { // Session-history preselection is delayed when extension-provided models - // have not arrived yet. Always clear the previous session-history wait + // have not arrived yet. Always clear the previous session-history intent // before any early return so a listener captured for another session cannot // later apply its model to the active session. - this._modelSelectionController.clearHistoryModelWait(); + this._modelSelectionController.clearHistoryIntent(); const sessionModel = this._widget?.viewModel?.model; const sessionResource = sessionModel?.sessionResource; @@ -1866,9 +1885,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return model ? model : undefined; } - /** Resets the language model to the location default and cancels any pending persisted-model waiter. */ + /** Resets the language model to the location default and cancels any pending model-selection intent. */ public resetLanguageModelToDefault(): void { - this._modelSelectionController.clearAuthoritativeModelWait(); + this._modelSelectionController.clearIntent(); this.setCurrentLanguageModelToDefault(); } @@ -2200,7 +2219,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const hasSendableContent = inputHasText || this._attachmentModel.attachments.some(isExplicitFileOrImageVariableEntry); // Block sending when the session type has no usable model (and can't // fall back to Auto): there is nothing to send the request with. - this.inputEditorHasSendableContent.set(hasSendableContent && !this.hasNoAvailableModel()); + this.inputEditorHasSendableContent.set(hasSendableContent && !this.hasNoAvailableModel() && !this.hasPendingProgrammaticModelSelection); } private getOrCreateOptionEmitter(optionGroupId: string): Emitter { @@ -2786,7 +2805,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // move away from the model that will be applied when it appears. if (this._modelSelectionController.restorePerTypeModel) { this.initSelectedModel(); - if (!this._modelSelectionController.hasAuthoritativeModelWait()) { + if (!this._modelSelectionController.hasPendingIntent()) { this.checkModelInSessionPool(); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionModel.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionModel.ts deleted file mode 100644 index d23282614ea004..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionModel.ts +++ /dev/null @@ -1,208 +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 { IObservable, observableValue } from '../../../../../../base/common/observable.js'; -import { ChatAgentLocation } from '../../../common/constants.js'; -import { ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; -import { IModelSelectionModelsContext, IModelSelectionSessionContext, IModelSelectionTransitionResult, IPendingModelSelection, ModelIdentifierResolution, ModelSelectionApplyReason, ModelSelectionReason, transitionModelSelection } from '../../../common/modelSelection.js'; -import { IChatModelSelectionDiagnostics, NullChatModelSelectionDiagnostics } from './chatModelSelectionDiagnostics.js'; - -/** Captures coordinator state so a failed external selection effect can be rolled back. */ -export interface IChatModelSelectionSnapshot { - readonly currentModel: ILanguageModelChatMetadataAndIdentifier | undefined; - readonly sessionKey: string | undefined; - readonly lastPushedChatKey: string | undefined; - readonly currentReason: ModelSelectionApplyReason | undefined; - readonly pendingSelection: IPendingModelSelection | undefined; -} - -export interface IChatModelCatalogSnapshot { - readonly models: readonly ILanguageModelChatMetadataAndIdentifier[]; - readonly desiredModelResolution: ModelIdentifierResolution; - readonly modelTarget?: string; -} - -export interface IChatModelCatalogTransitionInput { - readonly session: IModelSelectionSessionContext; - readonly trigger?: string; - readonly location: ChatAgentLocation; - readonly configuredModel: string | undefined; - readonly waitForConfiguredModel: boolean; - readonly getSnapshot: (desiredModelId: string | undefined) => TSnapshot; - readonly getRememberedSelection: (snapshot: TSnapshot) => TRemembered | undefined; -} - -export interface IChatModelCatalogTransitionResult { - readonly previousState: IChatModelSelectionSnapshot; - readonly snapshot: TSnapshot; - readonly result: IModelSelectionTransitionResult; -} - -/** Owns model selection state and transitions independently of picker presentation. */ -export class ChatModelSelectionModel { - - private readonly _currentModel = observableValue(this, undefined); - readonly currentModel: IObservable = this._currentModel; - - private _sessionKey: string | undefined; - private _lastPushedChatKey: string | undefined; - private _currentReason: ModelSelectionApplyReason | undefined; - private _pendingSelection: IPendingModelSelection | undefined; - - constructor(private readonly _diagnostics: IChatModelSelectionDiagnostics = NullChatModelSelectionDiagnostics) { } - - get userExplicitlySelectedModel(): boolean { - return this._currentReason === ModelSelectionReason.UserSelection; - } - - get selectionReason(): ModelSelectionApplyReason | undefined { - return this._currentReason; - } - - getCurrentReason(sessionKey: string | undefined): ModelSelectionApplyReason | undefined { - return sessionKey === this._sessionKey ? this._currentReason : undefined; - } - - resetSelectionOrigin(): void { - this._currentReason = undefined; - } - - setCurrentModel(model: ILanguageModelChatMetadataAndIdentifier, isUserAction: boolean): void { - if (isUserAction) { - this._currentReason = ModelSelectionReason.UserSelection; - } - this._currentModel.set(model, undefined); - } - - setSelectionReason(reason: ModelSelectionApplyReason | undefined): void { - this._currentReason = reason; - } - - setTransitionMemory(sessionKey: string | undefined, chatKey: string | undefined, reason: ModelSelectionApplyReason): void { - this._sessionKey = sessionKey; - this._lastPushedChatKey = chatKey; - this._currentReason = reason; - this._pendingSelection = undefined; - } - - applyExplicitSelection( - model: ILanguageModelChatMetadataAndIdentifier, - sessionKey: string | undefined, - chatKey: string | undefined, - apply: () => void, - rollbackOnError: boolean = true, - ): void { - const previousState = this.captureState(); - this.setCurrentModel(model, true); - this.setTransitionMemory(sessionKey, chatKey, ModelSelectionReason.UserSelection); - this._diagnostics.report('explicit-selection', { model: model.identifier }, 'info'); - try { - apply(); - this._diagnostics.report('explicit-selection-applied', { model: model.identifier }, 'info'); - } catch (error) { - if (rollbackOnError) { - this.restoreState(previousState); - } - this._diagnostics.report('explicit-selection-failed', { model: model.identifier, error: String(error) }, 'error'); - throw error; - } - } - - applyTransitionEffect(previousState: IChatModelSelectionSnapshot, apply: () => void): void { - try { - apply(); - } catch (error) { - this.restoreState(previousState); - throw error; - } - } - - private transition(session: IModelSelectionSessionContext, models: IModelSelectionModelsContext): IModelSelectionTransitionResult { - const currentReason = this.getCurrentReason(session.kind === 'none' ? undefined : session.key); - const result = transitionModelSelection({ - session, - models, - previous: { - sessionKey: this._sessionKey, - lastPushedChatKey: this._lastPushedChatKey, - currentModel: this._currentModel.get(), - currentReason, - }, - }); - this._sessionKey = result.sessionKey; - this._lastPushedChatKey = result.lastPushedChatKey; - this._currentReason = result.currentReason; - this._pendingSelection = result.pendingSelection; - this._currentModel.set(result.currentModel, undefined); - return result; - } - - transitionFromCatalog( - input: IChatModelCatalogTransitionInput, - ): IChatModelCatalogTransitionResult { - const previousState = this.captureState(); - const sessionKey = input.session.kind === 'none' ? undefined : input.session.key; - const sessionModelId = input.session.kind === 'none' ? undefined : input.session.modelId; - const currentReason = this.getCurrentReason(sessionKey); - const initialSnapshot = input.getSnapshot(sessionModelId); - const rememberedSelection = input.getRememberedSelection(initialSnapshot); - const rememberedModelId = rememberedSelection?.identifier; - const desiredModelIdentifier = input.session.kind === 'untitled' - ? (currentReason === ModelSelectionReason.FirstAvailable ? rememberedModelId : (sessionModelId ?? rememberedModelId)) - : sessionModelId; - const snapshot = desiredModelIdentifier !== sessionModelId - ? input.getSnapshot(desiredModelIdentifier) - : initialSnapshot; - const fallbackModel = snapshot.models.find(model => model.metadata.isDefaultForLocation[input.location]) ?? snapshot.models[0]; - const result = this.transition(input.session, { - available: snapshot.models, - configuredModel: input.configuredModel, - waitForConfiguredModel: input.waitForConfiguredModel, - rememberedModelId, - desiredModelResolution: snapshot.desiredModelResolution, - fallbackModel, - }); - this._diagnostics.report('transition', { - trigger: input.trigger, - sessionKind: input.session.kind, - modelTarget: snapshot.modelTarget, - configuredModel: input.configuredModel, - rememberedModel: rememberedModelId, - rememberedSource: rememberedSelection?.source, - desiredModel: desiredModelIdentifier, - desiredResolution: snapshot.desiredModelResolution.kind, - fallbackModel: fallbackModel?.identifier, - availableModels: snapshot.models.map(model => model.identifier).join(','), - previousModel: previousState.currentModel?.identifier, - previousReason: currentReason, - resultModel: result.currentModel?.identifier, - resultReason: result.currentReason, - pendingSource: result.pendingSelection?.source, - pendingReference: result.pendingSelection?.reference, - effect: result.effect.kind, - effectModel: result.effect.kind === 'apply' ? result.effect.model.identifier : undefined, - effectReason: result.effect.kind === 'none' ? undefined : result.effect.reason, - }, result.effect.kind === 'none' && previousState.currentModel?.identifier === result.currentModel?.identifier ? 'debug' : 'info'); - return { previousState, snapshot, result }; - } - - captureState(): IChatModelSelectionSnapshot { - return { - currentModel: this._currentModel.get(), - sessionKey: this._sessionKey, - lastPushedChatKey: this._lastPushedChatKey, - currentReason: this._currentReason, - pendingSelection: this._pendingSelection, - }; - } - - restoreState(snapshot: IChatModelSelectionSnapshot): void { - this._sessionKey = snapshot.sessionKey; - this._lastPushedChatKey = snapshot.lastPushedChatKey; - this._currentReason = snapshot.currentReason; - this._pendingSelection = snapshot.pendingSelection; - this._currentModel.set(snapshot.currentModel, undefined); - } -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.ts index b4edadc8e4c0c2..30a5e72de8f409 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.ts @@ -213,7 +213,7 @@ export class MobileChatInputCombinedPickerActionItem extends BaseActionViewItem const currentModel = this._modelDelegate.currentModel.get(); if (currentModel) { - dom.append(trigger, renderIcon(getModelProviderIcon(currentModel, this._modelDelegate.useGenericModelIcon?.()))); + dom.append(trigger, renderIcon(getModelProviderIcon(currentModel, this._modelDelegate.getPresentationOptions().useGenericModelIcon))); } const labelText = currentModel?.metadata.name ?? localize('chatPhoneInput.autoLabel', "Auto"); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts index a1099653f7b19b..0959e4a6134d49 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerActionItem.ts @@ -39,23 +39,20 @@ export interface IModelConfigurationAccess { readonly onDidChange?: Event; } +export interface IModelPickerPresentationOptions { + readonly useGroupedModelPicker: boolean; + readonly showManageModelsAction: boolean; + readonly showUnavailableFeatured: boolean; + readonly showFeatured: boolean; + readonly showAutoModel: boolean; + readonly useGenericModelIcon: boolean; +} + export interface IModelPickerDelegate { readonly currentModel: IObservable; setModel(model: ILanguageModelChatMetadataAndIdentifier): void; getModels(): ILanguageModelChatMetadataAndIdentifier[]; - useGroupedModelPicker(): boolean; - showManageModelsAction(): boolean; - showUnavailableFeatured(): boolean; - showFeatured(): boolean; - useGenericModelIcon?(): boolean; - /** - * Whether the synthetic "Auto" model is available for the current session, - * so it can fall back to Auto. Defaults to `true` when omitted. When this - * returns `false` and {@link getModels} is empty, the picker shows a - * "No models available" entry (and an upgrade prompt for Copilot Free / - * Student users) instead of an Auto entry. - */ - showAutoModel?(): boolean; + getPresentationOptions(): IModelPickerPresentationOptions; /** * The id of the current chat session, used to correlate model-picker * changes with the session in telemetry. Matches the `chatSessionId` diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts new file mode 100644 index 00000000000000..10309856f280b7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemPrimitives.ts @@ -0,0 +1,236 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAction, toAction } from '../../../../../../../base/common/actions.js'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import * as semver from '../../../../../../../base/common/semver/semver.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../../nls.js'; +import { ActionListItemKind, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; +import { StateType } from '../../../../../../../platform/update/common/update.js'; +import { ChatEntitlement, IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; +import { IModelControlEntry, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; +import { getModelHoverContent } from './modelPickerHover.js'; +import { getPriceCategoryLabel, isMultiplierPricing } from './modelPickerPresentation.js'; + +export function isVersionAtLeast(current: string, required: string): boolean { + const currentSemver = semver.coerce(current); + return !!currentSemver && semver.gte(currentSemver, required); +} + +function getUpdateHoverContent(updateState: StateType): MarkdownString { + const hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); + switch (updateState) { + case StateType.AvailableForDownload: + hoverContent.appendMarkdown(localize('chat.modelPicker.downloadUpdateHover', "This model requires a newer version of VS Code. [Download Update](command:update.downloadUpdate) to access it.")); + break; + case StateType.Downloaded: + case StateType.Ready: + hoverContent.appendMarkdown(localize('chat.modelPicker.restartUpdateHover', "This model requires a newer version of VS Code. [Restart to Update](command:update.restartToUpdate) to access it.")); + break; + default: + hoverContent.appendMarkdown(localize('chat.modelPicker.checkUpdateHover', "This model requires a newer version of VS Code. [Update VS Code](command:update.checkForUpdate) to access it.")); + break; + } + return hoverContent; +} + +export type ProviderGroupKey = string; + +export interface IProviderGroupInfo { + readonly vendor: string; + readonly groupName: string; +} + +export function getProviderGroupKey(vendor: string, groupName: string): ProviderGroupKey { + return `${vendor}\u0000${groupName}`; +} + +function getVendorDisplayName(languageModelsService: ILanguageModelsService, vendor: string): string { + if (vendor === 'copilotcli') { + // @vritant24: This is temporary until we we have 2 distinct vendors for Copilot CLI vs Copilot Chat. + return localize('chat.modelPicker.copilotGroup', "Copilot"); + } + const descriptor = languageModelsService.getVendors().find(candidate => candidate.vendor === vendor); + return descriptor?.displayName ?? vendor.charAt(0).toUpperCase() + vendor.slice(1); +} + +export function buildModelToProviderGroupMap(languageModelsService: ILanguageModelsService): Map { + const map = new Map(); + for (const vendor of languageModelsService.getVendors()) { + for (const group of languageModelsService.getLanguageModelGroups(vendor.vendor)) { + const groupName = group.group?.name ?? vendor.displayName; + for (const identifier of group.modelIdentifiers) { + map.set(identifier, { vendor: vendor.vendor, groupName }); + } + } + } + return map; +} + +export function getProviderGroupForModel( + model: ILanguageModelChatMetadataAndIdentifier, + modelToGroup: Map, + languageModelsService: ILanguageModelsService, +): IProviderGroupInfo { + if (model.metadata.modelGroup) { + return { vendor: model.metadata.vendor, groupName: getVendorDisplayName(languageModelsService, model.metadata.modelGroup.id) }; + } + return modelToGroup.get(model.identifier) ?? { + vendor: model.metadata.vendor, + groupName: getVendorDisplayName(languageModelsService, model.metadata.vendor), + }; +} + +export function createModelItem( + action: IActionWidgetDropdownAction & { section?: string }, + model?: ILanguageModelChatMetadataAndIdentifier, + openerService?: IOpenerService, + vendorLabel?: string, + isUBB?: boolean, + ariaDescription?: string, + pinAction?: IAction, + onConfigure?: (model: ILanguageModelChatMetadataAndIdentifier, group: string) => void, +): IActionListItem { + const hover = model && openerService + ? getModelHoverContent(model, isUBB, onConfigure ? group => onConfigure(model, group) : undefined, openerService) + : undefined; + return { + item: action, + kind: ActionListItemKind.Action, + label: action.label, + description: action.description, + ariaDescription, + group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, + hideIcon: false, + section: action.section, + className: vendorLabel ? 'chat-model-picker-inline-source' : undefined, + badge: vendorLabel, + hover: hover ? { content: hover.element, disposable: hover.disposable } : undefined, + tooltip: action.tooltip, + toolbarActions: pinAction ? [pinAction] : undefined, + submenuActions: action.toolbarActions?.length ? action.toolbarActions : undefined, + }; +} + +export function createPinAction( + modelIdentifier: string, + isPinned: boolean, + onTogglePin: (modelIdentifier: string, pinned: boolean) => void, +): IAction { + return toAction({ + id: `pin.${modelIdentifier}`, + label: isPinned + ? localize('chat.modelPicker.unpin', "Unpin Model") + : localize('chat.modelPicker.pin', "Pin Model"), + class: ThemeIcon.asClassName(isPinned ? Codicon.pinned : Codicon.pin), + run: () => onTogglePin(modelIdentifier, !isPinned), + }); +} + +export function createModelAction( + model: ILanguageModelChatMetadataAndIdentifier, + selectedModelId: string | undefined, + onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, + section?: string, + suppressVendorInDetail?: boolean, +): { action: IActionWidgetDropdownAction & { section?: string }; ariaDescription?: string } { + const pricingForDescription = isMultiplierPricing(model) ? model.metadata.pricing : undefined; + const priceCategoryLabel = getPriceCategoryLabel(model.metadata.priceCategory); + const detail = suppressVendorInDetail ? undefined : model.metadata.detail; + const promo = ILanguageModelChatMetadata.hasPromoDiscount(model.metadata) ? model.metadata.promo : undefined; + const promoDetail = promo ? localize('chat.promo.discount', "{0}% discount", promo.discountPercent) : undefined; + const textParts = [detail, promoDetail, pricingForDescription].filter(Boolean); + const textDescription = textParts.length > 0 ? textParts.join(' · ') : undefined; + const action: IActionWidgetDropdownAction & { section?: string } = { + id: model.identifier, + enabled: true, + icon: model.metadata.statusIcon, + checked: model.identifier === selectedModelId, + class: undefined, + description: textDescription, + tooltip: model.metadata.name, + label: model.metadata.name, + section, + run: () => onSelect(model), + }; + const ariaDescription = priceCategoryLabel + ? (textDescription ? textDescription + ' · ' + priceCategoryLabel : priceCategoryLabel) + : undefined; + return { action, ariaDescription }; +} + +export function getUnavailableReason( + entry: IModelControlEntry, + chatEntitlementService: IChatEntitlementService, + currentVSCodeVersion: string, +): 'upgrade' | 'update' | 'admin' { + const businessOrEnterprise = chatEntitlementService.entitlement === ChatEntitlement.Business || chatEntitlementService.entitlement === ChatEntitlement.Enterprise; + if (!businessOrEnterprise) { + return 'upgrade'; + } + return entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion) ? 'update' : 'admin'; +} + +export function createUnavailableModelItem( + id: string, + entry: IModelControlEntry, + reason: 'upgrade' | 'update' | 'admin', + manageSettingsUrl: string | undefined, + updateStateType: StateType, + chatEntitlementService: IChatEntitlementService, + section?: string, +): IActionListItem { + let description: string | MarkdownString | undefined; + if (reason === 'upgrade') { + description = new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade](command:workbench.action.chat.upgradePlan \" \")"), { isTrusted: true }); + } else if (reason === 'update') { + description = localize('chat.modelPicker.updateDescription', "Update VS Code"); + } else { + description = manageSettingsUrl + ? new MarkdownString(localize('chat.modelPicker.adminLink', "[Contact your admin]({0})", manageSettingsUrl), { isTrusted: true }) + : localize('chat.modelPicker.adminDescription', "Contact your admin"); + } + + let hoverContent: MarkdownString; + if (reason === 'upgrade') { + hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); + if (chatEntitlementService.entitlement === ChatEntitlement.Pro) { + hoverContent.appendMarkdown(localize('chat.modelPicker.upgradeHoverProPlus', "[Upgrade to GitHub Copilot Pro+](command:workbench.action.chat.upgradePlan \" \") to use the best models.")); + } else { + hoverContent.appendMarkdown(localize('chat.modelPicker.upgradeHover', "[Upgrade to GitHub Copilot Pro](command:workbench.action.chat.upgradePlan \" \") to use the best models.")); + } + } else if (reason === 'update') { + hoverContent = getUpdateHoverContent(updateStateType); + } else { + hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); + hoverContent.appendMarkdown(localize('chat.modelPicker.adminHover', "This model is not available. Contact your administrator to enable it.")); + } + + return { + item: { + id, + enabled: false, + checked: false, + class: undefined, + tooltip: entry.label, + label: entry.label, + description: typeof description === 'string' ? description : undefined, + run: () => { }, + }, + kind: ActionListItemKind.Action, + label: entry.label, + description, + group: { title: '', icon: ThemeIcon.fromId(Codicon.blank.id) }, + disabled: true, + hideIcon: false, + className: 'chat-model-picker-unavailable', + section, + hover: { content: hoverContent }, + }; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemSections.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemSections.ts new file mode 100644 index 00000000000000..f434345aa75644 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemSections.ts @@ -0,0 +1,389 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toAction } from '../../../../../../../base/common/actions.js'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { ThemeIcon } from '../../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../../nls.js'; +import { ActionListItemKind, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; +import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { ChatEntitlement } from '../../../../../../services/chat/common/chatEntitlementService.js'; +import { IModelControlEntry, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; +import { buildModelToProviderGroupMap, createModelAction, createModelItem, createPinAction, createUnavailableModelItem, getProviderGroupForModel, getProviderGroupKey, getUnavailableReason, isVersionAtLeast, ProviderGroupKey } from './modelPickerItemPrimitives.js'; +import type { IBuildModelPickerItemsOptions } from './modelPickerItemTypes.js'; +import { isAutoModel } from './modelPickerPresentation.js'; + +export const ModelPickerSection = { + Other: 'other', +} as const; + +export const RESTRICTED_MODE_TRUST_ACTION_ID = 'restrictedModeTrust'; +export const SETUP_REQUIRED_SIGN_IN_ACTION_ID = 'setupRequiredSignIn'; + +function createSyntheticAutoItem(): IActionListItem { + return createModelItem({ + id: 'auto', + enabled: true, + checked: true, + class: undefined, + tooltip: localize('chat.modelPicker.auto', "Auto"), + label: localize('chat.modelPicker.auto', "Auto"), + run: () => { }, + }); +} + +export function buildUnavailableStateItems(options: IBuildModelPickerItemsOptions): IActionListItem[] | undefined { + const { restrictedMode, setupRequired, showAutoModel } = options.presentation; + if (restrictedMode) { + const enabled = !!options.actions.onRequestTrust; + return [ + { kind: ActionListItemKind.Header, label: localize('chat.modelPicker.restrictedMode', "Models unavailable while in Restricted mode") }, + { + item: { + id: RESTRICTED_MODE_TRUST_ACTION_ID, + enabled, + checked: false, + class: undefined, + tooltip: localize('chat.modelPicker.restrictedMode.trustTooltip', "Trust the workspace to enable models."), + label: localize('chat.modelPicker.restrictedMode.trust', "Trust Workspace to enable models..."), + run: () => options.actions.onRequestTrust?.(), + }, + kind: ActionListItemKind.Action, + label: localize('chat.modelPicker.restrictedMode.trust', "Trust Workspace to enable models..."), + group: { title: '', icon: ThemeIcon.fromId(Codicon.workspaceTrusted.id) }, + disabled: !enabled, + hideIcon: false, + }, + ]; + } + if (setupRequired) { + const enabled = !!options.actions.onRequestSetup; + return [ + { kind: ActionListItemKind.Header, label: localize('chat.modelPicker.setupRequired', "Sign in to use Copilot") }, + { + item: { + id: SETUP_REQUIRED_SIGN_IN_ACTION_ID, + enabled, + checked: false, + class: undefined, + tooltip: localize('chat.modelPicker.setupRequired.signInTooltip', "Sign in to GitHub Copilot to choose a model."), + label: localize('chat.modelPicker.setupRequired.signIn', "Sign in to use Copilot..."), + run: () => options.actions.onRequestSetup?.(), + }, + kind: ActionListItemKind.Action, + label: localize('chat.modelPicker.setupRequired.signIn', "Sign in to use Copilot..."), + group: { title: '', icon: ThemeIcon.fromId(Codicon.signIn.id) }, + disabled: !enabled, + hideIcon: false, + }, + ]; + } + if (options.models.length > 0) { + return undefined; + } + if (showAutoModel) { + return undefined; + } + const entitlement = options.chatEntitlementService.entitlement; + const canUpgrade = entitlement === ChatEntitlement.Free || entitlement === ChatEntitlement.EDU; + const description = canUpgrade + ? new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade](command:workbench.action.chat.upgradePlan \" \")"), { isTrusted: true }) + : undefined; + const hover = canUpgrade ? new MarkdownString('', { isTrusted: true, supportThemeIcons: true }) : undefined; + hover?.appendMarkdown(localize('chat.modelPicker.upgradeHover', "[Upgrade to GitHub Copilot Pro](command:workbench.action.chat.upgradePlan \" \") to use the best models.")); + return [{ + item: { + id: 'noModels', + enabled: false, + checked: false, + class: undefined, + tooltip: localize('chat.modelPicker.noModels', "No models available"), + label: localize('chat.modelPicker.noModels', "No models available"), + run: () => { }, + }, + kind: ActionListItemKind.Action, + label: localize('chat.modelPicker.noModels', "No models available"), + description, + group: { title: '', icon: ThemeIcon.fromId(Codicon.blank.id) }, + disabled: true, + hideIcon: false, + hover: hover ? { content: hover } : undefined, + }]; +} + +export function buildFlatModelItems(options: IBuildModelPickerItemsOptions): IActionListItem[] { + const items: IActionListItem[] = []; + if (options.models.length === 0 && options.presentation.showAutoModel) { + items.push(createSyntheticAutoItem()); + } + const autoModel = options.models.find(isAutoModel); + if (autoModel) { + const { action, ariaDescription } = createModelAction(autoModel, options.selectedModelId, options.actions.onSelect); + items.push(createModelItem(action, autoModel, options.openerService, undefined, options.presentation.isUBB, ariaDescription)); + } + const sortedModels = options.models + .filter(model => model !== autoModel) + .sort((left, right) => left.metadata.vendor.localeCompare(right.metadata.vendor) || left.metadata.name.localeCompare(right.metadata.name)); + for (const model of sortedModels) { + const { action, ariaDescription } = createModelAction(model, options.selectedModelId, options.actions.onSelect); + items.push(createModelItem(action, model, options.openerService, undefined, options.presentation.isUBB, ariaDescription, undefined, options.actions.onConfigure)); + } + return items; +} + +interface IGroupedContext { + readonly options: IBuildModelPickerItemsOptions; + readonly items: IActionListItem[]; + readonly modelToGroup: ReturnType; + readonly resolveModel: (id: string) => ILanguageModelChatMetadataAndIdentifier | undefined; + readonly placed: Set; + readonly showGroupLabel: boolean; + readonly makePinAction: (model: ILanguageModelChatMetadataAndIdentifier) => ReturnType | undefined; + markPlaced(identifierOrId: string, metadataId?: string): void; +} + +function createGroupedContext(options: IBuildModelPickerItemsOptions): IGroupedContext { + const modelToGroup = buildModelToProviderGroupMap(options.languageModelsService); + const allModels = new Map(options.models.map(model => [model.identifier, model])); + const modelsByMetadataId = new Map(options.models.map(model => [model.metadata.id, model])); + const placed = new Set(); + return { + options, + items: [], + modelToGroup, + resolveModel: id => allModels.get(id) ?? modelsByMetadataId.get(id), + placed, + showGroupLabel: new Set(options.models.map(model => { + const group = getProviderGroupForModel(model, modelToGroup, options.languageModelsService); + return getProviderGroupKey(group.vendor, group.groupName); + })).size > 1, + makePinAction: model => options.actions.onTogglePin + ? createPinAction(model.identifier, options.pinnedModelIds.includes(model.identifier), options.actions.onTogglePin) + : undefined, + markPlaced: (identifierOrId, metadataId) => { + placed.add(identifierOrId); + if (metadataId) { + placed.add(metadataId); + } + }, + }; +} + +function appendLeadingModels(context: IGroupedContext): ILanguageModelChatMetadataAndIdentifier | undefined { + const { options, items } = context; + const autoModel = options.models.find(isAutoModel); + if (!autoModel && options.models.length === 0 && options.presentation.showAutoModel) { + items.push(createSyntheticAutoItem()); + } + if (autoModel) { + context.markPlaced(autoModel.identifier, autoModel.metadata.id); + const { action, ariaDescription } = createModelAction(autoModel, options.selectedModelId, options.actions.onSelect); + items.push(createModelItem(action, autoModel, options.openerService, undefined, options.presentation.isUBB, ariaDescription)); + } + for (const model of options.models) { + if (!context.placed.has(model.identifier) && !context.placed.has(model.metadata.id) && ILanguageModelChatMetadata.hasPromoDiscount(model.metadata)) { + context.markPlaced(model.identifier, model.metadata.id); + const { action, ariaDescription } = createModelAction(model, options.selectedModelId, options.actions.onSelect); + items.push(createModelItem(action, model, options.openerService, undefined, options.presentation.isUBB, ariaDescription)); + } + } + return autoModel; +} + +function appendPinnedModels(context: IGroupedContext): Set { + const { options, items } = context; + const pinnedSet = new Set(options.pinnedModelIds); + const pinnedModels: ILanguageModelChatMetadataAndIdentifier[] = []; + for (const id of options.pinnedModelIds) { + const model = context.resolveModel(id); + if (!context.placed.has(id) && model && !context.placed.has(model.identifier)) { + context.markPlaced(model.identifier, model.metadata.id); + pinnedModels.push(model); + } + } + pinnedModels.sort((left, right) => { + const leftGroup = getProviderGroupForModel(left, context.modelToGroup, options.languageModelsService); + const rightGroup = getProviderGroupForModel(right, context.modelToGroup, options.languageModelsService); + return leftGroup.groupName.localeCompare(rightGroup.groupName) || left.metadata.name.localeCompare(right.metadata.name); + }); + if (pinnedModels.length > 0) { + items.push({ kind: ActionListItemKind.Separator, label: localize('chat.modelPicker.pinned', "Pinned") }); + for (const model of pinnedModels) { + const groupLabel = context.showGroupLabel ? getProviderGroupForModel(model, context.modelToGroup, options.languageModelsService).groupName : undefined; + const { action, ariaDescription } = createModelAction(model, options.selectedModelId, options.actions.onSelect, undefined, context.showGroupLabel); + items.push(createModelItem(action, model, options.openerService, groupLabel, options.presentation.isUBB, ariaDescription, context.makePinAction(model), options.actions.onConfigure)); + } + } + return pinnedSet; +} + +type PromotedItem = + | { readonly kind: 'available'; readonly model: ILanguageModelChatMetadataAndIdentifier } + | { readonly kind: 'unavailable'; readonly id: string; readonly entry: IModelControlEntry; readonly reason: 'upgrade' | 'update' | 'admin' }; + +function appendPromotedModels(context: IGroupedContext, autoModel: ILanguageModelChatMetadataAndIdentifier | undefined, pinnedSet: Set): void { + const { options, items } = context; + const promoted: PromotedItem[] = []; + const tryPlace = (id: string): boolean => { + if (context.placed.has(id)) { + return false; + } + const model = context.resolveModel(id); + if (model && !context.placed.has(model.identifier)) { + context.markPlaced(model.identifier, model.metadata.id); + const entry = options.controlModels[model.metadata.id]; + if (entry?.minVSCodeVersion && !isVersionAtLeast(options.currentVSCodeVersion, entry.minVSCodeVersion)) { + promoted.push({ kind: 'unavailable', id: model.metadata.id, entry, reason: 'update' }); + } else { + promoted.push({ kind: 'available', model }); + } + return true; + } + const entry = options.controlModels[id]; + if (!model && entry && !entry.exists) { + context.markPlaced(id); + promoted.push({ kind: 'unavailable', id, entry, reason: getUnavailableReason(entry, options.chatEntitlementService, options.currentVSCodeVersion) }); + return true; + } + return false; + }; + if (options.selectedModelId && options.selectedModelId !== autoModel?.identifier) { + tryPlace(options.selectedModelId); + } + for (const id of options.recentModelIds.filter(id => !pinnedSet.has(id)).slice(0, 3)) { + tryPlace(id); + } + if (options.presentation.showFeatured) { + for (const model of options.models) { + if (model.metadata.promo && !ILanguageModelChatMetadata.hasPromoDiscount(model.metadata)) { + tryPlace(model.identifier); + } + } + for (const [entryId, entry] of Object.entries(options.controlModels)) { + if (!entry.featured || context.placed.has(entryId)) { + continue; + } + const model = context.resolveModel(entryId); + if (model && !context.placed.has(model.identifier)) { + if (entry.minVSCodeVersion && !isVersionAtLeast(options.currentVSCodeVersion, entry.minVSCodeVersion)) { + if (options.presentation.showUnavailableFeatured) { + context.markPlaced(model.identifier, model.metadata.id); + promoted.push({ kind: 'unavailable', id: entryId, entry, reason: 'update' }); + } + } else { + context.markPlaced(model.identifier, model.metadata.id); + promoted.push({ kind: 'available', model }); + } + } else if (!model && !entry.exists && options.presentation.showUnavailableFeatured) { + context.markPlaced(entryId); + promoted.push({ kind: 'unavailable', id: entryId, entry, reason: getUnavailableReason(entry, options.chatEntitlementService, options.currentVSCodeVersion) }); + } + } + } + if (promoted.length === 0) { + return; + } + if (items.length > 0) { + items.push({ kind: ActionListItemKind.Separator }); + } + promoted.sort((left, right) => { + const availability = (left.kind === 'available' ? 0 : 1) - (right.kind === 'available' ? 0 : 1); + const leftName = left.kind === 'available' ? left.model.metadata.name : left.entry.label; + const rightName = right.kind === 'available' ? right.model.metadata.name : right.entry.label; + return availability || leftName.localeCompare(rightName); + }); + for (const item of promoted) { + if (item.kind === 'available') { + const groupLabel = context.showGroupLabel ? getProviderGroupForModel(item.model, context.modelToGroup, options.languageModelsService).groupName : undefined; + const { action, ariaDescription } = createModelAction(item.model, options.selectedModelId, options.actions.onSelect, undefined, context.showGroupLabel); + items.push(createModelItem(action, item.model, options.openerService, groupLabel, options.presentation.isUBB, ariaDescription, context.makePinAction(item.model), options.actions.onConfigure)); + } else { + items.push(createUnavailableModelItem(item.id, item.entry, item.reason, options.manageSettingsUrl, options.updateStateType, options.chatEntitlementService)); + } + } +} + +function appendOtherModels(context: IGroupedContext): boolean { + const { options, items } = context; + const otherModels = options.models.filter(model => !context.placed.has(model.identifier) && !context.placed.has(model.metadata.id)); + if (otherModels.length === 0) { + return false; + } + if (items.length > 0) { + items.push({ kind: ActionListItemKind.Separator }); + } + const toolbarActions = options.manageModelsAction + ? [toAction({ id: options.manageModelsAction.id, label: options.manageModelsAction.tooltip ?? options.manageModelsAction.label, class: ThemeIcon.asClassName(Codicon.gear), run: () => options.manageModelsAction!.run() })] + : undefined; + items.push({ + item: { id: 'otherModels', enabled: true, checked: false, class: undefined, tooltip: localize('chat.modelPicker.otherModels', "Other Models"), label: localize('chat.modelPicker.otherModels', "Other Models"), run: () => { } }, + kind: ActionListItemKind.Action, + label: localize('chat.modelPicker.otherModels', "Other Models"), + group: { title: '', icon: Codicon.chevronDown }, + hideIcon: false, + section: ModelPickerSection.Other, + isSectionToggle: true, + toolbarActions, + className: 'chat-model-picker-section-toggle', + }); + interface IProviderGroupBucket { vendor: string; groupName: string; models: ILanguageModelChatMetadataAndIdentifier[] } + const groups = new Map(); + for (const model of otherModels) { + const info = getProviderGroupForModel(model, context.modelToGroup, options.languageModelsService); + const key = getProviderGroupKey(info.vendor, info.groupName); + const bucket = groups.get(key) ?? { vendor: info.vendor, groupName: info.groupName, models: [] }; + bucket.models.push(model); + groups.set(key, bucket); + } + const sortedGroups = [...groups.values()].sort((left, right) => { + if (left.vendor === 'copilot' && right.vendor !== 'copilot') { return -1; } + if (right.vendor === 'copilot' && left.vendor !== 'copilot') { return 1; } + return left.groupName.localeCompare(right.groupName); + }); + const showHeaders = sortedGroups.length > 1; + for (const group of sortedGroups) { + if (showHeaders) { + items.push({ kind: ActionListItemKind.Separator, label: group.groupName, section: ModelPickerSection.Other }); + } + group.models.sort((left, right) => { + const leftEntry = options.controlModels[left.metadata.id] ?? options.controlModels[left.identifier]; + const rightEntry = options.controlModels[right.metadata.id] ?? options.controlModels[right.identifier]; + const leftUnavailable = leftEntry?.minVSCodeVersion && !isVersionAtLeast(options.currentVSCodeVersion, leftEntry.minVSCodeVersion) ? 1 : 0; + const rightUnavailable = rightEntry?.minVSCodeVersion && !isVersionAtLeast(options.currentVSCodeVersion, rightEntry.minVSCodeVersion) ? 1 : 0; + return leftUnavailable - rightUnavailable || left.metadata.name.localeCompare(right.metadata.name); + }); + for (const model of group.models) { + const entry = options.controlModels[model.metadata.id] ?? options.controlModels[model.identifier]; + if (entry?.minVSCodeVersion && !isVersionAtLeast(options.currentVSCodeVersion, entry.minVSCodeVersion)) { + items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', options.manageSettingsUrl, options.updateStateType, options.chatEntitlementService, ModelPickerSection.Other)); + } else { + const { action, ariaDescription } = createModelAction(model, options.selectedModelId, options.actions.onSelect, ModelPickerSection.Other, showHeaders); + items.push(createModelItem(action, model, options.openerService, undefined, options.presentation.isUBB, ariaDescription, context.makePinAction(model), options.actions.onConfigure)); + } + } + } + return true; +} + +export function buildGroupedModelItems(options: IBuildModelPickerItemsOptions): IActionListItem[] { + const context = createGroupedContext(options); + const autoModel = appendLeadingModels(context); + const pinnedSet = appendPinnedModels(context); + appendPromotedModels(context, autoModel, pinnedSet); + const hasOtherModels = appendOtherModels(context); + if (options.manageModelsAction && !hasOtherModels) { + context.items.push({ kind: ActionListItemKind.Separator }); + context.items.push({ + item: options.manageModelsAction, + kind: ActionListItemKind.Action, + label: options.manageModelsAction.label, + group: { title: '', icon: Codicon.blank }, + hideIcon: false, + showAlways: true, + }); + } + return context.items; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemTypes.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemTypes.ts new file mode 100644 index 00000000000000..e577044e75e880 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItemTypes.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStringDictionary } from '../../../../../../../base/common/collections.js'; +import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; +import { StateType } from '../../../../../../../platform/update/common/update.js'; +import { IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; +import { IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; + +export interface IBuildModelPickerItemsOptions { + readonly models: ILanguageModelChatMetadataAndIdentifier[]; + readonly selectedModelId: string | undefined; + readonly recentModelIds: string[]; + readonly pinnedModelIds: string[]; + readonly controlModels: IStringDictionary; + readonly currentVSCodeVersion: string; + readonly updateStateType: StateType; + readonly manageSettingsUrl: string | undefined; + readonly manageModelsAction: IActionWidgetDropdownAction | undefined; + readonly chatEntitlementService: IChatEntitlementService; + readonly languageModelsService: ILanguageModelsService; + readonly openerService: IOpenerService | undefined; + readonly presentation: { + readonly useGroupedModelPicker: boolean; + readonly showUnavailableFeatured: boolean; + readonly showFeatured: boolean; + readonly showAutoModel: boolean; + readonly restrictedMode: boolean; + readonly setupRequired: boolean; + readonly isUBB: boolean; + }; + readonly actions: { + readonly onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void; + readonly onTogglePin: ((modelIdentifier: string, pinned: boolean) => void) | undefined; + readonly onConfigure: ((model: ILanguageModelChatMetadataAndIdentifier, group: string) => void) | undefined; + readonly onRequestTrust: (() => void) | undefined; + readonly onRequestSetup: (() => void) | undefined; + }; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts index c84b65c33b099a..108da77a0cb3d9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerItems.ts @@ -3,250 +3,28 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IAction, toAction } from '../../../../../../../base/common/actions.js'; import { IStringDictionary } from '../../../../../../../base/common/collections.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; -import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { ThemeIcon } from '../../../../../../../base/common/themables.js'; import { localize } from '../../../../../../../nls.js'; import { ActionListItemKind, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; -import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; -import { MANAGE_CHAT_COMMAND_ID } from '../../../../common/constants.js'; -import { IModelControlEntry, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelsControlManifest } from '../../../../common/languageModels.js'; import { ChatEntitlement, IChatEntitlementService, isProUser } from '../../../../../../services/chat/common/chatEntitlementService.js'; -import * as semver from '../../../../../../../base/common/semver/semver.js'; -import { getModelHoverContent } from './modelPickerHover.js'; -import { getPriceCategoryLabel, isAutoModel, isMultiplierPricing } from './modelPickerPresentation.js'; -import { StateType } from '../../../../../../../platform/update/common/update.js'; +import { MANAGE_CHAT_COMMAND_ID } from '../../../../common/constants.js'; +import { IModelControlEntry, IModelsControlManifest } from '../../../../common/languageModels.js'; +import { buildFlatModelItems, buildGroupedModelItems, buildUnavailableStateItems, RESTRICTED_MODE_TRUST_ACTION_ID, SETUP_REQUIRED_SIGN_IN_ACTION_ID } from './modelPickerItemSections.js'; +import type { IBuildModelPickerItemsOptions } from './modelPickerItemTypes.js'; -function isVersionAtLeast(current: string, required: string): boolean { - const currentSemver = semver.coerce(current); - if (!currentSemver) { - return false; - } - return semver.gte(currentSemver, required); -} +export type { IBuildModelPickerItemsOptions } from './modelPickerItemTypes.js'; +export { ModelPickerSection } from './modelPickerItemSections.js'; -function getUpdateHoverContent(updateState: StateType): MarkdownString { - const hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); - switch (updateState) { - case StateType.AvailableForDownload: - hoverContent.appendMarkdown(localize('chat.modelPicker.downloadUpdateHover', "This model requires a newer version of VS Code. [Download Update](command:update.downloadUpdate) to access it.")); - break; - case StateType.Downloaded: - case StateType.Ready: - hoverContent.appendMarkdown(localize('chat.modelPicker.restartUpdateHover', "This model requires a newer version of VS Code. [Restart to Update](command:update.restartToUpdate) to access it.")); - break; - default: - hoverContent.appendMarkdown(localize('chat.modelPicker.checkUpdateHover', "This model requires a newer version of VS Code. [Update VS Code](command:update.checkForUpdate) to access it.")); - break; - } - return hoverContent; -} +const PICKER_COMMAND_ACTION_IDS: ReadonlySet = new Set([RESTRICTED_MODE_TRUST_ACTION_ID, SETUP_REQUIRED_SIGN_IN_ACTION_ID]); export function getControlModelsForEntitlement(manifest: IModelsControlManifest, entitlement: ChatEntitlement): IStringDictionary { return isProUser(entitlement) && entitlement !== ChatEntitlement.EDU ? manifest.paid : manifest.free; } -/** - * Section identifiers for collapsible groups in the model picker. - */ -export const ModelPickerSection = { - Other: 'other', -} as const; - -/** - * Id of the synthetic "Trust Workspace to enable models..." entry shown in Restricted Mode. It is - * a command (not a selectable model), so the accessibility provider gives it a - * plain `menuitem` role instead of `menuitemradio`. - */ -const RESTRICTED_MODE_TRUST_ACTION_ID = 'restrictedModeTrust'; - -/** - * Id of the synthetic "Sign in to use Copilot..." entry shown when Chat still - * requires sign-in / setup. Like the Trust entry it is a command, so it gets a - * plain `menuitem` role. - */ -const SETUP_REQUIRED_SIGN_IN_ACTION_ID = 'setupRequiredSignIn'; - -/** Synthetic command entries (Trust / Sign in) that are not selectable models. */ -const PICKER_COMMAND_ACTION_IDS: ReadonlySet = new Set([RESTRICTED_MODE_TRUST_ACTION_ID, SETUP_REQUIRED_SIGN_IN_ACTION_ID]); - -/** - * Returns a human-readable display name for a model vendor. - * Uses known product names before falling back to the registered provider - * descriptor or a capitalized vendor id. - */ -function getVendorDisplayName(languageModelsService: ILanguageModelsService, vendor: string): string { - if (vendor === 'copilotcli') { - // @vritant24: This is temporary until we we have 2 distinct vendors for Copilot CLI vs Copilot Chat. - // For now, we want to show "Copilot" in the model picker for both. - return localize('chat.modelPicker.copilotGroup', "Copilot"); - } - const descriptor = languageModelsService.getVendors().find(v => v.vendor === vendor); - if (descriptor?.displayName) { - return descriptor.displayName; - } - return vendor.charAt(0).toUpperCase() + vendor.slice(1); -} - -/** - * Identifies a provider group bucket in the model picker. A bucket is - * defined by `(vendor, groupName)` so that BYOK setups with multiple - * user-configured groups under the same vendor (e.g. two `customoai` - * entries named "Provider 1" and "Provider 2") are surfaced as - * distinct sections — matching what the model configuration view shows. - */ -type ProviderGroupKey = string; - -function getProviderGroupKey(vendor: string, groupName: string): ProviderGroupKey { - return `${vendor}\u0000${groupName}`; -} - -interface IProviderGroupInfo { - readonly vendor: string; - readonly groupName: string; -} - -/** - * Builds a `modelIdentifier -> { vendor, groupName }` lookup by walking - * `getLanguageModelGroups()` for every registered vendor. Mirrors the - * grouping used by `chatModelsViewModel.ts` so the picker and the model - * configuration view stay aligned. - */ -function buildModelToProviderGroupMap(languageModelsService: ILanguageModelsService): Map { - const map = new Map(); - for (const vendor of languageModelsService.getVendors()) { - const groups = languageModelsService.getLanguageModelGroups(vendor.vendor); - for (const group of groups) { - // `group.group` is undefined for built-in vendors that have no - // user configuration; fall back to the vendor display name so - // the bucket key matches the single-section render path. - const groupName = group.group?.name ?? vendor.displayName; - for (const identifier of group.modelIdentifiers) { - map.set(identifier, { vendor: vendor.vendor, groupName }); - } - } - } - return map; -} - -/** - * Resolves the provider group for a model, falling back to the vendor - * display name when no group entry is registered (e.g. legacy vendors or - * tests that stub out `getLanguageModelGroups`). - */ -function getProviderGroupForModel( - model: ILanguageModelChatMetadataAndIdentifier, - modelToGroup: Map, - languageModelsService: ILanguageModelsService, -): IProviderGroupInfo { - // Agent-host models share one vendor but declare their upstream provider (a vendor id) - // via `modelGroup`; bucket by it, resolving the display name from the vendor registry — - // the same source used for every other vendor — so they don't collapse into one section. - if (model.metadata.modelGroup) { - return { vendor: model.metadata.vendor, groupName: getVendorDisplayName(languageModelsService, model.metadata.modelGroup.id) }; - } - - const info = modelToGroup.get(model.identifier); - if (info) { - return info; - } - return { - vendor: model.metadata.vendor, - groupName: getVendorDisplayName(languageModelsService, model.metadata.vendor), - }; -} - -function createModelItem( - action: IActionWidgetDropdownAction & { section?: string }, - model?: ILanguageModelChatMetadataAndIdentifier, - openerService?: IOpenerService, - vendorLabel?: string, - isUBB?: boolean, - ariaDescription?: string, - pinAction?: IAction, - onConfigure?: (model: ILanguageModelChatMetadataAndIdentifier, group: string) => void, -): IActionListItem { - const hover = model && openerService - ? getModelHoverContent(model, isUBB, onConfigure ? (group) => onConfigure(model, group) : undefined, openerService) - : undefined; - return { - item: action, - kind: ActionListItemKind.Action, - label: action.label, - description: action.description, - ariaDescription, - group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, - hideIcon: false, - section: action.section, - className: vendorLabel ? 'chat-model-picker-inline-source' : undefined, - badge: vendorLabel, - hover: hover ? { content: hover.element, disposable: hover.disposable } : undefined, - tooltip: action.tooltip, - toolbarActions: pinAction ? [pinAction] : undefined, - submenuActions: action.toolbarActions?.length ? action.toolbarActions : undefined, - }; -} - -/** - * Creates a pin/unpin toolbar action for a model item in the picker. - */ -function createPinAction( - modelIdentifier: string, - isPinned: boolean, - onTogglePin: (modelIdentifier: string, pinned: boolean) => void, -): IAction { - return toAction({ - id: `pin.${modelIdentifier}`, - label: isPinned - ? localize('chat.modelPicker.unpin', "Unpin Model") - : localize('chat.modelPicker.pin', "Pin Model"), - class: ThemeIcon.asClassName(isPinned ? Codicon.pinned : Codicon.pin), - run: () => onTogglePin(modelIdentifier, !isPinned), - }); -} - - -function createModelAction( - model: ILanguageModelChatMetadataAndIdentifier, - selectedModelId: string | undefined, - onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, - section?: string, - suppressVendorInDetail?: boolean, -): { action: IActionWidgetDropdownAction & { section?: string }; ariaDescription?: string } { - // Only show pricing in the description line if it's a multiplier (e.g. "2x"). - // Detailed AIC/token pricing is shown in the hover instead. - const pricingForDescription = isMultiplierPricing(model) ? model.metadata.pricing : undefined; - const priceCategoryLabel = getPriceCategoryLabel(model.metadata.priceCategory); - // Strip the detail when suppressVendorInDetail is set — the vendor is - // shown either inline (promoted) or in a section header (Other Models). - const detail = suppressVendorInDetail ? undefined : model.metadata.detail; - const promo = ILanguageModelChatMetadata.hasPromoDiscount(model.metadata) ? model.metadata.promo : undefined; - const promoDetail = promo ? localize('chat.promo.discount', "{0}% discount", promo.discountPercent) : undefined; - const textParts = [detail, promoDetail, pricingForDescription].filter(Boolean); - const textDescription = textParts.length > 0 ? textParts.join(' · ') : undefined; - - const action: IActionWidgetDropdownAction & { section?: string } = { - id: model.identifier, - enabled: true, - icon: model.metadata.statusIcon, - checked: model.identifier === selectedModelId, - class: undefined, - description: textDescription, - tooltip: model.metadata.name, - label: model.metadata.name, - section, - run: () => onSelect(model), - }; - const ariaDescription = priceCategoryLabel - ? (textDescription ? textDescription + ' · ' + priceCategoryLabel : priceCategoryLabel) - : undefined; - return { action, ariaDescription }; -} - export function shouldShowManageModelsAction(chatEntitlementService: IChatEntitlementService): boolean { return chatEntitlementService.clientByokEnabled || chatEntitlementService.hasByokModels || @@ -268,514 +46,19 @@ export function createManageModelsAction(commandService: ICommandService): IActi class: ThemeIcon.asClassName(Codicon.gear), tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), label: localize('chat.manageModels', "Manage Models..."), - run: () => { commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); } + run: () => { commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); }, }; } -/** - * Builds the grouped items for the model picker dropdown. - * - * Layout: - * 1. Auto (always first) - * 2. Pinned models, grouped by provider name and sorted by model name within each group - * 3. Promoted section (selected + recently used + featured models from control manifest) - * - Available models sorted alphabetically, followed by unavailable models - * - Unavailable models show upgrade/update/admin status - * - Promoted models show an inline source label (the provider group - * name) when more than one group is configured. - * 4. Other Models (collapsible toggle) - models grouped by provider group - * (vendor + user-configured group name) with separator headers - * - Each provider group has a titled separator header. This matches - * the buckets shown in the model configuration view, so a BYOK setup - * with several groups under a single vendor (e.g. an "OpenAI - * Compatible" group and an "AWS Bedrock" group both registered to - * the `customoai` vendor) renders as distinct sections. - * 5. Optional "Manage Models..." action shown in Other Models after a separator - * - * When `restrictedMode` is set (untrusted workspace), an explanatory "Models - * unavailable while in Restricted mode" header and a "Trust Workspace to enable - * models..." action (invoking `onRequestTrust`) replace all of the above. - * Likewise, when - * `setupRequired` is set (trusted, but Chat still needs sign-in / setup), a - * "Sign in to use Copilot" header and a Sign In action (invoking - * `onRequestSetup`) replace all of the above. `restrictedMode` takes precedence. - */ -export function buildModelPickerItems( - models: ILanguageModelChatMetadataAndIdentifier[], - selectedModelId: string | undefined, - recentModelIds: string[], - pinnedModelIds: string[], - controlModels: IStringDictionary, - currentVSCodeVersion: string, - updateStateType: StateType, - onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, - onTogglePin: ((modelIdentifier: string, pinned: boolean) => void) | undefined, - manageSettingsUrl: string | undefined, - useGroupedModelPicker: boolean, - manageModelsAction: IActionWidgetDropdownAction | undefined, - chatEntitlementService: IChatEntitlementService, - showUnavailableFeatured: boolean, - showFeatured: boolean, - languageModelsService?: ILanguageModelsService, - openerService?: IOpenerService, - showAutoModel: boolean = false, - onConfigure?: (model: ILanguageModelChatMetadataAndIdentifier, group: string) => void, - restrictedMode: boolean = false, - onRequestTrust?: () => void, - setupRequired: boolean = false, - onRequestSetup?: () => void, - isUBB: boolean = false, -): IActionListItem[] { - const items: IActionListItem[] = []; - if (restrictedMode) { - // Untrusted workspace: providers are disabled, so any `models` here are - // stale machine-cached entries. Surface a Trust action (mirroring the - // send-message trust prompt) instead of a misleading lone "Auto". Checked - // before the empty-list branch since cached entries can make `models` - // non-empty. - items.push({ - kind: ActionListItemKind.Header, - label: localize('chat.modelPicker.restrictedMode', "Models unavailable while in Restricted mode"), - }); - items.push({ - item: { - id: RESTRICTED_MODE_TRUST_ACTION_ID, - enabled: !!onRequestTrust, - checked: false, - class: undefined, - tooltip: localize('chat.modelPicker.restrictedMode.trustTooltip', "Trust the workspace to enable models."), - label: localize('chat.modelPicker.restrictedMode.trust', "Trust Workspace to enable models..."), - run: () => onRequestTrust?.() - }, - kind: ActionListItemKind.Action, - label: localize('chat.modelPicker.restrictedMode.trust', "Trust Workspace to enable models..."), - group: { title: '', icon: ThemeIcon.fromId(Codicon.workspaceTrusted.id) }, - disabled: !onRequestTrust, - hideIcon: false, - }); - return items; +/** Builds the ordered model picker sections for the current presentation state. */ +export function buildModelPickerItems(options: IBuildModelPickerItemsOptions): IActionListItem[] { + const unavailableItems = buildUnavailableStateItems(options); + if (unavailableItems) { + return unavailableItems; } - if (setupRequired) { - // Trusted, but Chat still needs sign-in / setup before any model is - // usable. Surface a Sign In action (mirroring the send-message setup - // prompt) instead of a misleading lone "Auto". Like restricted mode this - // is checked before the empty-list branch since stale machine-cached - // entries can make `models` non-empty. - items.push({ - kind: ActionListItemKind.Header, - label: localize('chat.modelPicker.setupRequired', "Sign in to use Copilot"), - }); - items.push({ - item: { - id: SETUP_REQUIRED_SIGN_IN_ACTION_ID, - enabled: !!onRequestSetup, - checked: false, - class: undefined, - tooltip: localize('chat.modelPicker.setupRequired.signInTooltip', "Sign in to GitHub Copilot to choose a model."), - label: localize('chat.modelPicker.setupRequired.signIn', "Sign in to use Copilot..."), - run: () => onRequestSetup?.() - }, - kind: ActionListItemKind.Action, - label: localize('chat.modelPicker.setupRequired.signIn', "Sign in to use Copilot..."), - group: { title: '', icon: ThemeIcon.fromId(Codicon.signIn.id) }, - disabled: !onRequestSetup, - hideIcon: false, - }); - return items; - } - if (models.length === 0) { - if (!showAutoModel) { - // Auto is not available for this session type (e.g. the Claude agent - // host), so the empty list cannot fall back to Auto. Surface a single - // disabled "No models available" entry. For Copilot Free / Student - // users, attach an inline upgrade link on the right (matching the - // unavailable-model upgrade affordance elsewhere in the picker). - const entitlement = chatEntitlementService.entitlement; - const canUpgrade = entitlement === ChatEntitlement.Free || entitlement === ChatEntitlement.EDU; - const description = canUpgrade - ? new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade](command:workbench.action.chat.upgradePlan \" \")"), { isTrusted: true }) - : undefined; - let hover: MarkdownString | undefined; - if (canUpgrade) { - hover = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); - hover.appendMarkdown(localize('chat.modelPicker.upgradeHover', "[Upgrade to GitHub Copilot Pro](command:workbench.action.chat.upgradePlan \" \") to use the best models.")); - } - items.push({ - item: { - id: 'noModels', - enabled: false, - checked: false, - class: undefined, - tooltip: localize('chat.modelPicker.noModels', "No models available"), - label: localize('chat.modelPicker.noModels', "No models available"), - run: () => { } - }, - kind: ActionListItemKind.Action, - label: localize('chat.modelPicker.noModels', "No models available"), - description, - group: { title: '', icon: ThemeIcon.fromId(Codicon.blank.id) }, - disabled: true, - hideIcon: false, - hover: hover ? { content: hover } : undefined, - }); - // Nothing else is selectable in this state, so surface only the - // single disabled entry. Returning here prevents the grouped-picker - // logic below from appending an Auto entry, model groups, or a - // standalone "Manage Models" action. - return items; - } else { - items.push(createModelItem({ - id: 'auto', - enabled: true, - checked: true, - class: undefined, - tooltip: localize('chat.modelPicker.auto', "Auto"), - label: localize('chat.modelPicker.auto', "Auto"), - run: () => { } - })); - } - } - - if (useGroupedModelPicker) { - let otherModels: ILanguageModelChatMetadataAndIdentifier[] = []; - // Build a lookup so each model can be assigned to its provider group - // (vendor + user-configured group name). This must happen before both - // the promoted-section badge logic and the Other Models grouping so - // that both surfaces use the same notion of "distinct provider". - const modelToGroup = languageModelsService - ? buildModelToProviderGroupMap(languageModelsService) - : new Map(); - if (models.length) { - // Collect all available models into lookup maps - const allModelsMap = new Map(); - const modelsByMetadataId = new Map(); - for (const model of models) { - allModelsMap.set(model.identifier, model); - modelsByMetadataId.set(model.metadata.id, model); - } - - const placed = new Set(); - - const markPlaced = (identifierOrId: string, metadataId?: string) => { - placed.add(identifierOrId); - if (metadataId) { - placed.add(metadataId); - } - }; - - const resolveModel = (id: string) => allModelsMap.get(id) ?? modelsByMetadataId.get(id); - - const getUnavailableReason = (entry: IModelControlEntry): 'upgrade' | 'update' | 'admin' => { - const isBusinessOrEnterpriseUser = chatEntitlementService.entitlement === ChatEntitlement.Business || chatEntitlementService.entitlement === ChatEntitlement.Enterprise; - if (!isBusinessOrEnterpriseUser) { - return 'upgrade'; - } - if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - return 'update'; - } - return 'admin'; - }; - - // --- 1. Auto --- - const autoModel = models.find(m => isAutoModel(m)); - if (autoModel) { - markPlaced(autoModel.identifier, autoModel.metadata.id); - const { action: autoAction, ariaDescription: autoAriaDesc } = createModelAction(autoModel, selectedModelId, onSelect); - items.push(createModelItem(autoAction, autoModel, openerService, undefined, isUBB, autoAriaDesc)); - } - - // --- 1b. Discounted promo models (boosted next to Auto) --- - for (const model of models) { - if (placed.has(model.identifier) || placed.has(model.metadata.id)) { - continue; - } - if (ILanguageModelChatMetadata.hasPromoDiscount(model.metadata)) { - markPlaced(model.identifier, model.metadata.id); - const { action: promoAction, ariaDescription: promoAriaDesc } = createModelAction(model, selectedModelId, onSelect); - items.push(createModelItem(promoAction, model, openerService, undefined, isUBB, promoAriaDesc)); - } - } - - // Precompute group labels needed for inline badges - const allGroupKeys = new Set( - models.map(m => { - const info = getProviderGroupForModel(m, modelToGroup, languageModelsService!); - return getProviderGroupKey(info.vendor, info.groupName); - }) - ); - const showGroupLabel = allGroupKeys.size > 1; - - // Helper to create a pin/unpin toolbar action for a model - const makePinAction = (model: ILanguageModelChatMetadataAndIdentifier) => - onTogglePin ? createPinAction(model.identifier, pinnedModelIds.includes(model.identifier), onTogglePin) : undefined; - - // --- 2. Pinned models --- - const pinnedSet = new Set(pinnedModelIds); - const pinnedModels: ILanguageModelChatMetadataAndIdentifier[] = []; - for (const id of pinnedModelIds) { - if (placed.has(id)) { - continue; - } - const model = resolveModel(id); - if (model && !placed.has(model.identifier)) { - markPlaced(model.identifier, model.metadata.id); - pinnedModels.push(model); - } - } - pinnedModels.sort((a, b) => { - const aGroup = getProviderGroupForModel(a, modelToGroup, languageModelsService!); - const bGroup = getProviderGroupForModel(b, modelToGroup, languageModelsService!); - const groupCompare = aGroup.groupName.localeCompare(bGroup.groupName); - return groupCompare || a.metadata.name.localeCompare(b.metadata.name); - }); - if (pinnedModels.length > 0) { - items.push({ kind: ActionListItemKind.Separator, label: localize('chat.modelPicker.pinned', "Pinned") }); - for (const model of pinnedModels) { - const groupLabel = showGroupLabel - ? getProviderGroupForModel(model, modelToGroup, languageModelsService!).groupName - : undefined; - const { action: pinnedAction, ariaDescription: pinnedAriaDesc } = createModelAction(model, selectedModelId, onSelect, undefined, showGroupLabel); - items.push(createModelItem(pinnedAction, model, openerService, groupLabel, isUBB, pinnedAriaDesc, makePinAction(model), onConfigure)); - } - } - - // --- 3. Promoted section (selected + recently used + featured) --- - // MRU excludes pinned models and is limited to 3 entries - const filteredRecentIds = recentModelIds.filter(id => !pinnedSet.has(id)).slice(0, 3); - - type PromotedItem = - | { kind: 'available'; model: ILanguageModelChatMetadataAndIdentifier } - | { kind: 'unavailable'; id: string; entry: IModelControlEntry; reason: 'upgrade' | 'update' | 'admin' }; - - const promotedItems: PromotedItem[] = []; - - // Try to place a model by id. Returns true if handled. - const tryPlaceModel = (id: string): boolean => { - if (placed.has(id)) { - return false; - } - const model = resolveModel(id); - if (model && !placed.has(model.identifier)) { - markPlaced(model.identifier, model.metadata.id); - const entry = controlModels[model.metadata.id]; - if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - promotedItems.push({ kind: 'unavailable', id: model.metadata.id, entry, reason: 'update' }); - } else { - promotedItems.push({ kind: 'available', model }); - } - return true; - } - if (!model) { - const entry = controlModels[id]; - if (entry && !entry.exists) { - markPlaced(id); - promotedItems.push({ kind: 'unavailable', id, entry, reason: getUnavailableReason(entry) }); - return true; - } - } - return false; - }; - - // Selected model - if (selectedModelId && selectedModelId !== autoModel?.identifier) { - tryPlaceModel(selectedModelId); - } - - // Recently used models (filtered to exclude pinned, limited to 3) - for (const id of filteredRecentIds) { - tryPlaceModel(id); - } - - // Non-discount promos are featured without promotional presentation. - if (showFeatured) { - for (const model of models) { - if (model.metadata.promo && !ILanguageModelChatMetadata.hasPromoDiscount(model.metadata)) { - tryPlaceModel(model.identifier); - } - } - } - - // Featured models from control manifest - if (showFeatured) { - for (const [entryId, entry] of Object.entries(controlModels)) { - if (!entry.featured || placed.has(entryId)) { - continue; - } - const model = resolveModel(entryId); - if (model && !placed.has(model.identifier)) { - if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - if (showUnavailableFeatured) { - markPlaced(model.identifier, model.metadata.id); - promotedItems.push({ kind: 'unavailable', id: entryId, entry, reason: 'update' }); - } - } else { - markPlaced(model.identifier, model.metadata.id); - promotedItems.push({ kind: 'available', model }); - } - } else if (!model && !entry.exists) { - if (showUnavailableFeatured) { - markPlaced(entryId); - promotedItems.push({ kind: 'unavailable', id: entryId, entry, reason: getUnavailableReason(entry) }); - } - } - } - } - - // Render promoted section: available first, then sorted alphabetically by name. - // Promoted models show their provider group name inline only when more - // than one provider group is configured across all models. - if (promotedItems.length > 0) { - if (items.length > 0) { - items.push({ kind: ActionListItemKind.Separator }); - } - promotedItems.sort((a, b) => { - const aAvail = a.kind === 'available' ? 0 : 1; - const bAvail = b.kind === 'available' ? 0 : 1; - if (aAvail !== bAvail) { - return aAvail - bAvail; - } - const aName = a.kind === 'available' ? a.model.metadata.name : a.entry.label; - const bName = b.kind === 'available' ? b.model.metadata.name : b.entry.label; - return aName.localeCompare(bName); - }); - - for (const item of promotedItems) { - if (item.kind === 'available') { - const groupLabel = showGroupLabel - ? getProviderGroupForModel(item.model, modelToGroup, languageModelsService!).groupName - : undefined; - const { action: promotedAction, ariaDescription: promotedAriaDesc } = createModelAction(item.model, selectedModelId, onSelect, undefined, showGroupLabel); - items.push(createModelItem(promotedAction, item.model, openerService, groupLabel, isUBB, promotedAriaDesc, makePinAction(item.model), onConfigure)); - } else { - items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType, chatEntitlementService)); - } - } - } - - // --- 3. Other Models (collapsible, grouped by provider group) --- - otherModels = models.filter(m => !placed.has(m.identifier) && !placed.has(m.metadata.id)); - - if (otherModels.length > 0) { - if (items.length > 0) { - items.push({ kind: ActionListItemKind.Separator }); - } - const otherModelsToolbar = manageModelsAction - ? [toAction({ id: manageModelsAction.id, label: manageModelsAction.tooltip ?? manageModelsAction.label, class: ThemeIcon.asClassName(Codicon.gear), run: () => manageModelsAction.run() })] - : undefined; - items.push({ - item: { - id: 'otherModels', - enabled: true, - checked: false, - class: undefined, - tooltip: localize('chat.modelPicker.otherModels', "Other Models"), - label: localize('chat.modelPicker.otherModels', "Other Models"), - run: () => { /* toggle handled by isSectionToggle */ } - }, - kind: ActionListItemKind.Action, - label: localize('chat.modelPicker.otherModels', "Other Models"), - group: { title: '', icon: Codicon.chevronDown }, - hideIcon: false, - section: ModelPickerSection.Other, - isSectionToggle: true, - toolbarActions: otherModelsToolbar, - className: 'chat-model-picker-section-toggle', - }); - - // Group remaining models by provider group (vendor + user-configured - // group name). This matches `chatModelsViewModel.getProviderGroupId`, - // so that BYOK setups with several groups under a single vendor - // (e.g. multiple `customoai` entries) render as distinct sections. - interface IProviderGroupBucket { - vendor: string; - groupName: string; - models: ILanguageModelChatMetadataAndIdentifier[]; - } - const providerGroups = new Map(); - for (const model of otherModels) { - const info = getProviderGroupForModel(model, modelToGroup, languageModelsService!); - const key = getProviderGroupKey(info.vendor, info.groupName); - let bucket = providerGroups.get(key); - if (!bucket) { - bucket = { vendor: info.vendor, groupName: info.groupName, models: [] }; - providerGroups.set(key, bucket); - } - bucket.models.push(model); - } - - // Sort buckets: copilot vendor first, then alphabetically by group name - const sortedBuckets = [...providerGroups.values()].sort((a, b) => { - if (a.vendor === 'copilot' && b.vendor !== 'copilot') { return -1; } - if (b.vendor === 'copilot' && a.vendor !== 'copilot') { return 1; } - return a.groupName.localeCompare(b.groupName); - }); - - const showGroupHeaders = sortedBuckets.length > 1; - - for (const bucket of sortedBuckets) { - if (showGroupHeaders) { - items.push({ - kind: ActionListItemKind.Separator, - label: bucket.groupName, - section: ModelPickerSection.Other, - }); - } - - // Models within a bucket sorted: available first, then alphabetically by name - const sortedBucketModels = [...bucket.models].sort((a, b) => { - const aEntry = controlModels[a.metadata.id] ?? controlModels[a.identifier]; - const bEntry = controlModels[b.metadata.id] ?? controlModels[b.identifier]; - const aAvail = aEntry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, aEntry.minVSCodeVersion) ? 1 : 0; - const bAvail = bEntry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, bEntry.minVSCodeVersion) ? 1 : 0; - if (aAvail !== bAvail) { return aAvail - bAvail; } - return a.metadata.name.localeCompare(b.metadata.name); - }); - - for (const model of sortedBucketModels) { - const entry = controlModels[model.metadata.id] ?? controlModels[model.identifier]; - if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', manageSettingsUrl, updateStateType, chatEntitlementService, ModelPickerSection.Other)); - } else { - const { action: bucketAction, ariaDescription: bucketAriaDesc } = createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other, showGroupHeaders); - items.push(createModelItem(bucketAction, model, openerService, undefined, isUBB, bucketAriaDesc, makePinAction(model), onConfigure)); - } - } - } - } - } - - if (manageModelsAction && !otherModels.length) { - // No Other Models section: show manage models as standalone - items.push({ kind: ActionListItemKind.Separator }); - items.push({ - item: manageModelsAction, - kind: ActionListItemKind.Action, - label: manageModelsAction.label, - group: { title: '', icon: Codicon.blank }, - hideIcon: false, - showAlways: true, - }); - } - } else { - // Flat list: auto first, then all models sorted alphabetically - const autoModel = models.find(m => isAutoModel(m)); - if (autoModel) { - const { action: flatAutoAction, ariaDescription: flatAutoAriaDesc } = createModelAction(autoModel, selectedModelId, onSelect); - items.push(createModelItem(flatAutoAction, autoModel, openerService, undefined, isUBB, flatAutoAriaDesc)); - } - const sortedModels = models - .filter(m => m !== autoModel) - .sort((a, b) => { - const vendorCmp = a.metadata.vendor.localeCompare(b.metadata.vendor); - return vendorCmp !== 0 ? vendorCmp : a.metadata.name.localeCompare(b.metadata.name); - }); - for (const model of sortedModels) { - const { action: flatAction, ariaDescription: flatAriaDesc } = createModelAction(model, selectedModelId, onSelect); - items.push(createModelItem(flatAction, model, openerService, undefined, isUBB, flatAriaDesc, undefined, onConfigure)); - } - } - - return items; + return options.presentation.useGroupedModelPicker + ? buildGroupedModelItems(options) + : buildFlatModelItems(options); } export function getModelPickerAccessibilityProvider() { @@ -791,10 +74,8 @@ export function getModelPickerAccessibilityProvider() { if (element.isSectionToggle) { return undefined; } - // The Trust / Sign in entries are commands, not selectable models, so - // they expose no checked state. if (element.kind === ActionListItemKind.Action && !(element.item?.id && PICKER_COMMAND_ACTION_IDS.has(element.item.id))) { - return !!element?.item?.checked; + return !!element.item?.checked; } return undefined; }, @@ -803,72 +84,13 @@ export function getModelPickerAccessibilityProvider() { return 'menuitem'; } switch (element.kind) { - // The Trust / Sign in entries are commands, not model choices, so - // announce them as plain menuitems rather than radios. - case ActionListItemKind.Action: return element.item?.id && PICKER_COMMAND_ACTION_IDS.has(element.item.id) ? 'menuitem' : 'menuitemradio'; - case ActionListItemKind.Separator: return 'separator'; - default: return 'separator'; + case ActionListItemKind.Action: + return element.item?.id && PICKER_COMMAND_ACTION_IDS.has(element.item.id) ? 'menuitem' : 'menuitemradio'; + case ActionListItemKind.Separator: + default: + return 'separator'; } }, getWidgetRole: () => 'menu', } as const; } - -function createUnavailableModelItem( - id: string, - entry: IModelControlEntry, - reason: 'upgrade' | 'update' | 'admin', - manageSettingsUrl: string | undefined, - updateStateType: StateType, - chatEntitlementService: IChatEntitlementService, - section?: string, -): IActionListItem { - let description: string | MarkdownString | undefined; - - if (reason === 'upgrade') { - description = new MarkdownString(localize('chat.modelPicker.upgradeLink', "[Upgrade](command:workbench.action.chat.upgradePlan \" \")"), { isTrusted: true }); - } else if (reason === 'update') { - description = localize('chat.modelPicker.updateDescription', "Update VS Code"); - } else { - description = manageSettingsUrl - ? new MarkdownString(localize('chat.modelPicker.adminLink', "[Contact your admin]({0})", manageSettingsUrl), { isTrusted: true }) - : localize('chat.modelPicker.adminDescription', "Contact your admin"); - } - - let hoverContent: MarkdownString; - if (reason === 'upgrade') { - hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); - if (chatEntitlementService.entitlement === ChatEntitlement.Pro) { - hoverContent.appendMarkdown(localize('chat.modelPicker.upgradeHoverProPlus', "[Upgrade to GitHub Copilot Pro+](command:workbench.action.chat.upgradePlan \" \") to use the best models.")); - } else { - hoverContent.appendMarkdown(localize('chat.modelPicker.upgradeHover', "[Upgrade to GitHub Copilot Pro](command:workbench.action.chat.upgradePlan \" \") to use the best models.")); - } - } else if (reason === 'update') { - hoverContent = getUpdateHoverContent(updateStateType); - } else { - hoverContent = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); - hoverContent.appendMarkdown(localize('chat.modelPicker.adminHover', "This model is not available. Contact your administrator to enable it.")); - } - - return { - item: { - id, - enabled: false, - checked: false, - class: undefined, - tooltip: entry.label, - label: entry.label, - description: typeof description === 'string' ? description : undefined, - run: () => { } - }, - kind: ActionListItemKind.Action, - label: entry.label, - description, - group: { title: '', icon: ThemeIcon.fromId(Codicon.blank.id) }, - disabled: true, - hideIcon: false, - className: 'chat-model-picker-unavailable', - section, - hover: { content: hoverContent }, - }; -} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts index 608d7593cc18ba..03db63da5ec018 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerWidget.ts @@ -162,7 +162,7 @@ export class ModelPickerWidget extends Disposable { // state while the chat extension comes up and loads them, rather than a // misleading "Auto" fallback. this._register(this._workspaceTrustManagementService.onDidChangeTrust(trusted => { - if (trusted && (this._delegate.showAutoModel?.() ?? false) && this._delegate.getModels().length === 0) { + if (trusted && this._delegate.getPresentationOptions().showAutoModel && this._delegate.getModels().length === 0) { this._activatingAfterTrust = true; this._activatingTimer.value = disposableTimeout(() => { this._activatingAfterTrust = false; @@ -390,7 +390,7 @@ export class ModelPickerWidget extends Disposable { const reason = this._unavailableReason(); const empty = this._delegate.getModels().length === 0; const activating = reason === undefined && empty && this._activatingAfterTrust; - const genericNoModels = reason === undefined && !activating && empty && !(this._delegate.showAutoModel?.() ?? false); + const genericNoModels = reason === undefined && !activating && empty && !this._delegate.getPresentationOptions().showAutoModel; return { reason, activating, genericNoModels, noModels: reason !== undefined || activating || genericNoModels }; } @@ -438,11 +438,12 @@ export class ModelPickerWidget extends Disposable { }; const models = this._delegate.getModels(); + const presentation = this._delegate.getPresentationOptions(); const isSignedOut = this._entitlementService.entitlement === ChatEntitlement.Unknown; const manifest = this._languageModelsService.getModelsControlManifest(); // Signed-out users (e.g. offline-BYOK) should not see Copilot control-manifest entries const controlModelsForTier: IStringDictionary = isSignedOut ? {} : getControlModelsForEntitlement(manifest, this._entitlementService.entitlement); - const canShowManageModelsAction = this._delegate.showManageModelsAction() && shouldShowManageModelsAction(this._entitlementService); + const canShowManageModelsAction = presentation.showManageModelsAction && shouldShowManageModelsAction(this._entitlementService); const manageModelsAction = canShowManageModelsAction ? createManageModelsAction(this._commandService) : undefined; const logModelPickerInteraction = (interaction: ChatModelPickerInteraction) => { this._telemetryService.publicLog2('chat.modelPickerInteraction', { interaction }); @@ -459,32 +460,33 @@ export class ModelPickerWidget extends Disposable { this.show(anchorElement); }; - const items = buildModelPickerItems( + const items = buildModelPickerItems({ models, - this._selectedModel?.identifier, - this._languageModelsService.getRecentlyUsedModelIds().filter(id => !this._languageModelsService.isModelHidden(id)), - this._languageModelsService.getPinnedModelIds().filter(id => !this._languageModelsService.isModelHidden(id)), - controlModelsForTier, - this._productService.version, - this._updateService.state.type, - onSelect, - onTogglePin, + selectedModelId: this._selectedModel?.identifier, + recentModelIds: this._languageModelsService.getRecentlyUsedModelIds().filter(id => !this._languageModelsService.isModelHidden(id)), + pinnedModelIds: this._languageModelsService.getPinnedModelIds().filter(id => !this._languageModelsService.isModelHidden(id)), + controlModels: controlModelsForTier, + currentVSCodeVersion: this._productService.version, + updateStateType: this._updateService.state.type, manageSettingsUrl, - this._delegate.useGroupedModelPicker(), manageModelsAction, - this._entitlementService, - this._delegate.showUnavailableFeatured(), - this._delegate.showFeatured(), - this._languageModelsService, - this._openerService, - this._delegate.showAutoModel?.() ?? false, - onConfigure, - this.isRestrictedMode(), - () => { void this._requestWorkspaceTrust(); }, - this.isSetupRequired(), - () => { this._requestSetup(); }, - !!this._entitlementService.quotas.usageBasedBilling, - ); + chatEntitlementService: this._entitlementService, + languageModelsService: this._languageModelsService, + openerService: this._openerService, + presentation: { + ...presentation, + restrictedMode: this.isRestrictedMode(), + setupRequired: this.isSetupRequired(), + isUBB: !!this._entitlementService.quotas.usageBasedBilling, + }, + actions: { + onSelect, + onTogglePin, + onConfigure, + onRequestTrust: () => { void this._requestWorkspaceTrust(); }, + onRequestSetup: () => { this._requestSetup(); }, + }, + }); // Collect all hover disposables so they are properly cleaned up when the // picker is hidden. The ActionListWidget only tracks the disposable for the @@ -587,7 +589,7 @@ export class ModelPickerWidget extends Disposable { // --- Name section --- const nameChildren: (HTMLElement | string)[] = []; - const modelIcon = this._selectedModel ? getModelPickerIcon(this._selectedModel, this._delegate.useGenericModelIcon?.()) : undefined; + const modelIcon = this._selectedModel ? getModelPickerIcon(this._selectedModel, this._delegate.getPresentationOptions().useGenericModelIcon) : undefined; const compact = this._compact?.get() ?? false; if (modelIcon && !noModelsAvailable) { nameChildren.push(renderIcon(modelIcon)); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index ebd9b544a47531..50e9bdd0eb37cf 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -150,24 +150,24 @@ export function backfillRestoredPickerState( } /** - * Recover the selected model on a restored input state when it was dropped during a cold - * handoff/restore. + * Recover the selected model on a transferred input state when it was dropped during a cold + * handoff. * * At cold restore an agent-host transferred draft can arrive without its `selectedModel` (the live * model list is not loaded yet, so the model resolved to `undefined`). Fall back to the model * derived from the session's request history so the picker restores the last-used model instead of * Auto. The history-derived model carries full metadata (including `targetChatSessionType`), so the * input part can wait for the model pool and apply it once it loads. An explicit model already - * present on `stateToApply` is never overridden. + * present on `transferredState` is never overridden. */ -export function backfillRestoredModel( - stateToApply: ISerializableChatModelInputState | undefined, +export function backfillTransferredModel( + transferredState: ISerializableChatModelInputState | undefined, historyModel: ISerializableChatModelInputState['selectedModel'], ): ISerializableChatModelInputState | undefined { - if (!stateToApply || stateToApply.selectedModel || !historyModel) { - return stateToApply; + if (!transferredState || transferredState.selectedModel || !historyModel) { + return transferredState; } - return { ...stateToApply, selectedModel: historyModel }; + return { ...transferredState, selectedModel: historyModel }; } export class ChatService extends Disposable implements IChatService { @@ -762,8 +762,12 @@ export class ChatService extends Disposable implements IChatService { // At cold restore the agent-host transferred draft can drop the user's per-session picker // selections (model/mode); restore them from the session's own saved `storedInputState` // (mode, via {@link backfillRestoredPickerState}) and from the history-derived model - // (via {@link backfillRestoredModel}). - const stateToApply = backfillRestoredModel(providedSession.transferredState?.inputState ?? restoredDraft, historyDerivedModel); + // (via {@link backfillTransferredModel}). The persisted draft already contains + // `historyDerivedModel`, so only a transferred draft needs this backfill. + const transferredInputState = providedSession.transferredState?.inputState; + const stateToApply = transferredInputState + ? backfillTransferredModel(transferredInputState, historyDerivedModel) + : restoredDraft; const inputState = backfillRestoredPickerState(stateToApply, storedInputState, ChatMode.Agent.id); const modelRef = this._sessionModels.acquireOrCreate({ initialData, diff --git a/src/vs/workbench/contrib/chat/common/modelSelection.ts b/src/vs/workbench/contrib/chat/common/modelSelection.ts index 86c2c222b74b07..bf2f82c708d6fa 100644 --- a/src/vs/workbench/contrib/chat/common/modelSelection.ts +++ b/src/vs/workbench/contrib/chat/common/modelSelection.ts @@ -134,6 +134,7 @@ export const enum ModelSelectionReason { ConfiguredDefault = 'configuredDefault', FirstAvailable = 'firstAvailable', NoModels = 'noModels', + ProgrammaticSelection = 'programmaticSelection', Remembered = 'remembered', RemovedModelFallback = 'removedModelFallback', SessionRestore = 'sessionRestore', @@ -143,8 +144,13 @@ export const enum ModelSelectionReason { export type ModelSelectionApplyReason = Exclude; +export function isAuthoritativeModelSelectionReason(reason: ModelSelectionApplyReason | undefined): boolean { + return reason === ModelSelectionReason.ProgrammaticSelection + || reason === ModelSelectionReason.SessionRestore + || reason === ModelSelectionReason.UserSelection; +} + export interface IPendingModelSelection { - readonly source: 'configured' | 'desired'; readonly reference: string; } @@ -154,9 +160,7 @@ export type InitialModelSelectionResult = | { readonly kind: 'apply'; readonly model: ILanguageModelChatMetadataAndIdentifier; readonly reason: ModelSelectionApplyReason }; export interface IInitialModelSelectionInput { - readonly configuredModelValue: string | undefined; readonly configuredModel: ILanguageModelChatMetadataAndIdentifier | undefined; - readonly waitForConfiguredModel: boolean; readonly desiredModelResolution: ModelIdentifierResolution; readonly desiredReason: ModelSelectionReason.SessionRestore | ModelSelectionReason.Remembered; readonly fallbackModel: ILanguageModelChatMetadataAndIdentifier | undefined; @@ -168,14 +172,11 @@ export function resolveInitialModelSelection(input: IInitialModelSelectionInput) if (input.configuredModel) { return { kind: 'apply', model: input.configuredModel, reason: ModelSelectionReason.ConfiguredDefault }; } - if (input.configuredModelValue && input.waitForConfiguredModel) { - return { kind: 'pending', selection: { source: 'configured', reference: input.configuredModelValue } }; - } if (input.desiredModelResolution.kind === 'available') { return { kind: 'apply', model: input.desiredModelResolution.model, reason: input.desiredReason }; } if (input.desiredModelResolution.kind === 'pending') { - return { kind: 'pending', selection: { source: 'desired', reference: input.desiredModelResolution.identifier } }; + return { kind: 'pending', selection: { reference: input.desiredModelResolution.identifier } }; } return input.fallbackModel ? { kind: 'apply', model: input.fallbackModel, reason: input.fallbackReason } @@ -199,7 +200,6 @@ export type IModelSelectionSessionContext = export interface IModelSelectionModelsContext { readonly available: readonly ILanguageModelChatMetadataAndIdentifier[]; readonly configuredModel: string | undefined; - readonly waitForConfiguredModel: boolean; readonly rememberedModelId: string | undefined; readonly desiredModelResolution: ModelIdentifierResolution; readonly fallbackModel: ILanguageModelChatMetadataAndIdentifier | undefined; @@ -244,7 +244,7 @@ export function transitionModelSelection(input: IModelSelectionTransitionInput): || currentReason === ModelSelectionReason.NewChatRepush; const configuredModelValue = session.kind === 'untitled' && (newConversation - || (!newConversation && (!sessionModelId || automaticSelection) && currentReason !== ModelSelectionReason.UserSelection && currentReason !== ModelSelectionReason.SessionRestore)) + || (!newConversation && (!sessionModelId || automaticSelection) && !isAuthoritativeModelSelectionReason(currentReason))) ? models.configuredModel : undefined; const configuredModel = configuredModelValue @@ -256,21 +256,11 @@ export function transitionModelSelection(input: IModelSelectionTransitionInput): } return applyResult(sessionKey, chatKey, configuredModel, ModelSelectionReason.ConfiguredDefault); } - if (configuredModelValue && models.waitForConfiguredModel) { - return { - currentModel: undefined, - currentReason: undefined, - pendingSelection: { source: 'configured', reference: configuredModelValue }, - effect: { kind: 'none' }, - sessionKey, - lastPushedChatKey: previous.lastPushedChatKey, - }; - } if (session.kind === 'existing' && models.desiredModelResolution.kind === 'pending') { return { currentModel: undefined, currentReason: undefined, - pendingSelection: { source: 'desired', reference: models.desiredModelResolution.identifier }, + pendingSelection: { reference: models.desiredModelResolution.identifier }, effect: currentModel ? { kind: 'clear', reason: ModelSelectionReason.SessionRestore } : { kind: 'none' }, sessionKey, lastPushedChatKey: chatKey, @@ -289,9 +279,7 @@ export function transitionModelSelection(input: IModelSelectionTransitionInput): if (!currentModel && session.kind === 'untitled') { const initial = resolveInitialModelSelection({ - configuredModelValue, configuredModel, - waitForConfiguredModel: models.waitForConfiguredModel, desiredModelResolution: models.desiredModelResolution, desiredReason: sessionModelId ? ModelSelectionReason.SessionRestore : ModelSelectionReason.Remembered, fallbackModel, @@ -338,7 +326,7 @@ export function transitionModelSelection(input: IModelSelectionTransitionInput): return { currentModel: undefined, currentReason: undefined, - pendingSelection: { source: 'desired', reference: models.desiredModelResolution.identifier }, + pendingSelection: { reference: models.desiredModelResolution.identifier }, effect: { kind: 'clear', reason: ModelSelectionReason.SessionRestore }, sessionKey, lastPushedChatKey: previous.lastPushedChatKey, @@ -359,9 +347,7 @@ export function transitionModelSelection(input: IModelSelectionTransitionInput): if (session.kind === 'untitled' && currentModel && currentReason === ModelSelectionReason.FirstAvailable) { const initial = resolveInitialModelSelection({ - configuredModelValue, configuredModel, - waitForConfiguredModel: models.waitForConfiguredModel, desiredModelResolution: models.desiredModelResolution, desiredReason: ModelSelectionReason.Remembered, fallbackModel, diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputModelUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputModelUtils.test.ts index 925c21e4504cff..b8ac912c56af8c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputModelUtils.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputModelUtils.test.ts @@ -12,7 +12,6 @@ import { filterModelsForSession, findBestMatchingModel, findDefaultModel, - findReplacementForProvisionalModel, getAgentHostByokManageModelsIdentifier, hasModelsTargetingSession, isModelHiddenInPicker, @@ -456,15 +455,6 @@ suite('ChatInputModelUtils', () => { assert.strictEqual(result?.metadata.id, 'terminal-default'); }); - test('replaces only the current provisional model when a location default arrives', () => { - const provisional = createModel('byok', 'BYOK'); - const defaultModel = createDefaultModelForLocation('auto', 'Auto', ChatAgentLocation.Chat); - assert.deepStrictEqual([ - findReplacementForProvisionalModel(provisional.identifier, provisional.identifier, [provisional], ChatAgentLocation.Chat)?.identifier, - findReplacementForProvisionalModel(provisional.identifier, provisional.identifier, [provisional, defaultModel], ChatAgentLocation.Chat)?.identifier, - findReplacementForProvisionalModel(defaultModel.identifier, provisional.identifier, [provisional, defaultModel], ChatAgentLocation.Chat)?.identifier, - ], [undefined, defaultModel.identifier, undefined]); - }); }); suite('shouldResetModelToDefault', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts index 0b5e78a33c83a1..37e1c511f5240b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts @@ -12,7 +12,6 @@ import { ChatAgentLocation, ChatModeKind } from '../../../../common/constants.js import { ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; import { ModelSelectionReason, resolveModelIdentifier, resolveModelIdentifierFromCatalog } from '../../../../common/modelSelection.js'; import { ChatInputModelSelectionController, IChatInputModelSelectionRuntime } from '../../../../browser/widget/input/chatInputModelSelectionController.js'; -import { ChatModelSelectionModel } from '../../../../browser/widget/input/chatModelSelectionModel.js'; function model(identifier: string): ILanguageModelChatMetadataAndIdentifier { return { @@ -40,11 +39,10 @@ interface IRuntimeState { models: ILanguageModelChatMetadataAndIdentifier[]; resolved: boolean; readonly sessionType: string; - readonly configuredModel?: string; + configuredModel?: string; } function createRuntime( - selection: ChatModelSelectionModel, state: IRuntimeState, modelChanges: Emitter, applied: string[], @@ -63,33 +61,31 @@ function createRuntime( getBoundConversationKey: () => 'chat:one', getVisibleConversationKey: () => 'chat:one', restoreModelConfiguration: () => { }, - applyModel: model => { - applied.push(model.identifier); - selection.setCurrentModel(model, false); - }, + applyModel: model => applied.push(model.identifier), }; } -suite('ChatModelSelectionModel', () => { +suite('ChatInputModelSelectionController', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); test('tracks explicit selection origin', () => { - const selection = new ChatModelSelectionModel(); + const modelChanges = disposables.add(new Emitter()); + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime({ models: [], resolved: true, sessionType: 'test' }, modelChanges, []))); const first = model('test/first'); const second = model('test/second'); - selection.setCurrentModel(first, false); + controller.applyAutomaticSelection(first, () => { }); const automatic = { - current: selection.currentModel.get()?.identifier, - explicit: selection.userExplicitlySelectedModel, + current: controller.currentModel.get()?.identifier, + explicit: controller.userExplicitlySelectedModel, }; - selection.setCurrentModel(second, true); + controller.applyExplicitSelection(second, () => { }, false); assert.deepStrictEqual({ automatic, - current: selection.currentModel.get()?.identifier, - explicitAfterUserSelection: selection.userExplicitlySelectedModel, + current: controller.currentModel.get()?.identifier, + explicitAfterUserSelection: controller.userExplicitlySelectedModel, }, { automatic: { current: first.identifier, explicit: false }, current: second.identifier, @@ -97,29 +93,30 @@ suite('ChatModelSelectionModel', () => { }); }); - test('rolls back a failed automatic transition effect', () => { - const selection = new ChatModelSelectionModel(); + test('rolls back a failed explicit selection effect', () => { + const modelChanges = disposables.add(new Emitter()); + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime({ models: [], resolved: true, sessionType: 'test' }, modelChanges, []))); const first = model('test/first'); const second = model('test/second'); - selection.setCurrentModel(first, false); - selection.setSelectionReason(ModelSelectionReason.FirstAvailable); - const previousState = selection.captureState(); - selection.setCurrentModel(second, false); - selection.setSelectionReason(ModelSelectionReason.ConfiguredDefault); + controller.applyAutomaticSelection(first, () => { }); - assert.throws(() => selection.applyTransitionEffect(previousState, () => { throw new Error('rejected'); }), /rejected/); + assert.throws(() => controller.applyExplicitSelection(second, () => { throw new Error('rejected'); }, true), /rejected/); assert.deepStrictEqual({ - current: selection.currentModel.get()?.identifier, - reason: selection.getCurrentReason(undefined), + current: controller.currentModel.get()?.identifier, + reason: controller.selectionReason, }, { current: first.identifier, - reason: ModelSelectionReason.FirstAvailable, + reason: undefined, }); }); test('restores only for fresh own-pool session switches', () => { - const selection = new ChatModelSelectionModel(); - const controller = disposables.add(new ChatInputModelSelectionController(selection, {} as IChatInputModelSelectionRuntime)); + const modelChanges = disposables.add(new Emitter()); + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime({ + models: [], + resolved: true, + sessionType: 'test', + }, modelChanges, []))); controller.beginSessionSwitch(true, true, false); const restoreDuringFreshSwitch = controller.restorePerTypeModel; @@ -139,7 +136,6 @@ suite('ChatModelSelectionModel', () => { }); test('applies a fallback while waiting for a remembered model, then restores it', () => { - const selection = new ChatModelSelectionModel(); const modelChanges = disposables.add(new Emitter()); const first = model('test/first'); const second = model('test/second'); @@ -164,12 +160,11 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.initialize(second.identifier, result => initialSelections.push(result.kind)); - const pending = controller.hasAuthoritativeModelWait(); + const pending = controller.hasPendingIntent(); models = [first, second]; catalogResolved = true; modelChanges.fire('test'); @@ -177,7 +172,7 @@ suite('ChatModelSelectionModel', () => { assert.deepStrictEqual({ initialSelections, pending, - pendingAfterResolve: controller.hasAuthoritativeModelWait(), + pendingAfterResolve: controller.hasPendingIntent(), applied, }, { initialSelections: ['pending'], @@ -187,8 +182,198 @@ suite('ChatModelSelectionModel', () => { }); }); + test('explicit selection cancels an eventual remembered-model restore', () => { + const modelChanges = disposables.add(new Emitter()); + const fallback = model('test/fallback'); + const explicit = model('test/explicit'); + const remembered = model('test/remembered'); + const state: IRuntimeState = { models: [fallback, explicit], resolved: true, sessionType: 'local' }; + const applied: string[] = []; + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime(state, modelChanges, applied))); + + controller.initialize(remembered.identifier, () => { }); + controller.applyExplicitSelection(explicit, () => applied.push(explicit.identifier), false); + state.models = [fallback, explicit, remembered]; + modelChanges.fire('loaded'); + + assert.deepStrictEqual({ + pending: controller.hasPendingIntent(), + applied, + current: controller.currentModel.get()?.identifier, + }, { + pending: false, + applied: [fallback.identifier, explicit.identifier], + current: explicit.identifier, + }); + }); + + test('programmatic selection cancels an eventual remembered-model restore', () => { + const modelChanges = disposables.add(new Emitter()); + const fallback = model('test/fallback'); + const programmatic = model('test/programmatic'); + const remembered = model('test/remembered'); + const state: IRuntimeState = { models: [fallback, programmatic], resolved: true, sessionType: 'local' }; + const applied: string[] = []; + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime(state, modelChanges, applied))); + + controller.initialize(remembered.identifier, () => { }); + controller.applyProgrammaticSelection(programmatic); + state.models = [fallback, programmatic, remembered]; + modelChanges.fire('loaded'); + + assert.deepStrictEqual({ + pending: controller.hasPendingIntent(), + applied, + current: controller.currentModel.get()?.identifier, + reason: controller.selectionReason, + }, { + pending: false, + applied: [fallback.identifier, programmatic.identifier], + current: programmatic.identifier, + reason: ModelSelectionReason.ProgrammaticSelection, + }); + }); + + test('pending programmatic selection applies when the model arrives', async () => { + const modelChanges = disposables.add(new Emitter()); + const requested = model('test/requested'); + const state: IRuntimeState = { models: [], resolved: false, sessionType: 'local' }; + const applied: string[] = []; + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime(state, modelChanges, applied))); + + const result = controller.requestProgrammaticSelection( + () => state.models.find(model => model.identifier === requested.identifier), + 'chat:one', + ); + const pending = controller.hasPendingProgrammaticSelection(); + state.models = [requested]; + modelChanges.fire('loaded'); + + assert.deepStrictEqual({ + pending, + result: await result, + pendingAfterLoad: controller.hasPendingProgrammaticSelection(), + applied, + current: controller.currentModel.get()?.identifier, + }, { + pending: true, + result: true, + pendingAfterLoad: false, + applied: [requested.identifier], + current: requested.identifier, + }); + }); + + test('explicit selection cancels a pending programmatic selection', async () => { + const modelChanges = disposables.add(new Emitter()); + const requested = model('test/requested'); + const explicit = model('test/explicit'); + const state: IRuntimeState = { models: [explicit], resolved: false, sessionType: 'local' }; + const applied: string[] = []; + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime(state, modelChanges, applied))); + + const result = controller.requestProgrammaticSelection( + () => state.models.find(model => model.identifier === requested.identifier), + 'chat:one', + ); + controller.applyExplicitSelection(explicit, () => applied.push(explicit.identifier), false); + state.models = [explicit, requested]; + modelChanges.fire('loaded'); + + assert.deepStrictEqual({ + result: await result, + pending: controller.hasPendingProgrammaticSelection(), + applied, + current: controller.currentModel.get()?.identifier, + }, { + result: false, + pending: false, + applied: [explicit.identifier], + current: explicit.identifier, + }); + }); + + test('clearing a pending programmatic selection clears its authority', async () => { + const modelChanges = disposables.add(new Emitter()); + const requested = model('test/requested'); + const state: IRuntimeState = { models: [], resolved: false, sessionType: 'local' }; + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime(state, modelChanges, []))); + + const result = controller.requestProgrammaticSelection( + () => state.models.find(model => model.identifier === requested.identifier), + 'chat:one', + ); + controller.clearIntent(); + + assert.deepStrictEqual({ result: await result, reason: controller.selectionReason }, { + result: false, + reason: undefined, + }); + }); + + test('location default improves the fallback and settles conclusively absent remembered intent', () => { + const modelChanges = disposables.add(new Emitter()); + const fallback = model('test/fallback'); + const remembered = model('test/remembered'); + const defaultBase = model('test/default'); + const locationDefault = { + ...defaultBase, + metadata: { ...defaultBase.metadata, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } }, + }; + const state: IRuntimeState = { models: [fallback], resolved: true, sessionType: 'local' }; + const applied: string[] = []; + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime(state, modelChanges, applied))); + + controller.initialize(remembered.identifier, () => { }); + state.models = [fallback, locationDefault]; + controller.reconcileModelListChange(state.models); + const pendingAfterDefault = controller.hasPendingIntent(); + state.models = [fallback, locationDefault, remembered]; + modelChanges.fire('loaded'); + + assert.deepStrictEqual({ + pendingAfterDefault, + pendingAfterLoad: controller.hasPendingIntent(), + applied, + current: controller.currentModel.get()?.identifier, + }, { + pendingAfterDefault: false, + pendingAfterLoad: false, + applied: [fallback.identifier, locationDefault.identifier], + current: locationDefault.identifier, + }); + }); + + test('repairs a removed fallback and settles conclusively absent remembered intent', () => { + const modelChanges = disposables.add(new Emitter()); + const fallback = model('test/fallback'); + const replacement = model('test/replacement'); + const remembered = model('test/remembered'); + const state: IRuntimeState = { models: [fallback], resolved: true, sessionType: 'local' }; + const applied: string[] = []; + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime(state, modelChanges, applied))); + + controller.initialize(remembered.identifier, () => { }); + state.models = [replacement]; + modelChanges.fire('fallback-removed'); + const pendingAfterRepair = controller.hasPendingIntent(); + state.models = [replacement, remembered]; + modelChanges.fire('remembered-loaded'); + + assert.deepStrictEqual({ + pendingAfterRepair, + pendingAfterLoad: controller.hasPendingIntent(), + applied, + current: controller.currentModel.get()?.identifier, + }, { + pendingAfterRepair: false, + pendingAfterLoad: false, + applied: [fallback.identifier, replacement.identifier], + current: replacement.identifier, + }); + }); + test('applies a fallback while the configured default loads, then upgrades it', () => { - const selection = new ChatModelSelectionModel(); const byok = model('openai/byok'); const configured = model('copilot/configured'); let models = [byok]; @@ -209,25 +394,84 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.initialize(undefined, () => { }); - const pending = controller.hasAuthoritativeModelWait(); + const pending = controller.hasPendingIntent(); models = [byok, configured]; controller.reconcileModelListChange(models); - assert.deepStrictEqual({ pending, applied, current: selection.currentModel.get()?.identifier }, { + assert.deepStrictEqual({ pending, applied, current: controller.currentModel.get()?.identifier }, { pending: false, applied: [byok.identifier, configured.identifier], current: configured.identifier, }); }); + test('configured default supersedes pending remembered intent', () => { + const modelChanges = disposables.add(new Emitter()); + const fallback = model('test/fallback'); + const configured = model('test/configured'); + const remembered = model('test/remembered'); + const state: IRuntimeState = { + models: [fallback], + resolved: false, + sessionType: 'local', + configuredModel: configured.metadata.id, + }; + const applied: string[] = []; + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime(state, modelChanges, applied))); + + controller.initialize(remembered.identifier, () => { }); + state.models = [fallback, configured, remembered]; + state.resolved = true; + modelChanges.fire('loaded'); + + assert.deepStrictEqual({ + pending: controller.hasPendingIntent(), + applied, + current: controller.currentModel.get()?.identifier, + reason: controller.selectionReason, + }, { + pending: false, + applied: [fallback.identifier, configured.identifier], + current: configured.identifier, + reason: ModelSelectionReason.ConfiguredDefault, + }); + }); + + test('configured default claims an already selected fallback', () => { + const modelChanges = disposables.add(new Emitter()); + const fallback = model('test/fallback'); + const defaultBase = model('test/default'); + const locationDefault = { + ...defaultBase, + metadata: { ...defaultBase.metadata, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } }, + }; + const state: IRuntimeState = { models: [fallback], resolved: true, sessionType: 'local' }; + const applied: string[] = []; + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime(state, modelChanges, applied))); + + controller.initialize(undefined, () => { }); + state.configuredModel = fallback.metadata.id; + state.models = [fallback, locationDefault]; + modelChanges.fire('configured'); + modelChanges.fire('unchanged'); + + assert.deepStrictEqual({ + applied, + current: controller.currentModel.get()?.identifier, + reason: controller.selectionReason, + }, { + applied: [fallback.identifier], + current: fallback.identifier, + reason: ModelSelectionReason.ConfiguredDefault, + }); + }); + test('keeps an explicit selection when the configured default loads later', () => { - const selection = new ChatModelSelectionModel(); const byok = model('openai/byok'); const explicit = model('openai/explicit'); const configured = model('copilot/configured'); @@ -249,24 +493,22 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.initialize(undefined, () => { }); - controller.applyExplicitSelection(explicit, undefined, 'chat:one', () => applied.push(explicit.identifier), false); + controller.applyExplicitSelection(explicit, () => applied.push(explicit.identifier), false); models = [byok, explicit, configured]; controller.reconcileModelListChange(models); - assert.deepStrictEqual({ applied, current: selection.currentModel.get()?.identifier }, { + assert.deepStrictEqual({ applied, current: controller.currentModel.get()?.identifier }, { applied: [byok.identifier, explicit.identifier], current: explicit.identifier, }); }); - test('conversation restore cancels the startup remembered-model wait', () => { - const selection = new ChatModelSelectionModel(); + test('conversation restore cancels startup remembered intent', () => { const modelChanges = disposables.add(new Emitter()); const fallback = model('test/fallback'); const remembered = model('copilot/remembered'); @@ -290,10 +532,9 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.initialize(remembered.identifier, () => { }); controller.syncFromConversationState(restored, undefined, undefined, 'chat:one'); @@ -302,9 +543,9 @@ suite('ChatModelSelectionModel', () => { modelChanges.fire('test'); assert.deepStrictEqual({ - pending: controller.hasAuthoritativeModelWait(), + pending: controller.hasPendingIntent(), applied, - current: selection.currentModel.get()?.identifier, + current: controller.currentModel.get()?.identifier, }, { pending: false, applied: [fallback.identifier, restored.identifier], @@ -313,7 +554,6 @@ suite('ChatModelSelectionModel', () => { }); test('late configured default does not overwrite a restored conversation model', () => { - const selection = new ChatModelSelectionModel(); const restored = model('test/restored'); const configured = model('copilot/configured'); let models = [restored]; @@ -334,24 +574,22 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.initialize(undefined, () => { }); controller.syncFromConversationState(restored, undefined, undefined, 'chat:one'); models = [restored, configured]; controller.reconcileModelListChange(models); - assert.deepStrictEqual({ applied, current: selection.currentModel.get()?.identifier }, { + assert.deepStrictEqual({ applied, current: controller.currentModel.get()?.identifier }, { applied: [restored.identifier], current: restored.identifier, }); }); - test('conversation restore cancels an older history wait', () => { - const selection = new ChatModelSelectionModel(); + test('conversation restore cancels older history intent', () => { const modelChanges = disposables.add(new Emitter()); const restored = model('test/restored'); const history = model('test/history'); @@ -373,17 +611,16 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.preselectFromHistory(history.identifier, 'chat:one'); controller.syncFromConversationState(restored, undefined, undefined, 'chat:one'); models = [restored, history]; modelChanges.fire('test'); - assert.deepStrictEqual({ applied, current: selection.currentModel.get()?.identifier }, { + assert.deepStrictEqual({ applied, current: controller.currentModel.get()?.identifier }, { applied: [restored.identifier], current: restored.identifier, }); @@ -401,7 +638,6 @@ suite('ChatModelSelectionModel', () => { }; const run = (configuredModel: string | undefined, rememberedModel: string | undefined, models: ILanguageModelChatMetadataAndIdentifier[]) => { - const selection = new ChatModelSelectionModel(); const applied: string[] = []; const runtime: IChatInputModelSelectionRuntime = { location: ChatAgentLocation.Chat, @@ -419,10 +655,9 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - disposables.add(new ChatInputModelSelectionController(selection, runtime)).initialize(rememberedModel, () => { }); + disposables.add(new ChatInputModelSelectionController(runtime)).initialize(rememberedModel, () => { }); return applied[0]; }; @@ -434,8 +669,7 @@ suite('ChatModelSelectionModel', () => { ], [locationDefault.identifier, remembered.identifier, locationDefault.identifier, first.identifier]); }); - test('applies provisional and configured defaults through the automatic path', () => { - const selection = new ChatModelSelectionModel(); + test('applies fallback and configured defaults through the automatic path', () => { const first = model('test/first'); const second = model('test/second'); const configuration: { model: string | undefined } = { model: undefined }; @@ -456,25 +690,21 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.ensureCurrentModelSupported(); - const provisional = controller.provisionalModelId; configuration.model = second.metadata.id; const configuredApplied = controller.applyConfiguredDefault(); - assert.deepStrictEqual({ provisional, configuredApplied, applied }, { - provisional: first.identifier, + assert.deepStrictEqual({ configuredApplied, applied }, { configuredApplied: true, applied: [first.identifier, second.identifier], }); }); test('replaces a BYOK first-available model when the Copilot default loads later', () => { - const selection = new ChatModelSelectionModel(); const modelChanges = disposables.add(new Emitter()); const byok = model('openai/byok'); const copilotDefault = { @@ -502,25 +732,21 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.initialize(undefined, () => { }); - const provisional = controller.provisionalModelId; models = [byok, copilotDefault]; controller.reconcileModelListChange(models); - assert.deepStrictEqual({ provisional, applied, current: selection.currentModel.get()?.identifier }, { - provisional: byok.identifier, + assert.deepStrictEqual({ applied, current: controller.currentModel.get()?.identifier }, { applied: [byok.identifier, copilotDefault.identifier], current: copilotDefault.identifier, }); }); test('drops cross-pool drafts and waits for a cold conversation model', () => { - const selection = new ChatModelSelectionModel(); const sessionType = 'agent-host-test'; const general = model('test/general'); const fallback = targetedModel('test/fallback', sessionType); @@ -546,15 +772,14 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: (modelId, configuration) => restored.push({ modelId, configuration }), applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); const draft = controller.resolveDraftModel(general, sessionType, true); models = []; controller.syncFromConversationState(desired, { effort: 'high' }, sessionType, 'chat:one'); - const pending = controller.hasAuthoritativeModelWait(); + const pending = controller.hasPendingIntent(); models = [fallback, desired]; resolved = true; modelChanges.fire('test'); @@ -562,7 +787,7 @@ suite('ChatModelSelectionModel', () => { assert.deepStrictEqual({ draft: { model: draft.model?.identifier, changed: draft.changed }, pending, - pendingAfterResolve: controller.hasAuthoritativeModelWait(), + pendingAfterResolve: controller.hasPendingIntent(), applied, restored, }, { @@ -581,7 +806,6 @@ suite('ChatModelSelectionModel', () => { // re-resolutions and applies the model once the pool loads — instead of defaulting to Auto. // (If the grace in resolveModelIdentifierFromCatalog is removed, resolution is `unavailable`, // no wait is armed, and this test fails.) - const selection = new ChatModelSelectionModel(); const sessionType = 'agent-host-copilotcli'; const base = targetedModel('agent-host-copilotcli:gpt-5.6-sol', sessionType); const desired = { ...base, metadata: { ...base.metadata, vendor: sessionType } }; @@ -609,16 +833,15 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: (modelId, configuration) => restored.push({ modelId, configuration }), applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.syncFromConversationState(desired, { effort: 'high' }, sessionType, 'chat:one'); - const pending = controller.hasAuthoritativeModelWait(); + const pending = controller.hasPendingIntent(); // An intermediate empty re-resolution must not end the wait or apply a default. modelChanges.fire('still-empty'); - const stillPendingAfterEmpty = controller.hasAuthoritativeModelWait(); + const stillPendingAfterEmpty = controller.hasPendingIntent(); const appliedAfterEmpty = [...applied]; // The real models finally arrive. models = [desired]; @@ -628,7 +851,7 @@ suite('ChatModelSelectionModel', () => { pending, stillPendingAfterEmpty, appliedAfterEmpty, - pendingAfterLoad: controller.hasAuthoritativeModelWait(), + pendingAfterLoad: controller.hasPendingIntent(), applied, restored, }, { @@ -641,20 +864,9 @@ suite('ChatModelSelectionModel', () => { }); }); - test('initialize waits for a resolved-but-empty agent-host pool and restores the remembered model', () => { - // Root-fix regression test (Option 1). A NEW/untitled agent-host session restores its - // remembered model via `initialize`. At cold start the agent-host vendor is registered - // ("resolved") but its models have not arrived yet. Using the real catalog resolver, the - // agent-host "empty is transient" grace must make the remembered model resolve as `pending` - // (not `unavailable`) so `initialize` waits for the pool and applies the model on load, - // instead of returning `none` and leaving the picker on Auto. - // - // If the fix in `isLanguageModelVendorAbsenceConclusive` is reverted, the resolution becomes - // `unavailable`, `initialize` gets `none`, no wait is armed, and nothing is applied — this - // test then fails on `pendingAfterInit`/`applied`/`current`. - const selection = new ChatModelSelectionModel(); - const sessionType = 'agent-host-copilotcli'; - const remembered = targetedModel('agent-host-copilotcli:gpt-5.6-sol', sessionType); + test('initialize settles remembered intent after a conclusively empty catalog update', () => { + const sessionType = 'test-session'; + const remembered = targetedModel('test:remembered', sessionType); const modelChanges = disposables.add(new Emitter()); let models: ILanguageModelChatMetadataAndIdentifier[] = []; const applied: string[] = []; @@ -667,29 +879,23 @@ suite('ChatModelSelectionModel', () => { getAllModels: () => models, requiresCustomModels: () => true, getConfiguredModelValue: () => undefined, - // Faithful to production wiring: the vendor is resolved but publishes models - // asynchronously, so route through the catalog resolver that applies the grace. - resolveModelIdentifier: identifier => resolveModelIdentifierFromCatalog(models, identifier, { - hasLiveModels: vendor => models.some(m => m.metadata.vendor === vendor), - hasResolved: () => true, - }), + resolveModelIdentifier: identifier => resolveModelIdentifier(models, identifier, true), subscribeToModelChanges: listener => modelChanges.event(listener), getBoundConversationKey: () => 'chat:one', getVisibleConversationKey: () => 'chat:one', restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.initialize(remembered.identifier, () => { }); - const pendingAfterInit = controller.hasAuthoritativeModelWait(); + const pendingAfterInit = controller.hasPendingIntent(); const appliedAfterInit = [...applied]; // An intermediate empty re-resolution must not end the wait or apply a default. modelChanges.fire('still-empty'); - const pendingAfterEmpty = controller.hasAuthoritativeModelWait(); + const pendingAfterEmpty = controller.hasPendingIntent(); // The agent-host pool finally publishes its models. models = [remembered]; modelChanges.fire('loaded'); @@ -698,13 +904,13 @@ suite('ChatModelSelectionModel', () => { pendingAfterInit, appliedAfterInit, pendingAfterEmpty, - pendingAfterLoad: controller.hasAuthoritativeModelWait(), + pendingAfterLoad: controller.hasPendingIntent(), applied, - current: selection.currentModel.get()?.identifier, + current: controller.currentModel.get()?.identifier, }, { pendingAfterInit: true, appliedAfterInit: [], - pendingAfterEmpty: true, + pendingAfterEmpty: false, pendingAfterLoad: false, applied: [remembered.identifier], current: remembered.identifier, @@ -712,7 +918,6 @@ suite('ChatModelSelectionModel', () => { }); test('late best-match restore remains authoritative after configured-model refresh', () => { - const selection = new ChatModelSelectionModel(); const modelChanges = disposables.add(new Emitter()); const sessionType = 'agent-host-test'; const desired = targetedModel('test/desired', sessionType); @@ -721,7 +926,7 @@ suite('ChatModelSelectionModel', () => { const configured = targetedModel('test/configured', sessionType); const state: IRuntimeState = { models: [], resolved: false, sessionType, configuredModel: configured.metadata.id }; const applied: string[] = []; - const controller = disposables.add(new ChatInputModelSelectionController(selection, createRuntime(selection, state, modelChanges, applied))); + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime(state, modelChanges, applied))); controller.syncFromConversationState(desired, undefined, sessionType, 'chat:one'); state.models = [match, configured]; @@ -731,8 +936,8 @@ suite('ChatModelSelectionModel', () => { assert.deepStrictEqual({ applied, - current: selection.currentModel.get()?.identifier, - reason: selection.selectionReason, + current: controller.currentModel.get()?.identifier, + reason: controller.selectionReason, }, { applied: [match.identifier], current: match.identifier, @@ -740,8 +945,7 @@ suite('ChatModelSelectionModel', () => { }); }); - test('terminal restore fallback cancels an obsolete authoritative wait', () => { - const selection = new ChatModelSelectionModel(); + test('terminal restore fallback cancels obsolete session intent', () => { const modelChanges = disposables.add(new Emitter()); const sessionType = 'agent-host-test'; const staleDesired = targetedModel('test/stale', sessionType); @@ -749,7 +953,7 @@ suite('ChatModelSelectionModel', () => { const inapplicable = model('test/inapplicable'); const state: IRuntimeState = { models: [], resolved: false, sessionType }; const applied: string[] = []; - const controller = disposables.add(new ChatInputModelSelectionController(selection, createRuntime(selection, state, modelChanges, applied))); + const controller = disposables.add(new ChatInputModelSelectionController(createRuntime(state, modelChanges, applied))); controller.syncFromConversationState(staleDesired, undefined, sessionType, 'chat:one'); state.models = [fallback]; @@ -758,14 +962,13 @@ suite('ChatModelSelectionModel', () => { state.models = [fallback, staleDesired]; modelChanges.fire('test'); - assert.deepStrictEqual({ pending: controller.hasAuthoritativeModelWait(), applied }, { + assert.deepStrictEqual({ pending: controller.hasPendingIntent(), applied }, { pending: false, applied: [fallback.identifier], }); }); test('does not apply a late history model after the visible conversation changes', () => { - const selection = new ChatModelSelectionModel(); const modelChanges = disposables.add(new Emitter()); const restored = model('test/restored'); let models: ILanguageModelChatMetadataAndIdentifier[] = []; @@ -787,7 +990,7 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => applied.push(selected.identifier), }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.preselectFromHistory(restored.identifier, 'chat:one'); visibleConversation = 'chat:two'; @@ -798,7 +1001,6 @@ suite('ChatModelSelectionModel', () => { }); test('revalidates a selection when switching model pools', () => { - const selection = new ChatModelSelectionModel(); const general = model('test/general'); const targeted = targetedModel('test/targeted', 'agent-host-test'); const state: { sessionType: string | undefined } = { sessionType: undefined }; @@ -819,16 +1021,15 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); - selection.setCurrentModel(general, false); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); + controller.applyAutomaticSelection(general, () => { }); state.sessionType = 'agent-host-test'; controller.revalidateForSessionType(() => { }); - assert.deepStrictEqual({ applied, current: selection.currentModel.get()?.identifier }, { + assert.deepStrictEqual({ applied, current: controller.currentModel.get()?.identifier }, { applied: [targeted.identifier], current: targeted.identifier, }); @@ -841,7 +1042,6 @@ suite('ChatModelSelectionModel', () => { // so when the remembered model shows up on a later change it is swapped in — instead of being // lost. Reverting the `initialize` restructure (arming the wait only for `pending`) leaves no // wait armed here, so `pendingAfterInit` is false and the remembered model is never applied. - const selection = new ChatModelSelectionModel(); const modelChanges = disposables.add(new Emitter()); const fallback = model('test/fallback'); const remembered = model('test/remembered'); @@ -863,26 +1063,22 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.initialize(remembered.identifier, () => { }); - const pendingAfterInit = controller.hasAuthoritativeModelWait(); - const provisional = controller.provisionalModelId; + const pendingAfterInit = controller.hasPendingIntent(); models = [fallback, remembered]; modelChanges.fire('loaded'); assert.deepStrictEqual({ pendingAfterInit, - provisional, - pendingAfterLoad: controller.hasAuthoritativeModelWait(), + pendingAfterLoad: controller.hasPendingIntent(), applied, - current: selection.currentModel.get()?.identifier, + current: controller.currentModel.get()?.identifier, }, { pendingAfterInit: true, - provisional: fallback.identifier, pendingAfterLoad: false, applied: [fallback.identifier, remembered.identifier], current: remembered.identifier, @@ -893,7 +1089,6 @@ suite('ChatModelSelectionModel', () => { // Termination guard: the wait must not linger. Once the remembered model is conclusively // absent (the pool loaded with other models but not it), settle on the already-applied // fallback and tear the subscription down — without re-applying the fallback. - const selection = new ChatModelSelectionModel(); const modelChanges = disposables.add(new Emitter()); const fallback = model('test/fallback'); const other = model('test/other'); @@ -916,21 +1111,20 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.initialize(remembered.identifier, () => { }); - const pendingAfterInit = controller.hasAuthoritativeModelWait(); + const pendingAfterInit = controller.hasPendingIntent(); models = [fallback, other]; modelChanges.fire('loaded-without-remembered'); assert.deepStrictEqual({ pendingAfterInit, - pendingAfterLoad: controller.hasAuthoritativeModelWait(), + pendingAfterLoad: controller.hasPendingIntent(), applied, - current: selection.currentModel.get()?.identifier, + current: controller.currentModel.get()?.identifier, }, { pendingAfterInit: true, pendingAfterLoad: false, @@ -943,7 +1137,6 @@ suite('ChatModelSelectionModel', () => { // Guard against over-arming: no remembered model, or a remembered model that is already // available, must not leave a catalog subscription armed. const build = (rememberedId: string | undefined, models: ILanguageModelChatMetadataAndIdentifier[]) => { - const selection = new ChatModelSelectionModel(); const applied: string[] = []; const runtime: IChatInputModelSelectionRuntime = { location: ChatAgentLocation.Chat, @@ -961,12 +1154,11 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.initialize(rememberedId, () => { }); - return controller.hasAuthoritativeModelWait(); + return controller.hasPendingIntent(); }; const first = model('test/first'); const remembered = model('test/remembered'); @@ -983,7 +1175,6 @@ suite('ChatModelSelectionModel', () => { test('an explicit selection cancels the initialize restore wait', () => { // While the wait is armed, an explicit user pick must win permanently: the wait is cancelled // and a later appearance of the remembered model does not override the explicit selection. - const selection = new ChatModelSelectionModel(); const modelChanges = disposables.add(new Emitter()); const fallback = model('test/fallback'); const explicit = model('test/explicit'); @@ -1006,15 +1197,14 @@ suite('ChatModelSelectionModel', () => { restoreModelConfiguration: () => { }, applyModel: selected => { applied.push(selected.identifier); - selection.setCurrentModel(selected, false); }, }; - const controller = disposables.add(new ChatInputModelSelectionController(selection, runtime)); + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); controller.initialize(remembered.identifier, () => { }); - const pendingAfterInit = controller.hasAuthoritativeModelWait(); - controller.applyExplicitSelection(explicit, undefined, 'chat:one', () => applied.push(explicit.identifier), false); - const pendingAfterExplicit = controller.hasAuthoritativeModelWait(); + const pendingAfterInit = controller.hasPendingIntent(); + controller.applyExplicitSelection(explicit, () => applied.push(explicit.identifier), false); + const pendingAfterExplicit = controller.hasPendingIntent(); models = [fallback, explicit, remembered]; modelChanges.fire('loaded'); @@ -1022,7 +1212,7 @@ suite('ChatModelSelectionModel', () => { pendingAfterInit, pendingAfterExplicit, applied, - current: selection.currentModel.get()?.identifier, + current: controller.currentModel.get()?.identifier, }, { pendingAfterInit: true, pendingAfterExplicit: false, diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerItems.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerItems.test.ts index 44557f683b0e42..053d24c4536a10 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerItems.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerItems.test.ts @@ -142,38 +142,45 @@ function callBuild( onRequestTrust?: () => void; setupRequired?: boolean; onRequestSetup?: () => void; + onSelect?: (model: ILanguageModelChatMetadataAndIdentifier) => void; + entitlementService?: IChatEntitlementService; } = {}, ): IActionListItem[] { - const onSelect = () => { }; - const entitlementService = createStubEntitlementService({ + const onSelect = opts.onSelect ?? (() => { }); + const entitlementService = opts.entitlementService ?? createStubEntitlementService({ entitlement: opts.entitlement ?? ChatEntitlement.Pro, anonymous: opts.anonymous ?? false, }); - return buildModelPickerItems( + return buildModelPickerItems({ models, - opts.selectedModelId, - opts.recentModelIds ?? [], - opts.pinnedModelIds ?? [], - opts.controlModels ?? {}, - opts.currentVSCodeVersion ?? '1.100.0', - opts.updateStateType ?? StateType.Idle, - onSelect, - undefined, - opts.manageSettingsUrl, - true, - stubManageModelsAction, - entitlementService, - opts.showUnavailableFeatured ?? true, - opts.showFeatured ?? true, - opts.languageModelsService ?? stubLanguageModelsService, - undefined, - opts.showAutoModel ?? true, - undefined, - opts.restrictedMode ?? false, - opts.onRequestTrust, - opts.setupRequired ?? false, - opts.onRequestSetup, - ); + selectedModelId: opts.selectedModelId, + recentModelIds: opts.recentModelIds ?? [], + pinnedModelIds: opts.pinnedModelIds ?? [], + controlModels: opts.controlModels ?? {}, + currentVSCodeVersion: opts.currentVSCodeVersion ?? '1.100.0', + updateStateType: opts.updateStateType ?? StateType.Idle, + manageSettingsUrl: opts.manageSettingsUrl, + manageModelsAction: stubManageModelsAction, + chatEntitlementService: entitlementService, + languageModelsService: opts.languageModelsService ?? stubLanguageModelsService, + openerService: undefined, + presentation: { + useGroupedModelPicker: true, + showUnavailableFeatured: opts.showUnavailableFeatured ?? true, + showFeatured: opts.showFeatured ?? true, + showAutoModel: opts.showAutoModel ?? true, + restrictedMode: opts.restrictedMode ?? false, + setupRequired: opts.setupRequired ?? false, + isUBB: false, + }, + actions: { + onSelect, + onTogglePin: undefined, + onConfigure: undefined, + onRequestTrust: opts.onRequestTrust, + onRequestSetup: opts.onRequestSetup, + }, + }); } function createControlManifest(): IModelsControlManifest { @@ -890,24 +897,7 @@ suite('buildModelPickerItems', () => { const modelA = createModel('gpt-4o', 'GPT-4o'); let selectedModel: ILanguageModelChatMetadataAndIdentifier | undefined; const onSelect = (m: ILanguageModelChatMetadataAndIdentifier) => { selectedModel = m; }; - const items = buildModelPickerItems( - [auto, modelA], - undefined, - [], - [], - {}, - '1.100.0', - StateType.Idle, - onSelect, - undefined, - undefined, - true, - undefined, - stubChatEntitlementService, - true, - true, - stubLanguageModelsService, - ); + const items = callBuild([auto, modelA], { onSelect, entitlementService: stubChatEntitlementService }); const gptItem = getActionItems(items).find(a => a.label === 'GPT-4o'); assert.ok(gptItem?.item); gptItem.item.run(); @@ -978,24 +968,12 @@ suite('buildModelPickerItems', () => { test('admin unavailable model shows manage settings link in description', () => { const auto = createAutoModel(); const businessEntitlementService = createStubEntitlementService({ entitlement: ChatEntitlement.Business }); - const items = buildModelPickerItems( - [auto], - undefined, - ['missing-model'], - [], - { 'missing-model': { label: 'Missing Model' } as IModelControlEntry }, - '1.100.0', - StateType.Idle, - () => { }, - undefined, - 'https://aka.ms/github-copilot-settings', - true, - undefined, - businessEntitlementService, - true, - true, - stubLanguageModelsService, - ); + const items = callBuild([auto], { + recentModelIds: ['missing-model'], + controlModels: { 'missing-model': { label: 'Missing Model' } as IModelControlEntry }, + manageSettingsUrl: 'https://aka.ms/github-copilot-settings', + entitlementService: businessEntitlementService, + }); const adminItem = getActionItems(items).find(a => a.label === 'Missing Model'); assert.ok(adminItem); @@ -1066,24 +1044,7 @@ suite('buildModelPickerItems', () => { let selectedModel: ILanguageModelChatMetadataAndIdentifier | undefined; const onSelect = (m: ILanguageModelChatMetadataAndIdentifier) => { selectedModel = m; }; const anonymousEntitlementService = createStubEntitlementService({ entitlement: ChatEntitlement.Unknown, anonymous: true }); - const items = buildModelPickerItems( - [auto, modelA], - undefined, - [], - [], - {}, - '1.100.0', - StateType.Idle, - onSelect, - undefined, - undefined, - true, - undefined, - anonymousEntitlementService, - true, - true, - stubLanguageModelsService, - ); + const items = callBuild([auto, modelA], { onSelect, entitlementService: anonymousEntitlementService }); const gptItem = getActionItems(items).find(a => a.label === 'GPT-4o'); assert.ok(gptItem?.item); gptItem.item.run(); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 9b7962ad7147aa..f1c7bd0c1d1d98 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -46,7 +46,7 @@ import { IChatVariablesService } from '../../../common/attachments/chatVariables import { IChatDebugService } from '../../../common/chatDebugService.js'; import { ChatDebugServiceImpl } from '../../../common/chatDebugServiceImpl.js'; import { ChatRequestQueueKind, ChatSendResult, IChatFollowup, IChatModelReference, IChatProgress, IChatService, ResponseModelState } from '../../../common/chatService/chatService.js'; -import { backfillRestoredModel, backfillRestoredPickerState, ChatService } from '../../../common/chatService/chatServiceImpl.js'; +import { backfillTransferredModel, backfillRestoredPickerState, ChatService } from '../../../common/chatService/chatServiceImpl.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../common/languageModels.js'; @@ -2478,6 +2478,50 @@ suite('ChatService', () => { ); }); + test('restored draft keeps the history model while the live catalog is cold', async () => { + const historyModelId = 'agent-host-copilotcli:gpt-5.6-sol'; + const historyMetadata: ILanguageModelChatMetadata = { + id: historyModelId, name: 'GPT-5.6 Sol', vendor: 'agent-host-copilotcli', version: '1.0', family: 'gpt-5.6-sol', + extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, + isDefaultForLocation: {}, targetChatSessionType: remoteScheme, + }; + let catalogLoaded = true; + instantiationService.stub(ILanguageModelsService, new class extends NullLanguageModelsService { + override lookupLanguageModel(id: string): ILanguageModelChatMetadata | undefined { + return catalogLoaded && id === historyModelId ? historyMetadata : undefined; + } + }); + + const { resource } = setupRemoteProvider({ + history: [{ type: 'request', prompt: 'hello', participant: remoteScheme, modelId: historyModelId }] + }); + const testService = createChatService(); + const ref1 = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref1, 'Should load remote session'); + (ref1.object as ChatModel).inputModel.setState({ + inputText: 'unsent draft', + selectedModel: { identifier: historyModelId, metadata: historyMetadata }, + }); + ref1.dispose(); + await testService.waitForModelDisposals(); + + catalogLoaded = false; + const ref2 = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref2, 'Should re-load remote session'); + testDisposables.add(ref2); + const restored = (ref2.object as ChatModel).inputModel.state.get(); + + assert.deepStrictEqual({ + inputText: restored?.inputText, + selectedModel: restored?.selectedModel?.identifier, + target: restored?.selectedModel?.metadata.targetChatSessionType, + }, { + inputText: 'unsent draft', + selectedModel: historyModelId, + target: remoteScheme, + }); + }); + test('restored draft preserves the model configuration (effort/context) of the history model', async () => { const historyModelId = 'history-model'; const historyMetadata: ILanguageModelChatMetadata = { @@ -2653,7 +2697,7 @@ suite('backfillRestoredPickerState', () => { }); }); -suite('backfillRestoredModel', () => { +suite('backfillTransferredModel', () => { ensureNoDisposablesAreLeakedInTestSuite(); const AGENT = 'agent'; @@ -2669,25 +2713,25 @@ suite('backfillRestoredModel', () => { attachments: [], mode: { id: AGENT, kind: ChatModeKind.Agent }, selectedModel, inputText: '', selections: [], contrib: {} }); - test('backfills the history model when the restored state dropped its model (cold restore)', () => { + test('backfills the history model when the transferred state dropped its model', () => { const history = model('agent-host-copilotcli:gpt-5.6-sol'); - const result = backfillRestoredModel(state(undefined), history); + const result = backfillTransferredModel(state(undefined), history); assert.strictEqual(result?.selectedModel?.identifier, 'agent-host-copilotcli:gpt-5.6-sol'); }); - test('never overrides a model already present on the restored state', () => { - const result = backfillRestoredModel(state(model('agent-host-copilotcli:gpt-5.6-terra')), model('agent-host-copilotcli:gpt-5.6-sol')); + test('never overrides a model already present on the transferred state', () => { + const result = backfillTransferredModel(state(model('agent-host-copilotcli:gpt-5.6-terra')), model('agent-host-copilotcli:gpt-5.6-sol')); assert.strictEqual(result?.selectedModel?.identifier, 'agent-host-copilotcli:gpt-5.6-terra'); }); test('leaves the state unchanged when there is no history model', () => { const chosen = state(undefined); - assert.strictEqual(backfillRestoredModel(chosen, undefined), chosen); + assert.strictEqual(backfillTransferredModel(chosen, undefined), chosen); assert.strictEqual(chosen.selectedModel, undefined); }); test('returns undefined state as-is', () => { - assert.strictEqual(backfillRestoredModel(undefined, model('agent-host-copilotcli:gpt-5.6-sol')), undefined); + assert.strictEqual(backfillTransferredModel(undefined, model('agent-host-copilotcli:gpt-5.6-sol')), undefined); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/modelSelection.test.ts b/src/vs/workbench/contrib/chat/test/common/modelSelection.test.ts index 00764f178327ff..7eef316ba6f275 100644 --- a/src/vs/workbench/contrib/chat/test/common/modelSelection.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/modelSelection.test.ts @@ -47,7 +47,6 @@ function transition(overrides: ITransitionOverrides = {}) { models: { available: [first, second], configuredModel: undefined, - waitForConfiguredModel: false, rememberedModelId: undefined, desiredModelResolution: { kind: 'notRequested' }, fallbackModel: first, @@ -139,18 +138,14 @@ suite('ModelSelection', () => { test('shares configured, desired, pending, then fallback precedence', () => { assert.deepStrictEqual([ - resolveInitialModelSelection({ configuredModelValue: 'second', configuredModel: second, waitForConfiguredModel: true, desiredModelResolution: { kind: 'available', model: first }, desiredReason: ModelSelectionReason.Remembered, fallbackModel: first, fallbackReason: ModelSelectionReason.FirstAvailable }), - resolveInitialModelSelection({ configuredModelValue: 'second', configuredModel: undefined, waitForConfiguredModel: true, desiredModelResolution: { kind: 'available', model: first }, desiredReason: ModelSelectionReason.Remembered, fallbackModel: first, fallbackReason: ModelSelectionReason.FirstAvailable }), - resolveInitialModelSelection({ configuredModelValue: 'second', configuredModel: undefined, waitForConfiguredModel: false, desiredModelResolution: { kind: 'available', model: first }, desiredReason: ModelSelectionReason.Remembered, fallbackModel: first, fallbackReason: ModelSelectionReason.FirstAvailable }), - resolveInitialModelSelection({ configuredModelValue: undefined, configuredModel: undefined, waitForConfiguredModel: false, desiredModelResolution: { kind: 'available', model: second }, desiredReason: ModelSelectionReason.Remembered, fallbackModel: first, fallbackReason: ModelSelectionReason.FirstAvailable }), - resolveInitialModelSelection({ configuredModelValue: undefined, configuredModel: undefined, waitForConfiguredModel: false, desiredModelResolution: { kind: 'pending', identifier: second.identifier }, desiredReason: ModelSelectionReason.Remembered, fallbackModel: first, fallbackReason: ModelSelectionReason.FirstAvailable }), - resolveInitialModelSelection({ configuredModelValue: undefined, configuredModel: undefined, waitForConfiguredModel: false, desiredModelResolution: { kind: 'unavailable', identifier: second.identifier }, desiredReason: ModelSelectionReason.Remembered, fallbackModel: first, fallbackReason: ModelSelectionReason.FirstAvailable }), + resolveInitialModelSelection({ configuredModel: second, desiredModelResolution: { kind: 'available', model: first }, desiredReason: ModelSelectionReason.Remembered, fallbackModel: first, fallbackReason: ModelSelectionReason.FirstAvailable }), + resolveInitialModelSelection({ configuredModel: undefined, desiredModelResolution: { kind: 'available', model: second }, desiredReason: ModelSelectionReason.Remembered, fallbackModel: first, fallbackReason: ModelSelectionReason.FirstAvailable }), + resolveInitialModelSelection({ configuredModel: undefined, desiredModelResolution: { kind: 'pending', identifier: second.identifier }, desiredReason: ModelSelectionReason.Remembered, fallbackModel: first, fallbackReason: ModelSelectionReason.FirstAvailable }), + resolveInitialModelSelection({ configuredModel: undefined, desiredModelResolution: { kind: 'unavailable', identifier: second.identifier }, desiredReason: ModelSelectionReason.Remembered, fallbackModel: first, fallbackReason: ModelSelectionReason.FirstAvailable }), ], [ { kind: 'apply', model: second, reason: ModelSelectionReason.ConfiguredDefault }, - { kind: 'pending', selection: { source: 'configured', reference: 'second' } }, - { kind: 'apply', model: first, reason: ModelSelectionReason.Remembered }, { kind: 'apply', model: second, reason: ModelSelectionReason.Remembered }, - { kind: 'pending', selection: { source: 'desired', reference: second.identifier } }, + { kind: 'pending', selection: { reference: second.identifier } }, { kind: 'apply', model: first, reason: ModelSelectionReason.FirstAvailable }, ]); }); @@ -188,7 +183,7 @@ suite('ModelSelection', () => { ], [{ current: second.identifier, pending: undefined, effect: 'none', applied: undefined, reason: undefined, lastPushedChatKey: 'chat:one', }, { - current: undefined, pending: { source: 'desired', reference: 'target:missing' }, effect: 'clear', applied: undefined, reason: ModelSelectionReason.SessionRestore, lastPushedChatKey: 'chat:one', + current: undefined, pending: { reference: 'target:missing' }, effect: 'clear', applied: undefined, reason: ModelSelectionReason.SessionRestore, lastPushedChatKey: 'chat:one', }, { current: second.identifier, pending: undefined, effect: 'apply', applied: second.identifier, reason: ModelSelectionReason.RemovedModelFallback, lastPushedChatKey: 'chat:one', }, { @@ -207,7 +202,7 @@ suite('ModelSelection', () => { }, { current: second.identifier, pending: undefined, effect: 'apply', applied: second.identifier, reason: ModelSelectionReason.Remembered, lastPushedChatKey: 'chat:one', }, { - current: undefined, pending: { source: 'desired', reference: second.identifier }, effect: 'none', applied: undefined, reason: undefined, lastPushedChatKey: 'chat:previous', + current: undefined, pending: { reference: second.identifier }, effect: 'none', applied: undefined, reason: undefined, lastPushedChatKey: 'chat:previous', }, { current: first.identifier, pending: undefined, effect: 'apply', applied: first.identifier, reason: ModelSelectionReason.FirstAvailable, lastPushedChatKey: 'chat:one', }]); @@ -324,7 +319,6 @@ suite('ModelSelection', () => { assert.deepStrictEqual(summarize(transition({ models: { configuredModel: 'missing-family', - waitForConfiguredModel: false, available: [first], fallbackModel: first, desiredModelResolution: { kind: 'notRequested' }, @@ -351,7 +345,7 @@ suite('ModelSelection', () => { previous: { currentModel: first }, })), { current: undefined, - pending: { source: 'desired', reference: second.identifier }, + pending: { reference: second.identifier }, effect: 'clear', applied: undefined, reason: ModelSelectionReason.SessionRestore, From 2e96c5c8adc20660285dfe15a1ca05124ba1c737 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 22 Jul 2026 09:46:44 -0700 Subject: [PATCH 19/34] sessions: address Copilot code review feedback on folder Quick Pick (#326974) * sessions: address Copilot code review feedback on folder Quick Pick Addresses the 4 review comments from Copilot code review on #326921: - Carry each recent entry's provider ID through the Quick Pick item and pass it to `openNewSession`, so re-picking a folder resolvable by multiple providers keeps its previous provider association instead of falling back to the first registered one. - Centralize the workspace-trust gate: `ISessionsService.openNewSession` now resolves the workspace and, when it requires trust, prompts before creating a session for it (a single gate shared by every path that creates a concrete session for a folder). `NewChatWidget` no longer duplicates this check itself. - Add `ISessionsRecentWorkspacesService.getOwnRecentWorkspaces()`, restricted to the sessions' own recently-picked history (excluding VS Code's global recents), and use it for the workspace picker's restore-on-startup fallback so an unrelated folder merely opened in another VS Code window can no longer silently seed a new session. - Document the new Cmd/Ctrl+O folder Quick Pick command in `SessionsChatAccessibilityHelp` with a dynamic keybinding reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: fold getOwnRecentWorkspaces into a getRecentWorkspaces parameter Per review feedback, replaces the separate getOwnRecentWorkspaces() API with an includeVSCodeRecents parameter (default true) on the existing getRecentWorkspaces(), instead of adding a new method. The workspace picker's restore-on-startup fallback now calls getRecentWorkspaces(false) to restrict to the sessions' own history. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address CCR feedback round 2 on PR #326974 - resolveWorkspace now accepts an optional preferredProviderId so the trust check in openNewSession honors options.providerId instead of always resolving against the first matching provider - openNewSession returns a discriminated IOpenNewSessionResult ({ session, trustDeclined }) instead of a bare ISession | undefined, so callers can tell trust-declined apart from other non-creation outcomes without re-deriving it from resolveWorkspace - newChatWidget consumes the discriminated result and skips scheduling a provider-change retry after an explicit trust decline - add regression tests verifying workspace-picker restore uses only the sessions-owned recent history, not VS Code global recents - update SESSIONS.md to describe the async trust gate in the new session data flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/SESSIONS.md | 14 +++- .../contrib/chat/browser/newChatWidget.ts | 63 +++++++-------- .../newSessionFolderQuickPickAction.ts | 9 ++- .../chat/browser/sessionWorkspacePicker.ts | 8 +- .../browser/sessionsChatAccessibilityHelp.ts | 1 + .../newSessionFolderQuickPickAction.test.ts | 5 +- .../browser/sessionWorkspacePicker.test.ts | 81 +++++++++++++++++-- .../browser/sessionsManagementService.ts | 9 ++- .../sessionsRecentWorkspacesService.ts | 30 +++++-- .../sessions/browser/sessionsService.ts | 59 +++++++++++--- .../sessions/common/sessionsManagement.ts | 14 ++-- 11 files changed, 224 insertions(+), 69 deletions(-) diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 72583e020b54ee..a6d635e2763074 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -231,12 +231,24 @@ Review-capable changesets expose `setReviewState(resource, reviewed)`. Agent-hos 1. User picks a folder in the workspace picker → WorkspacePicker fires onDidSelectWorkspace(folderUri) → NewChatWidget → ISessionsService.openNewSession({ folderUri, ...options }) + → view resolves the folder via SessionsManagementService.resolveWorkspace(folderUri, + options?.providerId) and, when the resolved workspace requires trust, awaits the + workspace-trust prompt **before** creating anything; declining returns + `{ session: undefined, trustDeclined: true }` and never calls createNewSession → view calls SessionsManagementService.createNewSession(folderUri, options?) → Iterates providers, picks the first one whose resolveWorkspace(folderUri) succeeds (filtered by options.sessionTypeId when given) → Calls provider.createNewSession(folderUri, sessionTypeId) → Returns ISession (model draft, `newSession`); the view then activates it so - it becomes the activeSession and the draft slot shows reactively + it becomes the activeSession and the draft slot shows reactively, and + openNewSession resolves `{ session, trustDeclined: false }` + + This trust gate is the **single** checkpoint for creating a session against a + folder — every folder-based entry point (composer, quick pick, dropdown) goes + through `openNewSession`, so none can bypass it. Callers distinguish "the user + declined trust" from other non-creation outcomes (for example the no-provider + case) via the returned `trustDeclined` flag rather than treating any falsy + `session` the same way. 2. User picks a different session type for the same folder → SessionTypePicker queries getSessionTypesForFolder(folderUri), diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 55eb07fc6fc195..04498a3a511e13 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -16,9 +16,8 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri import { localize } from '../../../../nls.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISession } from '../../../services/sessions/common/session.js'; -import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { IOpenNewSessionResult, ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IAquariumService, IMountedToggleHandle } from '../../aquarium/browser/aquariumOverlay.js'; -import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { WorkspacePicker } from './sessionWorkspacePicker.js'; import { WebWorkspacePicker } from './webWorkspacePicker.js'; import { IPreferredSessionType } from './sessionTypePicker.js'; @@ -81,7 +80,6 @@ export class NewChatWidget extends Disposable { @ILogService private readonly logService: ILogService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly sessionsService: ISessionsService, - @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @IAquariumService private readonly aquariumService: IAquariumService, @IAgentHostFilterService private readonly agentHostFilterService: IAgentHostFilterService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @@ -263,7 +261,7 @@ export class NewChatWidget extends Disposable { private _seedWorkspaceDraft(): void { const restoredFolderUri = this._workspacePicker.selectedFolderUri; if (!this._syncWorkspacePickerFromActiveSession() && restoredFolderUri) { - this._createNewSession(restoredFolderUri); + void this._createNewSession(restoredFolderUri); } } @@ -298,25 +296,32 @@ export class NewChatWidget extends Disposable { && t.sessionType.id === pick.sessionTypeId); } - private _createNewSession(folderUri: URI): void { + private async _createNewSession(folderUri: URI): Promise { this._pendingPreferredUpgrade.clear(); const userPick = this._newChatInput.sessionTypePicker.getUserPickedSessionType(); - const created = this._createSessionNow(folderUri, userPick); + const result = await this._createSessionNow(folderUri, userPick); + if (result.trustDeclined) { + // The user explicitly declined trust: don't schedule a retry, which + // would silently recreate (and possibly re-prompt) the draft once a + // provider registers/changes without any further user action. + return result; + } // Keep the draft in sync with late-registering providers. Agent hosts // connect lazily, so there is no timeout — the listener lives until the // draft is sent or replaced. We watch when: - // - no provider can serve the folder yet (!created), + // - no provider can serve the folder yet (!result.session), // - the user's explicit pick isn't servable yet (created with a // fallback, upgrade once its provider connects), or // - there is no explicit pick, so the draft tracks the preferred // (first) type, which can change as the folder's session-type list // grows. - if (!created || !userPick || !this._isPreferredServable(folderUri, userPick)) { - this._scheduleRecreateOnProviderChange(folderUri, userPick, created); + if (!result.session || !userPick || !this._isPreferredServable(folderUri, userPick)) { + this._scheduleRecreateOnProviderChange(folderUri, userPick, result.session); } + return result; } - private _createSessionNow(folderUri: URI, userPick: IPreferredSessionType | undefined): ISession | undefined { + private async _createSessionNow(folderUri: URI, userPick: IPreferredSessionType | undefined): Promise { // Prefer the user's explicit pick when its provider can serve the // folder; otherwise fall back to the preferred (first) session type. const effectivePick = userPick && this._isPreferredServable(folderUri, userPick) @@ -324,7 +329,7 @@ export class NewChatWidget extends Disposable { : this._newChatInput.sessionTypePicker.getPreferredSessionType(folderUri); const fallbackProviderId = this._workspacePicker.selectedResolved?.providerId; try { - return this.sessionsService.openNewSession({ + return await this.sessionsService.openNewSession({ folderUri, ...(effectivePick ? { providerId: effectivePick.providerId, sessionTypeId: effectivePick.sessionTypeId } @@ -334,7 +339,7 @@ export class NewChatWidget extends Disposable { }); } catch (e) { this.logService.error('Failed to create new session:', e); - return undefined; + return { session: undefined, trustDeclined: false }; } } @@ -359,7 +364,7 @@ export class NewChatWidget extends Disposable { } } } - this._createNewSession(folderUri); + void this._createNewSession(folderUri); })); this._pendingPreferredUpgrade.value = store; } @@ -519,22 +524,11 @@ export class NewChatWidget extends Disposable { if (wasQuickChat) { this.sessionsService.openQuickChat(); } else if (reseedFolderUri) { - this._createNewSession(reseedFolderUri); + await this._createNewSession(reseedFolderUri); } } } - private async _requestFolderTrust(folderUri: URI): Promise { - const trusted = await this.workspaceTrustRequestService.requestResourcesTrust({ - uri: folderUri, - message: localize('trustFolderMessage', "An agent session will be able to read files, run commands, and make changes in this folder."), - }); - if (!trusted) { - this._workspacePicker.removeFromRecents(folderUri); - } - return !!trusted; - } - saveState(): void { this._newChatInput.saveState(); } @@ -556,8 +550,10 @@ export class NewChatWidget extends Disposable { } /** - * Handles a workspace selection from the workspace picker. - * Requests folder trust if needed and creates a new session. + * Handles a workspace selection from the workspace picker and creates a + * new session for it. Workspace trust (when required) is requested by + * {@link ISessionsService.openNewSession} itself — a single gate shared + * by every path that creates a concrete session for a folder. */ private async _onWorkspaceSelected(folderUri: URI | undefined): Promise { // Cancel any in-flight upgrade for a previous selection. @@ -568,15 +564,14 @@ export class NewChatWidget extends Disposable { return; } - const resolved = this.sessionsManagementService.resolveWorkspace(folderUri); - if (resolved?.workspace.requiresWorkspaceTrust) { - if (!await this._requestFolderTrust(folderUri)) { - return; - } + if (this._store.isDisposed) { + return; } - if (!this._store.isDisposed) { - this._createNewSession(folderUri); + const result = await this._createNewSession(folderUri); + if (result.trustDeclined) { + // Don't leave the picker showing the declined folder as selected. + this._workspacePicker.removeFromRecents(folderUri); } } diff --git a/src/vs/sessions/contrib/chat/browser/newSessionFolderQuickPickAction.ts b/src/vs/sessions/contrib/chat/browser/newSessionFolderQuickPickAction.ts index 749ea1e0a0353c..5bb8b72cd5fd85 100644 --- a/src/vs/sessions/contrib/chat/browser/newSessionFolderQuickPickAction.ts +++ b/src/vs/sessions/contrib/chat/browser/newSessionFolderQuickPickAction.ts @@ -20,6 +20,8 @@ import { ISessionsService } from '../../../services/sessions/browser/sessionsSer export interface IFolderQuickPickItem extends IQuickPickItem { readonly folderUri?: URI; + /** The provider that previously resolved this folder, so re-picking it keeps the same provider association. */ + readonly providerId?: string; readonly browse?: boolean; } @@ -33,13 +35,14 @@ export function buildFolderQuickPickItems( const items: (IFolderQuickPickItem | IQuickPickSeparator)[] = []; if (recents.length > 0) { items.push({ type: 'separator', label: localize('sessions.newSession.pickFolderQuickPick.recent', "Recent") }); - for (const { workspace } of recents) { + for (const { workspace, providerId } of recents) { const folderUri = workspace.folders[0]?.root; if (!folderUri) { continue; } items.push({ folderUri, + providerId, label: `$(${workspace.icon.id}) ${workspace.label}`, description: labelService.getUriLabel(folderUri, { relative: false }), }); @@ -91,6 +94,7 @@ class NewSessionPickFolderQuickPickAction extends Action2 { } let folderUri = picked.folderUri; + let providerId = picked.providerId; if (picked.browse) { const result = await fileDialogService.showOpenDialog({ canSelectFolders: true, @@ -98,12 +102,13 @@ class NewSessionPickFolderQuickPickAction extends Action2 { canSelectMany: false, }); folderUri = result?.[0]; + providerId = undefined; } if (!folderUri) { return; } - sessionsService.openNewSession({ folderUri }); + await sessionsService.openNewSession({ folderUri, providerId }); } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 32db059dda01ad..45a3159ef982a4 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -972,9 +972,11 @@ export class WorkspacePicker extends Disposable { // Fall back to the first resolvable recent workspace from a connected provider. // Fallbacks (vs. the user's explicit checked pick) require the provider // to be ready: we don't want to silently land on, e.g., a disconnected - // remote workspace that the user never picked. + // remote workspace that the user never picked. Restrict to the sessions' + // own recent history (not VS Code's global recents) so restoration never + // seeds a new session from a folder merely opened in another window. try { - for (const recent of this.recentWorkspacesService.getRecentWorkspaces()) { + for (const recent of this.recentWorkspacesService.getRecentWorkspaces(false)) { if (this._isProviderUnavailable(recent.providerId)) { continue; } @@ -996,7 +998,7 @@ export class WorkspacePicker extends Disposable { */ private _restoreCheckedWorkspace(): IResolvedFolderWorkspace | undefined { try { - return this.recentWorkspacesService.getRecentWorkspaces().find(recent => recent.checked); + return this.recentWorkspacesService.getRecentWorkspaces(false).find(recent => recent.checked); } catch { return undefined; } diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index b3aa68274c5d0a..de5854cd433325 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -27,6 +27,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.input', "You are in the chat input. Type a message and press Enter to send it.")); content.push(localize('sessionsChat.inputBackground', "Press Alt+Enter to start the session in the background without navigating into it. The started session appears in the Chat Sessions view.")); content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session.")); + content.push(localize('sessionsChat.pickFolderQuickPick', "To choose a folder from a searchable list instead, use the New Session in Folder command{0}.", '')); content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. A quick chat has no workspace, so the workspace picker does not apply and the Toggle Side Panel command is disabled.", '')); content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); content.push(localize('sessionsChat.history', "Use up and down arrows to navigate your request history in the input box.")); diff --git a/src/vs/sessions/contrib/chat/test/browser/newSessionFolderQuickPickAction.test.ts b/src/vs/sessions/contrib/chat/test/browser/newSessionFolderQuickPickAction.test.ts index 9d20e264c88077..50282eb7cc5f1d 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newSessionFolderQuickPickAction.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newSessionFolderQuickPickAction.test.ts @@ -51,14 +51,15 @@ suite('New Session Folder Quick Pick', () => { const vsCodeRecentUri = URI.file('/repo-b'); const recentWorkspacesService = createRecentWorkspacesService([ - createResolvedRecent(ownRecentUri), - createResolvedRecent(vsCodeRecentUri), + createResolvedRecent(ownRecentUri, 'provider-a'), + createResolvedRecent(vsCodeRecentUri, 'provider-b'), ]); const items = buildFolderQuickPickItems(recentWorkspacesService, labelService); const folderItems = items.filter((i): i is IFolderQuickPickItem => !isSeparator(i) && !i.browse); assert.deepStrictEqual(folderItems.map(i => i.folderUri?.toString()), [ownRecentUri.toString(), vsCodeRecentUri.toString()]); + assert.deepStrictEqual(folderItems.map(i => i.providerId), ['provider-a', 'provider-b'], 'each item carries its recent entry\'s provider ID'); const separators = items.filter(isSeparator); assert.strictEqual(separators.length, 2); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 4dfda32339cdb4..c8a29279fdca2e 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -269,6 +269,8 @@ function createTestPicker( notificationService: INotificationService = new TestNotificationService(), pickerCtor: typeof WorkspacePicker = WorkspacePicker, fileDialogService: Partial = {}, + workspacesService: IWorkspacesService = { getRecentlyOpened: async () => ({ workspaces: [], files: [] }), onDidChangeRecentlyOpened: Event.None } as unknown as IWorkspacesService, + recentWorkspacesService?: ISessionsRecentWorkspacesService, ): WorkspacePicker { const instantiationService = disposables.add(new TestInstantiationService()); const storage = storageService ?? disposables.add(new TestStorageService()); @@ -292,16 +294,41 @@ function createTestPicker( getMenuActions: () => [], }); instantiationService.stub(INotificationService, notificationService); - instantiationService.stub(IWorkspacesService, { - getRecentlyOpened: async () => ({ workspaces: [], files: [] }), - onDidChangeRecentlyOpened: Event.None, - }); - instantiationService.stub(ISessionsRecentWorkspacesService, disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService))); + instantiationService.stub(IWorkspacesService, workspacesService); + instantiationService.stub(ISessionsRecentWorkspacesService, recentWorkspacesService ?? disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService))); instantiationService.stub(ITelemetryService, NullTelemetryService); return disposables.add(instantiationService.createInstance(pickerCtor)); } +/** + * Builds a {@link SessionsRecentWorkspacesService} and waits for its initial + * (asynchronous) VS Code recents fetch to complete, so a picker constructed + * against it afterwards restores against a fully-populated recents list + * instead of racing the fetch (as happens when {@link createTestPicker} + * builds its own service inline). + */ +async function createResolvedRecentWorkspacesService( + disposables: DisposableStore, + storageService: IStorageService, + providersService: MockSessionsProvidersService, + workspacesService: IWorkspacesService, +): Promise { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IStorageService, storageService); + instantiationService.stub(IUriIdentityService, { extUri }); + instantiationService.stub(IWorkspacesService, workspacesService); + instantiationService.stub(ISessionsProvidersService, providersService); + const recentWorkspacesService = disposables.add(instantiationService.createInstance(SessionsRecentWorkspacesService)); + await new Promise(resolve => { + const listener = recentWorkspacesService.onDidChangeRecentWorkspaces(() => { + listener.dispose(); + resolve(); + }); + }); + return recentWorkspacesService; +} + // ---- Assertion helpers ------------------------------------------------------ function assertSelectedProvider(picker: WorkspacePicker, expectedProviderId: string | undefined, message?: string): void { @@ -346,6 +373,50 @@ suite('WorkspacePicker - Connection Status', () => { assertSelectedProvider(picker, 'agenthost-remote-1'); }); + test('restore ignores VS Code\'s global recents, using only the sessions\' own history', async () => { + const localProvider = createMockProvider('local-1'); + providersService.setProviders([localProvider]); + + const ownUri = URI.file('/local/own-project'); + const globalUri = URI.file('/local/global-only-project'); + + const storage = disposables.add(new TestStorageService()); + seedStorage(storage, [{ uri: ownUri, providerId: 'local-1', checked: false }]); + + const workspacesService = { getRecentlyOpened: async () => ({ workspaces: [{ folderUri: globalUri }], files: [] }), onDidChangeRecentlyOpened: Event.None } as unknown as IWorkspacesService; + const recentWorkspacesService = await createResolvedRecentWorkspacesService(disposables, storage, providersService, workspacesService); + + // Sanity: the merged (display) list includes both entries... + assert.deepStrictEqual( + recentWorkspacesService.getRecentWorkspaces().map(r => r.workspace.uri.toString()), + [ownUri.toString(), globalUri.toString()], + ); + // ...but the own-only query used for restoration excludes the global one. + assert.deepStrictEqual( + recentWorkspacesService.getRecentWorkspaces(false).map(r => r.workspace.uri.toString()), + [ownUri.toString()], + ); + + const picker = createTestPicker(disposables, providersService, storage, undefined, undefined, undefined, workspacesService, recentWorkspacesService); + + assert.strictEqual(picker.selectedFolderUri?.toString(), ownUri.toString(), 'restore selects only the sessions-owned entry, not the VS Code global recent'); + }); + + test('restore selects nothing when own history is empty, even with VS Code global recents present', async () => { + const localProvider = createMockProvider('local-1'); + providersService.setProviders([localProvider]); + + const globalUri = URI.file('/local/global-only-project'); + const storage = disposables.add(new TestStorageService()); + + const workspacesService = { getRecentlyOpened: async () => ({ workspaces: [{ folderUri: globalUri }], files: [] }), onDidChangeRecentlyOpened: Event.None } as unknown as IWorkspacesService; + const recentWorkspacesService = await createResolvedRecentWorkspacesService(disposables, storage, providersService, workspacesService); + + const picker = createTestPicker(disposables, providersService, storage, undefined, undefined, undefined, workspacesService, recentWorkspacesService); + + assert.strictEqual(picker.selectedFolderUri, undefined, 'restore selects nothing when there is no owned history to restore from'); + }); + test('restored remote that never connects falls back after grace period', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // The provider is registered as Disconnected and never transitions — // e.g. SSH host is unreachable and the status was set before the picker diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 6775256c107f32..7426110a305220 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -255,7 +255,14 @@ export class SessionsManagementService extends Disposable implements ISessionsMa ); } - resolveWorkspace(folderUri: URI): { providerId: string; workspace: ISessionWorkspace } | undefined { + resolveWorkspace(folderUri: URI, preferredProviderId?: string): { providerId: string; workspace: ISessionWorkspace } | undefined { + if (preferredProviderId) { + const preferred = this.sessionsProvidersService.getProvider(preferredProviderId); + const workspace = preferred?.resolveWorkspace(folderUri); + if (workspace) { + return { providerId: preferredProviderId, workspace }; + } + } for (const provider of this.sessionsProvidersService.getProviders()) { const workspace = provider.resolveWorkspace(folderUri); if (workspace) { diff --git a/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts b/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts index 3bfdfc7a0f7821..075ade3a6d15d7 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsRecentWorkspacesService.ts @@ -40,8 +40,18 @@ export interface ISessionsRecentWorkspacesService { readonly onDidChangeRecentWorkspaces: Event; - /** The recently used folders, resolved and most recent first: own history first, then VS Code's recents (deduplicated). */ - getRecentWorkspaces(): IRecentWorkspace[]; + /** + * The recently used folders, resolved and most recent first: own history + * first, then (when `includeVSCodeRecents` is `true`, the default) VS + * Code's own recently opened folders (deduplicated against own history). + * + * Pass `false` to restrict to the sessions' own recently-picked history + * only, e.g. to restore the last explicit selection made in an Agents + * Window folder picker, so an unrelated folder the user merely opened in + * a regular VS Code window never silently becomes a new session's + * default workspace. + */ + getRecentWorkspaces(includeVSCodeRecents?: boolean): IRecentWorkspace[]; /** Records `folderUri` as most-recently used; `checked` un-checks every other entry. */ addRecentWorkspace(folderUri: URI, providerId: string | undefined, checked: boolean): void; @@ -75,18 +85,26 @@ export class SessionsRecentWorkspacesService extends Disposable implements ISess this._register(this.workspacesService.onDidChangeRecentlyOpened(() => this._refreshVSCodeRecentWorkspaces())); } - getRecentWorkspaces(): IRecentWorkspace[] { + getRecentWorkspaces(includeVSCodeRecents = true): IRecentWorkspace[] { const own = this._getStoredRecentWorkspaces(); + if (!includeVSCodeRecents) { + return this._resolveStored(own); + } + const ownUris = new Set(own.map(o => this.uriIdentityService.extUri.getComparisonKey(URI.revive(o.uri)))); const vsCode = this._vsCodeRecentFolderUris .filter(uri => !ownUris.has(this.uriIdentityService.extUri.getComparisonKey(uri))) .map(uri => ({ uri: uri.toJSON(), providerId: undefined, checked: false }) satisfies IStoredRecentWorkspace); + return this._resolveStored([...own, ...vsCode]); + } + + private _resolveStored(stored: readonly IStoredRecentWorkspace[]): IRecentWorkspace[] { const recents: IRecentWorkspace[] = []; - for (const stored of [...own, ...vsCode]) { - const resolved = this._resolveWorkspace(URI.revive(stored.uri), stored.providerId); + for (const entry of stored) { + const resolved = this._resolveWorkspace(URI.revive(entry.uri), entry.providerId); if (resolved) { - recents.push({ workspace: resolved.workspace, providerId: resolved.providerId, checked: stored.checked }); + recents.push({ workspace: resolved.workspace, providerId: resolved.providerId, checked: entry.checked }); } } return recents; diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 3d7a4863aa8a27..60d4d3b3c48a59 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -15,6 +15,8 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { localize } from '../../../../nls.js'; import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } from '../common/session.js'; import { IActiveSession, ICreateNewChatInSessionOptions, ICreateNewSessionOptions, IRecentlyOpenedSessions, ISessionsChangeEvent, ISessionsManagementService, IToggleSessionStickinessEvent } from '../common/sessionsManagement.js'; import { ISessionsProvidersService } from './sessionsProvidersService.js'; @@ -51,6 +53,19 @@ export interface IOpenNewSessionOptions extends ICreateNewSessionOptions { readonly folderUri?: URI; } +/** + * Result of {@link ISessionsService.openNewSession}. `session` holds the + * created/restored draft on success. `trustDeclined` is `true` only when a + * `folderUri` was supplied, the folder required workspace trust, and the + * user explicitly declined it — distinct from any other resolution/creation + * failure (where `session` is also `undefined` but `trustDeclined` is + * `false`, since that may still succeed later once a provider registers). + */ +export interface IOpenNewSessionResult { + readonly session: ISession | undefined; + readonly trustDeclined: boolean; +} + /** * Persisted state for a session. * Extend this interface to store additional per-session state that should be @@ -153,12 +168,17 @@ export interface ISessionsService { * the pending (composed-but-not-sent) draft if one exists, otherwise * showing the empty placeholder. No-op when the empty placeholder is * already showing (no session active). Returns the restored pending - * draft, or `undefined` when none. - * - With `options.folderUri`: create a concrete draft session for that - * folder (via {@link ISessionsManagementService.createNewSession}) and - * show it as the active session. Returns the created draft. + * draft as `result.session`, or `undefined` when none; `trustDeclined` + * is always `false`. + * - With `options.folderUri`: resolve the workspace and, when it requires + * workspace trust, prompt for it first (single gate for every path that + * creates a concrete session for a folder). If trust is declined, + * returns `{ session: undefined, trustDeclined: true }` without + * creating a session. Otherwise creates a concrete draft session for + * that folder (via {@link ISessionsManagementService.createNewSession}) + * and shows it as the active session, returning it as `result.session`. */ - openNewSession(options?: IOpenNewSessionOptions): ISession | undefined; + openNewSession(options?: IOpenNewSessionOptions): Promise; /** * Open a new **quick chat**: create a concrete workspace-less draft session @@ -286,6 +306,7 @@ export class SessionsService extends Disposable implements ISessionsService { @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @ISessionsPartService private readonly sessionsPartService: ISessionsPartService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, ) { super(); @@ -688,14 +709,32 @@ export class SessionsService extends Disposable implements ISessionsService { this._activate(undefined); } - openNewSession(options?: IOpenNewSessionOptions): ISession | undefined { + async openNewSession(options?: IOpenNewSessionOptions): Promise { const folderUri = options?.folderUri; if (folderUri) { + // Single trust gate for every path that creates a concrete session for + // a folder (the workspace picker dropdown, the folder Quick Pick, etc.): + // resolve the workspace and, if it requires trust, prompt before + // creating the session. A no-op if the folder is already trusted. + // Resolved with the same provider `createNewSession` below will use + // (honoring `options.providerId`), so the trust decision always + // reflects the workspace that is actually about to be created. + const resolved = this.sessionsManagementService.resolveWorkspace(folderUri, options?.providerId); + if (resolved?.workspace.requiresWorkspaceTrust) { + const trusted = await this.workspaceTrustRequestService.requestResourcesTrust({ + uri: folderUri, + message: localize('sessionsService.trustFolderMessage', "An agent session will be able to read files, run commands, and make changes in this folder."), + }); + if (!trusted) { + return { session: undefined, trustDeclined: true }; + } + } + this._startOpenSession(); try { const session = this.sessionsManagementService.createNewSession(folderUri, options); this._activate(session); - return session; + return { session, trustDeclined: false }; } catch (e) { // When the folder cannot be resolved (e.g. the active session's // workspace uses an unsupported scheme like 'unknown:/'), fall @@ -708,7 +747,7 @@ export class SessionsService extends Disposable implements ISessionsService { // the new-session composer view. // No-op when no session is active (empty new-session placeholder showing). if (this._visibility.activeSession.get() === undefined) { - return undefined; + return { session: undefined, trustDeclined: false }; } if (!folderUri) { this._startOpenSession(); @@ -725,11 +764,11 @@ export class SessionsService extends Disposable implements ISessionsService { if (newSession?.isQuickChat?.get()) { this.sessionsManagementService.discardNewSession(newSession); this._activate(undefined); - return undefined; + return { session: undefined, trustDeclined: false }; } this._activate(newSession ?? undefined); - return newSession ?? undefined; + return { session: newSession ?? undefined, trustDeclined: false }; } openQuickChat(options?: ICreateNewSessionOptions): IActiveSession | undefined { diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 232bdf8265660c..6fef3497166514 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -232,11 +232,15 @@ export interface ISessionsManagementService { isQuickChatTargetAvailable(options?: ICreateNewSessionOptions): boolean; /** - * Resolve a workspace URI to a workspace using the first provider whose - * {@link ISessionsProvider.resolveWorkspace} succeeds. Returns `undefined` - * when no registered provider can resolve the URI. - */ - resolveWorkspace(workspaceUri: URI): { providerId: string; workspace: ISessionWorkspace } | undefined; + * Resolve a workspace URI to a workspace. When `preferredProviderId` is + * given, that provider is tried first (matching the provider-selection + * rules {@link createNewSession} applies for the same options) so the + * resolution reflects the provider that would actually be used to create + * a session; otherwise iterates registered providers and returns the + * first whose {@link ISessionsProvider.resolveWorkspace} succeeds. + * Returns `undefined` when no provider can resolve the URI. + */ + resolveWorkspace(workspaceUri: URI, preferredProviderId?: string): { providerId: string; workspace: ISessionWorkspace } | undefined; /** * Fires when available session types change (providers added/removed). From a816e1a9ae176eba4e3972e9884bed39c3f269d5 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 23 Jul 2026 02:53:50 +1000 Subject: [PATCH 20/34] Fix chat list row height swallowed during render (stale/clipped until resize) (#326961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix chat list row height swallowed during render (stale/clipped until resize) fireItemHeightChange committed `currentRenderedHeight` before the `_elementBeingRendered` suppression check, so a later identical measurement was deduped by the "unchanged" guard and the tree never learned the grown row height. The chat response therefore stayed visually stale/clipped until a window resize forced a relayout. - Extract `reconcileChatItemHeight`; on a measurement suppressed during the render pass, keep the stored height untouched and schedule a next-frame re-measure so the grown height reliably reaches the tree (no reliance on the row ResizeObserver re-delivering). - Show the scroll-down button whenever the view is not at the bottom, decoupled from the `chat-list-at-bottom` padding state (`computeScrollDownState`), so users can jump to the latest content. - Add deterministic pure-function + real-render integration regression tests. Fixes #326952 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79925e85-d0b2-4e6d-91ae-2d50eee7fb7f * Show scroll-down button with explicit display:flex Clearing the inline display let the `.interactive-session .chat-scroll-down` stylesheet rule (`display: none`) win, so the button stayed hidden even when it should show. Use `flex` (the `.monaco-button` default) so the inline value overrides the stylesheet and the codicon stays centered. Addresses PR review feedback on #326952. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79925e85-d0b2-4e6d-91ae-2d50eee7fb7f * Cover scheduleInitial and initial-fit in reconciler tests The helper initialized the stored height from allocatedHeight, so currentRenderedHeight was never undefined and the `scheduleInitial` / initial-fit branches were never exercised (the "already fits" step actually hit the equality guard). Pass the initial stored height separately and add cases for an initial fit (<= allocation) and an initial measurement larger than the allocation. Addresses PR review feedback on #326952. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79925e85-d0b2-4e6d-91ae-2d50eee7fb7f * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix leaked disposable in the mid-render height integration test The test rendered a streaming (incomplete) response, so progressive rendering kept scheduling runProgressiveRender on animation frames and created a ChatWorkingProgressContentPart that outlived the test — surfacing in CI as a leaked disposable and unexpected console output during teardown. Complete the response (and re-render) before the assertions so progressive rendering stops, matching the existing renderer test's pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 79925e85-d0b2-4e6d-91ae-2d50eee7fb7f --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../chat/browser/widget/chatListRenderer.ts | 95 ++++++++--- .../chat/browser/widget/chatListWidget.ts | 25 ++- .../browser/widget/chatListRenderer.test.ts | 153 +++++++++++++++++- .../browser/widget/chatListWidget.test.ts | 20 ++- 4 files changed, 268 insertions(+), 25 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index fb587171ada5e0..d56c41962d3960 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -236,6 +236,60 @@ export function shouldScheduleInitialHeightChange(normalizedHeight: number, allo return typeof allocatedHeight !== 'number' || normalizedHeight > allocatedHeight; } +/** How a freshly measured row height should be reconciled against the tree's known height. */ +export type ChatItemHeightUpdateKind = 'none' | 'fire' | 'scheduleInitial' | 'deferReMeasure'; + +export interface IChatItemHeightUpdate { + /** Value to store back into the element's `currentRenderedHeight`. */ + readonly nextRenderedHeight: number | undefined; + /** Whether/how to notify the tree of the new height. */ + readonly kind: ChatItemHeightUpdateKind; + /** The height to notify with (meaningful when `kind` is `fire` or `scheduleInitial`). */ + readonly height: number; +} + +/** + * Decide how a freshly measured, normalized row height should be reconciled against the height + * the tree currently knows about (`currentRenderedHeight`). + * + * `isBeingRendered` is `true` when the measurement arrives *synchronously* during the tree's + * `renderElement` call; in that case the tree must not be notified re-entrantly. + * + * See https://github.com/microsoft/vscode/issues/326952: when notification is suppressed, + * `currentRenderedHeight` must remain unchanged so an identical deferred measurement is not + * deduplicated before it reaches the tree. + */ +export function reconcileChatItemHeight( + normalizedHeight: number, + currentRenderedHeight: number | undefined, + isBeingRendered: boolean, + allocatedHeight: number | undefined, +): IChatItemHeightUpdate { + if (normalizedHeight === currentRenderedHeight) { + return { nextRenderedHeight: currentRenderedHeight, kind: 'none', height: normalizedHeight }; + } + + if (isBeingRendered) { + // Suppress the re-entrant notification and DO NOT advance `currentRenderedHeight` (the tree + // was never told). Schedule a deferred re-measure so the height reaches the tree once this + // row is done rendering, instead of relying on a later async measurement that could be + // deduped by the "unchanged" check above. + return { nextRenderedHeight: currentRenderedHeight, kind: 'deferReMeasure', height: normalizedHeight }; + } + + if (typeof currentRenderedHeight === 'number') { + return { nextRenderedHeight: normalizedHeight, kind: 'fire', height: normalizedHeight }; + } + + // First measurements that already fit are just initialization. Only schedule a first update + // when the row would otherwise clip newly rendered content. + if (!shouldScheduleInitialHeightChange(normalizedHeight, allocatedHeight)) { + return { nextRenderedHeight: normalizedHeight, kind: 'none', height: normalizedHeight }; + } + + return { nextRenderedHeight: normalizedHeight, kind: 'scheduleInitial', height: normalizedHeight }; +} + export function renderChatResponseDetails(container: HTMLElement, details: string | undefined, completedAt: number | undefined, elapsedMs: number | undefined, verbose: boolean): HTMLElement | undefined { dom.clearNode(container); container.classList.remove('chat-response-flip-active', 'chat-response-flip-down', 'chat-response-flip-reset'); @@ -533,33 +587,34 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { if (template.currentElement !== element || element.currentRenderedHeight !== scheduledHeight) { return; } this._onDidChangeItemHeight.fire({ element, height: scheduledHeight }); }); + } else if (update.kind === 'deferReMeasure') { + // The measurement arrived synchronously during this row's render. Re-measure on the + // next frame (once the render pass is over) so the grown height reliably reaches the + // tree without a re-entrant notification. + dom.scheduleAtNextAnimationFrame(dom.getWindow(template.rowContainer), () => { + if (template.currentElement === element && element !== this._elementBeingRendered) { + this.fireItemHeightChange(template); + } + }); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 56dce28728ec30..c94a95963f37fd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -87,6 +87,24 @@ export function getAnchoredScrollTop(scrollTop: number, currentTargetTop: number return scrollTop + currentTargetTop - anchorTargetTop; } +/** + * Computes the scroll-down state for the chat list, keeping two concerns decoupled: + * + * - `showButton`: whether the "scroll to bottom" affordance is shown. Driven purely by the actual + * scroll position so the user can always jump to the latest content when the view is not at the + * bottom — including during an auto-scroll (agent) turn where the view has fallen behind. See + * https://github.com/microsoft/vscode/issues/326952 (previously this was also suppressed by the + * scroll lock, hiding the button for the whole agent turn). + * - `atBottom`: the `chat-list-at-bottom` visual state that reserves streaming-response padding. + * Intentionally still honours the scroll lock so padding during auto-scroll turns is unchanged. + */ +export function computeScrollDownState(isScrolledToBottom: boolean, scrollLock: boolean): { showButton: boolean; atBottom: boolean } { + return { + showButton: !isScrolledToBottom, + atBottom: isScrolledToBottom || scrollLock, + }; +} + class UserToggleResizeTracker extends Disposable { private readonly state = new UserToggleResizeState(2); @@ -609,8 +627,11 @@ export class ChatListWidget extends Disposable { * Update scroll-down button visibility based on scroll position and scroll lock. */ private updateScrollDownButtonVisibility(): void { - const atBottom = this.isScrolledToBottom || this._scrollLock; - this._scrollDownButton.element.style.display = atBottom ? 'none' : ''; + const { showButton, atBottom } = computeScrollDownState(this.isScrolledToBottom, this._scrollLock); + // Use an explicit `flex` (the `.monaco-button` default) rather than '' when showing: the + // stylesheet applies `display: none` to `.interactive-session .chat-scroll-down`, so clearing + // the inline style would let that rule win and keep the button hidden. + this._scrollDownButton.element.style.display = showButton ? 'flex' : 'none'; this._container.classList.toggle('chat-list-at-bottom', atBottom); } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 04022555e65b2f..f39ec4e1b27214 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import * as dom from '../../../../../../base/browser/dom.js'; import { mainWindow } from '../../../../../../base/browser/window.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -15,13 +16,13 @@ import { TestConfigurationService } from '../../../../../../platform/configurati import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; -import { buildPlanReviewProgressContent, ChatListItemRenderer, getWorkingProgressRelevantParts, isWaitingForMcpServers, renderChatRequestTimestamp, renderChatResponseDetails, shouldCreateGroupedThinkingPart, shouldHideChatUserIdentity, shouldPinToolInvocationToThinking, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange, shouldShowFileChangesSummaryForSettings, shouldShowPillsSummaryForSettings, shouldStartNewCollapsedThinkingGroup } from '../../../browser/widget/chatListRenderer.js'; +import { buildPlanReviewProgressContent, ChatListItemRenderer, getWorkingProgressRelevantParts, IChatListItemTemplate, isWaitingForMcpServers, reconcileChatItemHeight, renderChatRequestTimestamp, renderChatResponseDetails, shouldCreateGroupedThinkingPart, shouldHideChatUserIdentity, shouldPinToolInvocationToThinking, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange, shouldShowFileChangesSummaryForSettings, shouldShowPillsSummaryForSettings, shouldStartNewCollapsedThinkingGroup } from '../../../browser/widget/chatListRenderer.js'; import { isChatTurnStatusPillsEnabled } from '../../../browser/widget/chatTurnPills.js'; import { IChatMcpServersStartingSlow, IChatService, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { formatChatRequestTimestamp, formatChatResponseDetails, formatElapsedTime } from '../../../common/chatProgressFormatting.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../../common/constants.js'; import { ChatModel } from '../../../common/model/chatModel.js'; -import { ChatViewModel, IChatRendererContent, isResponseVM } from '../../../common/model/chatViewModel.js'; +import { ChatViewModel, IChatRendererContent, IChatResponseViewModel, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; @@ -50,6 +51,64 @@ suite('ChatListRenderer', () => { }); }); + suite('reconcileChatItemHeight', () => { + // Helper: run a sequence of measurements through the reconciler, threading + // `currentRenderedHeight` the way `fireItemHeightChange` does, and capture the + // notification kind + the stored height after each step. `initialStored` is the + // element's `currentRenderedHeight` before the first step (undefined = never measured). + const run = (steps: readonly { measured: number; isBeingRendered: boolean }[], allocatedHeight: number | undefined, initialStored: number | undefined) => { + let stored: number | undefined = initialStored; + return steps.map(({ measured, isBeingRendered }) => { + const update = reconcileChatItemHeight(measured, stored, isBeingRendered, allocatedHeight); + stored = update.nextRenderedHeight; + return { kind: update.kind, height: update.height, stored }; + }); + }; + + // Regression test for https://github.com/microsoft/vscode/issues/326952. + // A row grows during streaming and is measured synchronously while it is being rendered + // (notification suppressed). The stored height must NOT advance, and a deferred re-measure + // must be requested, so a follow-up measurement of the grown height actually reaches the + // tree instead of being deduped away (which would strand the content until a window resize). + test('does not strand a grown height first seen while the row is being rendered', () => { + assert.deepStrictEqual( + run([ + { measured: 900, isBeingRendered: true }, // grew mid-render -> suppressed, defer + { measured: 900, isBeingRendered: false }, // deferred re-measure delivers the height + ], /*allocatedHeight*/ 500, /*initialStored*/ 500), + [ + { kind: 'deferReMeasure', height: 900, stored: 500 }, + { kind: 'fire', height: 900, stored: 900 }, + ], + ); + }); + + test('notifies the tree on async growth and ignores an unchanged measurement', () => { + assert.deepStrictEqual( + run([ + { measured: 700, isBeingRendered: false }, // async growth -> notify + { measured: 700, isBeingRendered: false }, // unchanged -> no-op + ], /*allocatedHeight*/ 500, /*initialStored*/ 500), + [ + { kind: 'fire', height: 700, stored: 700 }, + { kind: 'none', height: 700, stored: 700 }, + ], + ); + }); + + test('first measurement (no stored height) only schedules an update when content would clip', () => { + assert.deepStrictEqual([ + // Initial measurement that fits within the allocated height -> no notification. + run([{ measured: 500, isBeingRendered: false }], /*allocatedHeight*/ 500, /*initialStored*/ undefined), + // Initial measurement larger than the allocation -> schedule an initial update. + run([{ measured: 700, isBeingRendered: false }], /*allocatedHeight*/ 500, /*initialStored*/ undefined), + ], [ + [{ kind: 'none', height: 500, stored: 500 }], + [{ kind: 'scheduleInitial', height: 700, stored: 700 }], + ]); + }); + }); + suite('shouldRenderInitialProgressiveContentImmediately', () => { test('renders accumulated markdown immediately only when progressive rendering has not started', () => { assert.deepStrictEqual([ @@ -460,4 +519,94 @@ suite('ChatListRenderer', () => { disposables.dispose(); }); + // End-to-end regression test for https://github.com/microsoft/vscode/issues/326952: a height + // measured synchronously *during* the render pass must be deferred (not fired re-entrantly and + // not stored), then reliably delivered to the tree afterwards via a re-measure — so streamed + // content can't get stranded below a stale row height until a window resize. + test('fireItemHeightChange defers a mid-render measurement and delivers it after the render pass', async () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + const configurationService = new TestConfigurationService(); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IChatService, new MockChatService()); + instantiationService.stub(IChatAgentService, disposables.add(instantiationService.createInstance(ChatAgentService))); + + const model = disposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); + const viewModel = disposables.add(instantiationService.createInstance(ChatViewModel, model, undefined)); + const text = 'test'; + const request = model.addRequest({ + text, + parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, 1, 1, text.length + 1), text)] + }, { variables: [] }, 0); + const response = viewModel.getItems().find(isResponseVM); + assert.ok(response); + + const container = mainWindow.document.createElement('div'); + mainWindow.document.body.appendChild(container); + disposables.add(toDisposable(() => container.remove())); + const renderer = disposables.add(instantiationService.createInstance( + ChatListItemRenderer, + {} as ChatEditorOptions, + { progressMessageAtBottomOfResponse: true }, + { + getListLength: () => 1, + onDidScroll: () => toDisposable(() => { }), + container, + currentChatMode: () => ChatModeKind.Agent, + }, + undefined, + viewModel, + )); + const template = renderer.renderTemplate(container); + disposables.add(toDisposable(() => renderer.disposeTemplate(template))); + const node = { element: response, children: [], depth: 0, visibleChildrenCount: 0, visibleChildIndex: 0, collapsible: false, collapsed: false, visible: true, filterData: undefined }; + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('Some initial content') }); + renderer.renderElement(node, 0, template); + // Complete the response so progressive rendering stops. Otherwise a streaming response keeps + // scheduling `runProgressiveRender` on animation frames, which creates a + // ChatWorkingProgressContentPart that outlives the test (leaked disposable + stray console + // output during teardown). + request.response?.complete(); + renderer.renderElement(node, 0, template); + + const privateRenderer = renderer as unknown as { + _elementBeingRendered: IChatResponseViewModel | undefined; + fireItemHeightChange(template: IChatListItemTemplate, measuredHeight?: number): void; + }; + const nextFrame = () => new Promise(resolve => dom.scheduleAtNextAnimationFrame(dom.getWindow(container), () => resolve())); + + // Let the initial render's height activity (ResizeObserver / scheduled updates) settle. + await nextFrame(); + await nextFrame(); + + // The row's real rendered height. The DOM is NOT mutated after this point, so the row's + // ResizeObserver stays quiet and only the code under test can deliver a further update. + const renderedHeight = Math.ceil(template.rowContainer.getBoundingClientRect().height); + assert.ok(renderedHeight > 1, 'row should have a real rendered height'); + + // Simulate streaming that grew the row past the height the tree last acknowledged. + response.currentRenderedHeight = renderedHeight - 1; + const heightEvents: number[] = []; + disposables.add(renderer.onDidChangeItemHeight(e => heightEvents.push(e.height))); + + // (a) A measurement seen synchronously during the render pass must not notify the tree + // re-entrantly and must not advance the stored height. + privateRenderer._elementBeingRendered = response; + privateRenderer.fireItemHeightChange(template); + assert.deepStrictEqual( + { events: [...heightEvents], stored: response.currentRenderedHeight }, + { events: [], stored: renderedHeight - 1 }, + ); + + // (b) Once the render pass is over the deferred re-measure delivers the real height. + privateRenderer._elementBeingRendered = undefined; + await nextFrame(); + assert.deepStrictEqual( + { events: [...heightEvents], stored: response.currentRenderedHeight }, + { events: [renderedHeight], stored: renderedHeight }, + ); + + disposables.dispose(); + }); + }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts index c80ac4a1ae3df6..a5f00987a4a560 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListWidget.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { getAnchoredScrollTop, UserToggleResizeState } from '../../../browser/widget/chatListWidget.js'; +import { computeScrollDownState, getAnchoredScrollTop, UserToggleResizeState } from '../../../browser/widget/chatListWidget.js'; suite('ChatListWidget', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -45,4 +45,22 @@ suite('ChatListWidget', () => { titleUnchanged: 300, }); }); + + // Regression test for https://github.com/microsoft/vscode/issues/326952: the scroll-down + // button must reflect the actual scroll position (shown whenever not at the bottom) even while + // the scroll lock is engaged during an agent turn, while the `chat-list-at-bottom` padding + // state stays coupled to the scroll lock. + test('scroll-down button is decoupled from the at-bottom padding state', () => { + assert.deepStrictEqual([ + computeScrollDownState(/*isScrolledToBottom*/ true, /*scrollLock*/ true), + computeScrollDownState(/*isScrolledToBottom*/ true, /*scrollLock*/ false), + computeScrollDownState(/*isScrolledToBottom*/ false, /*scrollLock*/ true), + computeScrollDownState(/*isScrolledToBottom*/ false, /*scrollLock*/ false), + ], [ + { showButton: false, atBottom: true }, + { showButton: false, atBottom: true }, + { showButton: true, atBottom: true }, + { showButton: true, atBottom: false }, + ]); + }); }); From e06cd76a94f8278151b2e944856aae34ec950fa4 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Jul 2026 13:11:37 -0400 Subject: [PATCH 21/34] dictation: publish native runtime to CDN instead of npm/NuGet at runtime (#326979) --- .../common/dictation-runtime-produce.yml | 66 +++++ .../steps/product-build-darwin-compile.yml | 4 + .../steps/product-build-linux-compile.yml | 4 + .../steps/product-build-win32-compile.yml | 4 + build/dictation-runtime/common.ts | 225 +++++++++++++++++ build/dictation-runtime/nuget.ts | 228 ++++++++++++++++++ build/dictation-runtime/package.ts | 192 +++++++++++++++ build/dictation-runtime/produce.ts | 117 +++++++++ build/dictation-runtime/upload.ts | 135 +++++++++++ build/gulpfile.vscode.ts | 8 + src/vs/base/common/product.ts | 15 ++ .../common/localTranscription.ts | 8 +- .../node/foundryLocalRuntime.ts | 203 ++++++---------- .../node/localTranscriptionService.ts | 29 ++- .../test/node/foundryLocalRuntime.test.ts | 103 ++++++++ .../common/utilityProcessWorkerService.ts | 9 + .../utilityProcessWorkerMainService.ts | 1 + .../localTranscriptionService.ts | 22 +- 18 files changed, 1232 insertions(+), 141 deletions(-) create mode 100644 build/azure-pipelines/common/dictation-runtime-produce.yml create mode 100644 build/dictation-runtime/common.ts create mode 100644 build/dictation-runtime/nuget.ts create mode 100644 build/dictation-runtime/package.ts create mode 100644 build/dictation-runtime/produce.ts create mode 100644 build/dictation-runtime/upload.ts diff --git a/build/azure-pipelines/common/dictation-runtime-produce.yml b/build/azure-pipelines/common/dictation-runtime-produce.yml new file mode 100644 index 00000000000000..ba30ab03719f46 --- /dev/null +++ b/build/azure-pipelines/common/dictation-runtime-produce.yml @@ -0,0 +1,66 @@ +parameters: + - name: vscodePlatform + type: string + values: + - darwin + - linux + - win32 + +# Produces the dictation-runtime for ONE VS Code build: +# - STAMP: writes the (target-agnostic) product.dictationRuntime results file +# and emits `##vso[task.setvariable variable=DICTATION_RUNTIME_RESULTS_FILE]` +# on EVERY build that can host dictation, so the downstream gulp packaging +# step stamps product.json. Packaged builds strip the SDK's native payload, +# so they need this CDN pointer regardless of VSCODE_PUBLISH. +# - PAYLOAD: builds this target's tarball and uploads it to the CDN only on +# real publish runs (VSCODE_PUBLISH=true). darwin-x64 stamps but uploads +# nothing (the darwin-arm64 job publishes the shared Apple Silicon payload). +# +# The core libraries are fetched from NuGet for the target's explicit RID (see +# build/dictation-runtime/nuget.ts), so ARM64 targets build fine on x64 pools; +# the prebuilt addon is copied from the already-restored node_modules. No npm +# registry auth is needed. +# +# Steps: +# 1. AzureCLI@2 to fetch the SPN credentials for the CDN storage account — +# skipped on non-publish runs (no CDN write to auth for). +# 2. Run `node build/dictation-runtime/produce.ts`. Always stamps; uploads +# only on publish. +steps: + - task: AzureCLI@2 + displayName: "Dictation runtime: fetch CDN credentials" + condition: and(succeeded(), eq(lower(variables['VSCODE_PUBLISH']), 'true')) + inputs: + azureSubscription: vscode + scriptType: pscore + scriptLocation: inlineScript + addSpnToEnvironment: true + inlineScript: | + Write-Host "##vso[task.setvariable variable=AZURE_TENANT_ID]$env:tenantId" + Write-Host "##vso[task.setvariable variable=AZURE_CLIENT_ID]$env:servicePrincipalId" + Write-Host "##vso[task.setvariable variable=AZURE_ID_TOKEN;issecret=true]$env:idToken" + + - ${{ if eq(parameters.vscodePlatform, 'win32') }}: + - powershell: | + . build/azure-pipelines/win32/exec.ps1 + $ErrorActionPreference = "Stop" + exec { node build/dictation-runtime/produce.ts --vscode-platform=${{ parameters.vscodePlatform }} --arch=$(VSCODE_ARCH) } + env: + DICTATION_RUNTIME_RESULTS_FILE: $(Build.SourcesDirectory)/.build/dictation-runtime/${{ parameters.vscodePlatform }}-$(VSCODE_ARCH).json + AZURE_STORAGE_ACCOUNT: vscodeweb + AZURE_TENANT_ID: $(AZURE_TENANT_ID) + AZURE_CLIENT_ID: $(AZURE_CLIENT_ID) + AZURE_ID_TOKEN: $(AZURE_ID_TOKEN) + displayName: "Dictation runtime: build + upload" + + - ${{ if ne(parameters.vscodePlatform, 'win32') }}: + - script: | + set -e + node build/dictation-runtime/produce.ts --vscode-platform=${{ parameters.vscodePlatform }} --arch=$(VSCODE_ARCH) + env: + DICTATION_RUNTIME_RESULTS_FILE: $(Build.SourcesDirectory)/.build/dictation-runtime/${{ parameters.vscodePlatform }}-$(VSCODE_ARCH).json + AZURE_STORAGE_ACCOUNT: vscodeweb + AZURE_TENANT_ID: $(AZURE_TENANT_ID) + AZURE_CLIENT_ID: $(AZURE_CLIENT_ID) + AZURE_ID_TOKEN: $(AZURE_ID_TOKEN) + displayName: "Dictation runtime: build + upload" diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml index 14f3a0573303a9..4a04abc71c6710 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -142,6 +142,10 @@ steps: parameters: vscodePlatform: darwin + - template: ../../common/dictation-runtime-produce.yml@self + parameters: + vscodePlatform: darwin + - script: npx deemon --detach --wait -- node build/azure-pipelines/common/downloadCopilotVsix.ts env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) diff --git a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml index a9d8e91ba5c53e..0fe51faae33969 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -190,6 +190,10 @@ steps: parameters: vscodePlatform: linux + - template: ../../common/dictation-runtime-produce.yml@self + parameters: + vscodePlatform: linux + - script: npx deemon --detach --wait -- node build/azure-pipelines/common/downloadCopilotVsix.ts env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) diff --git a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml index 126362fd93ba38..3f39a50bc563aa 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml @@ -131,6 +131,10 @@ steps: parameters: vscodePlatform: win32 + - template: ../../common/dictation-runtime-produce.yml@self + parameters: + vscodePlatform: win32 + - pwsh: npx deemon --detach --wait -- node build/azure-pipelines/common/downloadCopilotVsix.ts env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) diff --git a/build/dictation-runtime/common.ts b/build/dictation-runtime/common.ts new file mode 100644 index 00000000000000..857618f3cf7801 --- /dev/null +++ b/build/dictation-runtime/common.ts @@ -0,0 +1,225 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Shared helpers for the per-platform dictation-runtime build pipeline. Called + * from `package.ts`, `upload.ts`, and `produce.ts`, plus the gulpfiles' + * `packageTask` (via `readDictationRuntimeResults`) so each VS Code build can + * stamp its own `product.dictationRuntime` into the per-platform `product.json` + * at packaging time. + * + * This mirrors `build/agent-sdk/` but for the Foundry Local native runtime used + * by on-device dictation: a prebuilt N-API addon (`foundry_local_napi.node`) + * plus the Foundry Local Core / onnxruntime / onnxruntime-genai shared + * libraries. Rather than downloading those from npm + NuGet at runtime, each + * platform build job produces its own tarball and uploads it to + * `main.vscode-cdn.net`; the runtime downloads the single content-addressed + * tarball for its target (see `foundryLocalRuntime.ts`). + * + * There is exactly ONE runtime (unlike the multi-SDK agent-sdk pipeline), so + * `product.dictationRuntime` is a single `{version, urlTemplate}` object rather + * than a map keyed by id. The version is the pinned `foundry-local-sdk` + * dependency in the repo-root `package.json` — the same package the runtime + * loads — so the shipped types and the CDN payload stay in lockstep. + */ + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); + +/** Repo-root `package.json`, the source of truth for the pinned SDK version. */ +const ROOT_PACKAGE_JSON = path.join(THIS_DIR, '..', '..', 'package.json'); + +/** The npm package whose native payload we republish to the CDN. */ +export const SDK_PACKAGE_NAME = 'foundry-local-sdk'; + +/** + * Path segment under the CDN URL and the conceptual id of the runtime. Kept as a + * named constant (rather than inlined) so the blob layout, the url builders, and + * `upload.ts`'s filename parsing agree on one string. + */ +export const RUNTIME_ID = 'foundry-local'; + +interface IRootPackageJson { + readonly dependencies?: Readonly>; +} + +let _versionCache: string | undefined; + +/** + * The exact pinned `foundry-local-sdk` version from the repo-root + * `package.json`. Rejects `^`/`~` ranges — a range would let the build resolve + * a different version than the runtime types were compiled against, and the + * content-addressed CDN upload would then diverge across runs. + */ +export function getRuntimeVersion(): string { + if (_versionCache) { + return _versionCache; + } + const json = JSON.parse(fs.readFileSync(ROOT_PACKAGE_JSON, 'utf8')) as IRootPackageJson; + const version = json.dependencies?.[SDK_PACKAGE_NAME]; + if (!version) { + throw new Error(`Expected a '${SDK_PACKAGE_NAME}' entry in ${ROOT_PACKAGE_JSON} dependencies.`); + } + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Refusing to use ${SDK_PACKAGE_NAME}@${version} from ${ROOT_PACKAGE_JSON}: must be an exact version (no ^ or ~ ranges).`); + } + _versionCache = version; + return version; +} + +/** Strict subset of VS Code build platforms the dictation runtime can target. */ +export type VscodeBuildPlatform = 'darwin' | 'linux' | 'win32'; + +/** Runtime whitelist mirroring `VscodeBuildPlatform`, for CLI guards. */ +export const KNOWN_VSCODE_PLATFORMS: ReadonlySet = new Set([ + 'darwin', 'linux', 'win32', +]); + +/** + * The set of `-` targets Foundry Local ships a native runtime + * for. Mirrors `FOUNDRY_LOCAL_SUPPORTED_PLATFORMS` in + * `src/vs/platform/localTranscription/node/foundryLocalRuntime.ts` and the + * `SUPPORTED_TARGETS` runtime gate — keep the three in sync. + */ +export const SUPPORTED_TARGETS: ReadonlySet = new Set([ + 'darwin-arm64', + 'linux-x64', + 'linux-arm64', + 'win32-x64', + 'win32-arm64', +]); + +/** + * Resolves the runtime target for a particular VS Code build, or `undefined` + * when that `(platform, arch)` has no Foundry Local runtime (e.g. `darwin-x64`, + * Alpine/musl, armhf, web). The macOS build is Universal but Foundry Local only + * ships `darwin-arm64`, so `darwin-x64` returns `undefined` — the runtime gate + * reports on-device dictation unsupported there and never downloads. + * + * The legacy Alpine x64 encoding (`{platform: 'linux', arch: 'alpine'}`) and any + * real `alpine` platform return `undefined`: the core libraries are glibc-linked. + */ +export function getRuntimeTargetForBuild(vscodePlatform: string, arch: string): string | undefined { + if (vscodePlatform === 'alpine' || arch === 'alpine' || arch === 'musl') { + return undefined; + } + const target = `${vscodePlatform}-${arch}`; + return SUPPORTED_TARGETS.has(target) ? target : undefined; +} + +/** + * Whether a given VS Code build should stamp `product.dictationRuntime`, EVEN IF + * this build's own `(platform, arch)` has no CDN payload of its own. + * + * The stamp — `{version, urlTemplate}` — is target-agnostic (the `{target}` + * placeholder is resolved by the runtime per launch), so it must be present on + * every product whose app can host on-device dictation, whether or not this + * particular job produced/uploaded the payload: + * + * - `darwin-x64`: the macOS app is Universal. Its x64 and arm64 slices are + * merged into one bundle whose single `product.json` must carry the runtime + * config so dictation works when the Universal app runs natively on Apple + * Silicon — even though only the `darwin-arm64` job builds/uploads the + * payload. So `darwin-x64` stamps but does not produce. + * - non-publish product builds: packaging always strips the SDK's native + * payload (`getFoundryLocalExcludeFilter` in `gulpfile.vscode.ts`), so a + * packaged build with no stamp would have NEITHER a CDN location NOR a + * `node_modules` fallback. Stamping regardless of `VSCODE_PUBLISH` gives the + * packaged app a usable CDN source; the payload for that version is uploaded + * (idempotently) by publish runs. Only local dev-from-source (which never + * runs `produce.ts`) keeps the `node_modules` payload. + * + * Returns `false` for platforms/arches that can never host dictation (armhf, + * Alpine/musl, web) so their `product.json` stays clean. + */ +export function shouldStampRuntime(vscodePlatform: string, arch: string): boolean { + if (getRuntimeTargetForBuild(vscodePlatform, arch)) { + return true; + } + // The Universal macOS x64 slice must match its arm64 sibling's stamp. + return vscodePlatform === 'darwin' && arch === 'x64'; +} + +/** + * The CDN URL `product.dictationRuntime.urlTemplate` resolves to for a concrete + * target. Content-addressed under + * `dictation-runtime///.tgz`. Matches the upload + * path written by `upload.ts`. + */ +export function buildCdnUrl(version: string, target: string): string { + return `https://main.vscode-cdn.net/dictation-runtime/${RUNTIME_ID}/${version}/${target}.tgz`; +} + +/** + * The `format2`-style URL template stamped into + * `product.dictationRuntime.urlTemplate`. The runtime substitutes `{target}` + * per launch via `foundryLocalPlatformKey()`; every platform job emits the same + * template so a macOS Universal bundle can share one `product.json`. + */ +export function buildCdnUrlTemplate(version: string): string { + return `https://main.vscode-cdn.net/dictation-runtime/${RUNTIME_ID}/${version}/{target}.tgz`; +} + +/** Streams `filePath` into a sha256 hasher without buffering the whole file. */ +export function sha256OfFile(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + stream.on('error', reject); + stream.on('data', chunk => hash.update(chunk)); + stream.on('end', () => resolve(hash.digest('hex'))); + }); +} + +/** Parses `--key=value` CLI flags into a Map. */ +export function parseFlags(argv: readonly string[]): Map { + const flags = new Map(); + for (const arg of argv) { + const m = /^--([a-zA-Z-]+)=(.+)$/.exec(arg); + if (m) { + flags.set(m[1], m[2]); + } + } + return flags; +} + +/** + * The `product.dictationRuntime` entry, written by `produce.ts` and read by the + * gulpfiles' `packageTask`. Shape matches `IDictationRuntimeProductConfig` in + * `src/vs/base/common/product.ts`. + */ +export interface IDictationRuntimeResult { + readonly version: string; + readonly urlTemplate: string; +} + +/** + * Reads the per-platform dictation-runtime results file written by `produce.ts`. + * Returns `undefined` when `DICTATION_RUNTIME_RESULTS_FILE` is unset, the file + * doesn't exist, or the build can't host dictation. In a pipeline build that CAN + * host dictation this is always present (publish or not); it is absent for local + * dev-from-source (which never runs `produce.ts`) — that path ships product.json + * without `dictationRuntime` and the runtime falls back to the SDK's own + * `node_modules` payload. + */ +export function readDictationRuntimeResults(): IDictationRuntimeResult | undefined { + const filePath = process.env.DICTATION_RUNTIME_RESULTS_FILE; + if (!filePath || !fs.existsSync(filePath)) { + return undefined; + } + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + if (typeof parsed !== 'object' || parsed === null) { + throw new Error(`DICTATION_RUNTIME_RESULTS_FILE at ${filePath} is not a JSON object`); + } + const result = parsed as Partial; + if (typeof result.version !== 'string' || typeof result.urlTemplate !== 'string') { + return undefined; + } + return { version: result.version, urlTemplate: result.urlTemplate }; +} diff --git a/build/dictation-runtime/nuget.ts b/build/dictation-runtime/nuget.ts new file mode 100644 index 00000000000000..98ecc4f6d1c235 --- /dev/null +++ b/build/dictation-runtime/nuget.ts @@ -0,0 +1,228 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Fetches the Foundry Local native core libraries (Foundry Local Core + + * onnxruntime + onnxruntime-genai) from NuGet for an EXPLICIT RID, so a single + * build agent can assemble a tarball for any target regardless of its own + * `process.platform`/`process.arch`. + * + * This is a deliberate re-implementation of the download/extract loop in + * `foundry-local-sdk`'s `script/install-utils.cjs`, whose `runInstall` derives + * the RID from the RUNNING host (`os.platform()`/`os.arch()`) at module load and + * exposes no override. VS Code's ARM64 desktop builds run on x64 pools, so the + * host-locked installer can never produce the `linux-arm64`/`win32-arm64` + * tarballs; extracting `runtimes//native/*` from the same `.nupkg` files + * for an explicit RID is host-independent and fixes that. + * + * Only the "standard" artifact set is supported (the three packages selected by + * `package.ts`); the SDK installer's WinML override / `includeFiles` / + * `removeFiles` paths are intentionally not ported. + */ + +import { createRequire } from 'module'; +import * as fs from 'fs'; +import * as https from 'https'; +import * as os from 'os'; +import * as path from 'path'; +import { SDK_PACKAGE_NAME } from './common.ts'; + +const SCRIPT = 'nuget.ts'; + +/** + * `adm-zip`, resolved through `foundry-local-sdk`'s own dependency tree (it is a + * dependency of the SDK), so we don't add a new build dependency just to unzip + * `.nupkg` archives. + */ +function loadAdmZip(): any { + const sdkRequire = createRequire(import.meta.url); + const fromSdk = createRequire(sdkRequire.resolve(`${SDK_PACKAGE_NAME}/package.json`)); + return fromSdk('adm-zip'); +} + +/** + * NuGet feeds tried in order, matching `foundry-local-sdk`'s installer: the + * stable nuget.org feed first, then the public ORT-Nightly Azure DevOps feed + * (where pre-release Foundry Local Core / ORT / ORT-GenAI builds live before + * they reach nuget.org). + */ +const FEEDS: readonly string[] = [ + 'https://api.nuget.org/v3/index.json', + 'https://pkgs.dev.azure.com/aiinfra/PublicPackages/_packaging/ORT-Nightly/nuget/v3/index.json', +]; + +/** The NuGet Runtime IDentifier for each supported runtime target. */ +const RID_BY_TARGET: Readonly> = { + 'win32-x64': 'win-x64', + 'win32-arm64': 'win-arm64', + 'linux-x64': 'linux-x64', + 'linux-arm64': 'linux-arm64', + 'darwin-arm64': 'osx-arm64', +}; + +/** The shared-library file extension for a target's OS. */ +function libExt(target: string): string { + return target.startsWith('win32-') ? '.dll' : target.startsWith('darwin-') ? '.dylib' : '.so'; +} + +export interface INugetArtifact { + readonly name: string; + readonly version: string; +} + +/** + * Download each `artifact` `.nupkg` for `target`'s RID and extract its native + * shared libraries into `binDir`. Throws if a package can't be fetched from any + * feed; callers verify the resulting library set separately. + */ +export async function fetchCoreLibraries(target: string, artifacts: readonly INugetArtifact[], binDir: string): Promise { + const rid = RID_BY_TARGET[target]; + if (!rid) { + throw new Error(`[${SCRIPT}] No NuGet RID mapping for target '${target}'.`); + } + const ext = libExt(target); + const AdmZip = loadAdmZip(); + + fs.mkdirSync(binDir, { recursive: true }); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dictation-nuget-')); + const serviceIndexCache = new Map(); + try { + console.log(`[${SCRIPT}] Fetching native libraries for RID ${rid} (target ${target})...`); + for (const artifact of artifacts) { + await installPackage(artifact, rid, ext, tempDir, binDir, AdmZip, serviceIndexCache); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +async function installPackage( + artifact: INugetArtifact, + rid: string, + ext: string, + tempDir: string, + binDir: string, + AdmZip: any, + serviceIndexCache: Map, +): Promise { + let lastError: unknown; + for (let i = 0; i < FEEDS.length; i++) { + const feedUrl = FEEDS[i]; + const feedHost = new URL(feedUrl).host; + try { + const baseAddress = await getBaseAddress(feedUrl, serviceIndexCache); + const nameLower = artifact.name.toLowerCase(); + const verLower = artifact.version.toLowerCase(); + const downloadUrl = `${baseAddress}${nameLower}/${verLower}/${nameLower}.${verLower}.nupkg`; + + const nupkgPath = path.join(tempDir, `${artifact.name}.${artifact.version}.nupkg`); + console.log(`[${SCRIPT}] Downloading ${artifact.name} ${artifact.version} from ${feedHost}...`); + await downloadToFile(downloadUrl, nupkgPath); + + const zip = new AdmZip(nupkgPath); + const entries = nativeEntriesForRid(zip, rid, ext); + if (entries.length === 0) { + console.warn(`[${SCRIPT}] No files found for RID ${rid} in ${artifact.name}.`); + } + for (const entry of entries) { + zip.extractEntryTo(entry, binDir, false, true); + console.log(`[${SCRIPT}] Extracted ${entry.name}`); + } + return; + } catch (err) { + lastError = err; + const reason = err instanceof Error ? err.message : String(err); + if (i < FEEDS.length - 1) { + console.warn(`[${SCRIPT}] ${artifact.name} ${artifact.version}: download from ${feedHost} failed (${reason}); trying next feed...`); + } + } + } + const feeds = FEEDS.map(f => new URL(f).host).join(', '); + throw new Error(`[${SCRIPT}] Failed to download ${artifact.name} ${artifact.version} from any feed (${feeds}): ${lastError instanceof Error ? lastError.message : lastError}`); +} + +/** + * The native shared-library entries in `zip` for `rid`: files ending in `ext` + * that live under `runtimes//native/` or directly under `runtimes//`. + */ +function nativeEntriesForRid(zip: any, rid: string, ext: string): any[] { + const nativePrefix = `runtimes/${rid}/native/`.toLowerCase(); + const runtimePrefix = `runtimes/${rid}/`.toLowerCase(); + return zip.getEntries().filter((e: any) => { + const p = String(e.entryName).replace(/\\/g, '/').toLowerCase(); + if (!p.endsWith(ext.toLowerCase())) { + return false; + } + if (p.startsWith(nativePrefix)) { + return true; + } + if (p.startsWith(runtimePrefix)) { + const rest = p.slice(runtimePrefix.length); + return rest.length > 0 && !rest.includes('/'); + } + return false; + }); +} + +/** Resolve a NuGet feed's `PackageBaseAddress/3.0.0` service endpoint. */ +async function getBaseAddress(feedUrl: string, cache: Map): Promise { + if (!cache.has(feedUrl)) { + cache.set(feedUrl, JSON.parse(await downloadToString(feedUrl))); + } + const index = cache.get(feedUrl) as { resources?: { '@type'?: string; '@id'?: string }[] }; + const res = (index.resources ?? []).find(r => typeof r['@type'] === 'string' && r['@type']!.startsWith('PackageBaseAddress/3.0.0')); + if (!res?.['@id']) { + throw new Error(`[${SCRIPT}] Could not find PackageBaseAddress/3.0.0 in NuGet feed ${feedUrl}.`); + } + return res['@id']!.endsWith('/') ? res['@id']! : `${res['@id']}/`; +} + +/** GET `url` following redirects, resolving with the response body as a string. */ +function downloadToString(url: string): Promise { + return followRedirects(url, res => new Promise((resolve, reject) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => resolve(data)); + res.on('error', reject); + })); +} + +/** GET `url` following redirects, streaming the response body into `dest`. */ +function downloadToFile(url: string, dest: string): Promise { + return followRedirects(url, res => new Promise((resolve, reject) => { + const file = fs.createWriteStream(dest); + res.pipe(file); + file.on('finish', () => file.close(err => err ? reject(err) : resolve())); + file.on('error', err => { fs.rmSync(dest, { force: true }); reject(err); }); + res.on('error', reject); + })); +} + +/** Issue a GET, following up to 5 redirects, then hand the 200 response to `onOk`. */ +function followRedirects(url: string, onOk: (res: import('http').IncomingMessage) => Promise): Promise { + return new Promise((resolve, reject) => { + const request = (currentUrl: string, redirectsLeft: number): void => { + https.get(currentUrl, res => { + const status = res.statusCode ?? 0; + if (status >= 300 && status < 400 && res.headers.location) { + res.resume(); + if (redirectsLeft <= 0) { + reject(new Error(`Too many redirects downloading ${url}.`)); + return; + } + request(new URL(res.headers.location, currentUrl).toString(), redirectsLeft - 1); + return; + } + if (status !== 200) { + res.resume(); + reject(new Error(`Download failed with status ${status}: ${currentUrl}`)); + return; + } + onOk(res).then(resolve, reject); + }).on('error', reject); + }; + request(url, 5); + }); +} diff --git a/build/dictation-runtime/package.ts b/build/dictation-runtime/package.ts new file mode 100644 index 00000000000000..e4a624efab8f91 --- /dev/null +++ b/build/dictation-runtime/package.ts @@ -0,0 +1,192 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Builds one per-target tarball of the Foundry Local native runtime (the + * prebuilt N-API addon + the Foundry Local Core / onnxruntime / onnxruntime-genai + * shared libraries). Callable as both a Node library (`buildOne(...)`) and a thin + * CLI (bottom of this file). + * + * The library form is what `produce.ts` calls during the per-platform + * "Dictation runtime: build + upload" pipeline step; the CLI form is for local + * one-off builds. + * + * The addon is copied from the pinned `foundry-local-sdk` package's `prebuilds/` + * (which ships every target), and the core libraries are fetched from NuGet for + * the requested target's RID via `fetchCoreLibraries` (NOT the SDK's host-locked + * installer), so ANY build host can produce ANY target's tarball. This is what + * lets VS Code's ARM64 desktop builds — which run on x64 pools — publish their + * `linux-arm64`/`win32-arm64` runtimes. + * + * The produced tarball's internal layout mirrors the runtime cache layout so the + * runtime extraction is a plain untar: + * + * prebuilds//foundry_local_napi.node + * foundry-local-core// + */ + +import { createRequire } from 'module'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as tar from 'tar'; +import { getRuntimeVersion, parseFlags, SDK_PACKAGE_NAME, sha256OfFile, SUPPORTED_TARGETS } from './common.ts'; +import { fetchCoreLibraries } from './nuget.ts'; + +const SCRIPT = 'package.ts'; + +/** Resolve `foundry-local-sdk` subpaths from the repo-root `node_modules`. */ +const sdkRequire = createRequire(import.meta.url); + +export interface IBuildResult { + readonly tgzPath: string; + readonly sha256: string; + readonly version: string; + readonly sizeBytes: number; +} + +export interface IBuildArgs { + readonly target: string; + readonly outDir: string; +} + +/** + * Build one runtime tarball for `args.target`. Copies the prebuilt addon from + * the installed SDK, fetches the matching core libraries via the SDK's NuGet + * installer, and tars both into a single gzipped tarball. Returns the produced + * `.tgz` path and its sha256. + */ +export async function buildOne(args: IBuildArgs): Promise { + if (!SUPPORTED_TARGETS.has(args.target)) { + throw new Error(`[${SCRIPT}] Unknown target '${args.target}'. Supported: ${[...SUPPORTED_TARGETS].join(', ')}.`); + } + + const version = getRuntimeVersion(); + const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dictation-runtime-pkg-')); + try { + console.log(`[${SCRIPT}] Building ${SDK_PACKAGE_NAME}@${version} native runtime for ${args.target} in ${stagingDir}`); + + await stageAddon(stagingDir, args.target); + await stageCoreLibraries(stagingDir, args.target); + + fs.mkdirSync(args.outDir, { recursive: true }); + const tgzPath = path.join(args.outDir, `${args.target}.tgz`); + await buildTarball(stagingDir, tgzPath); + + const sha256 = await sha256OfFile(tgzPath); + const sizeBytes = fs.statSync(tgzPath).size; + + console.log(`[${SCRIPT}] Wrote ${tgzPath} (${sizeBytes} bytes, sha256=${sha256})`); + return { tgzPath, sha256, version, sizeBytes }; + } finally { + fs.rmSync(stagingDir, { recursive: true, force: true }); + } +} + +/** + * Copy the prebuilt N-API addon for `target` out of the installed + * `foundry-local-sdk` package (`prebuilds//foundry_local_napi.node`) + * into the staging tree. + */ +async function stageAddon(stagingDir: string, target: string): Promise { + const sdkRoot = path.dirname(sdkRequire.resolve(`${SDK_PACKAGE_NAME}/package.json`)); + const addonSrc = path.join(sdkRoot, 'prebuilds', target, 'foundry_local_napi.node'); + if (!fs.existsSync(addonSrc)) { + throw new Error(`[${SCRIPT}] Prebuilt addon not found for ${target} at ${addonSrc}. Is ${SDK_PACKAGE_NAME} installed?`); + } + const addonDest = path.join(stagingDir, 'prebuilds', target, 'foundry_local_napi.node'); + fs.mkdirSync(path.dirname(addonDest), { recursive: true }); + fs.copyFileSync(addonSrc, addonDest); +} + +/** + * Fetch the Foundry Local core libraries for `target` into the staging tree from + * NuGet, for that target's explicit RID (see `fetchCoreLibraries`). Replicates + * the standard variant's artifact selection, including the linux-x64 GPU ONNX + * Runtime package. Host-independent — `target` need not match the build host. + */ +async function stageCoreLibraries(stagingDir: string, target: string): Promise { + const deps = sdkRequire(`${SDK_PACKAGE_NAME}/deps_versions.json`) as { + 'foundry-local-core': { nuget: string }; + onnxruntime: { version: string }; + 'onnxruntime-genai': { version: string }; + }; + + // Microsoft.ML.OnnxRuntime.Gpu.Linux only ships x86_64 native binaries, so + // linux-arm64 (and every non-linux-x64 target) uses the cross-platform + // Foundry ORT package. Mirrors `ensureCoreLibraries` in the runtime. + const ortPackageName = target === 'linux-x64' ? 'Microsoft.ML.OnnxRuntime.Gpu.Linux' : 'Microsoft.ML.OnnxRuntime.Foundry'; + + const artifacts = [ + { name: 'Microsoft.AI.Foundry.Local.Core', version: deps['foundry-local-core'].nuget }, + { name: ortPackageName, version: deps.onnxruntime.version }, + { name: 'Microsoft.ML.OnnxRuntimeGenAI.Foundry', version: deps['onnxruntime-genai'].version }, + ]; + + const coreDir = path.join(stagingDir, 'foundry-local-core', target); + await fetchCoreLibraries(target, artifacts, coreDir); + + for (const name of requiredCoreLibraryNames(target)) { + if (!fs.existsSync(path.join(coreDir, name))) { + throw new Error(`[${SCRIPT}] Core library '${name}' missing after install for ${target} — refusing to build an incomplete tarball.`); + } + } +} + +/** The core library filenames required for `target`. Mirrors the runtime gate. */ +function requiredCoreLibraryNames(target: string): string[] { + const isWin = target.startsWith('win32-'); + const isMac = target.startsWith('darwin-'); + const ext = isWin ? '.dll' : isMac ? '.dylib' : '.so'; + const prefix = isWin ? '' : 'lib'; + return [ + `Microsoft.AI.Foundry.Local.Core${ext}`, + `${prefix}onnxruntime${ext}`, + `${prefix}onnxruntime-genai${ext}`, + ]; +} + +/** + * Build the gzipped tar via node-tar so the output is consistent regardless of + * which host's system tar would otherwise be used. `portable`/`mtime` strip + * host-specific metadata for reproducible bytes across re-runs on the same host. + */ +async function buildTarball(stagingDir: string, outTgz: string): Promise { + await tar.c( + { + file: outTgz, + cwd: stagingDir, + gzip: { level: 9 }, + portable: true, + mtime: new Date(0), + }, + ['prebuilds', 'foundry-local-core'], + ); +} + +// #region CLI entry point + +function isCliInvocation(): boolean { + return import.meta.filename === process.argv[1]; +} + +function parseCliArgs(): IBuildArgs { + const flags = parseFlags(process.argv.slice(2)); + const target = flags.get('target'); + if (!target) { + throw new Error('--target= is required (e.g. --target=darwin-arm64)'); + } + const outDir = flags.get('out') ?? path.resolve(process.cwd(), 'out'); + return { target, outDir }; +} + +if (isCliInvocation()) { + buildOne(parseCliArgs()).catch(err => { + console.error(err); + process.exit(1); + }); +} + +// #endregion diff --git a/build/dictation-runtime/produce.ts b/build/dictation-runtime/produce.ts new file mode 100644 index 00000000000000..561637bb90f7dc --- /dev/null +++ b/build/dictation-runtime/produce.ts @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Per-(vscode-platform, arch) dictation-runtime producer. On publish builds it + * assembles + uploads this target's tarball to the CDN, and on ANY pipeline + * build it writes a small JSON results file that the gulpfile-side `packageTask` + * reads to stamp `product.json`'s `dictationRuntime` field. + * + * Run as a pipeline step BEFORE the gulp packaging step on the same agent (via + * `build/azure-pipelines/common/dictation-runtime-produce.yml`). + * + * Two independent axes: + * + * - STAMP (`product.dictationRuntime = {version, urlTemplate}`): written for + * every build that can host dictation (`shouldStampRuntime`), regardless of + * `VSCODE_PUBLISH`. The stamp is target-agnostic (the runtime resolves + * `{target}` per launch), so packaged non-publish builds — whose native + * payload is stripped at packaging time — still get a usable CDN source, and + * the Universal macOS x64 slice carries the same config as its arm64 sibling. + * Only local dev-from-source (which never runs this script) ships without the + * stamp and falls back to the SDK's `node_modules` payload. + * + * - PAYLOAD (the `.tgz` on the CDN): built + uploaded only on publish + * runs (`VSCODE_PUBLISH=true`) and only for a build whose `(platform, arch)` + * has a real target (so `darwin-x64` stamps but uploads nothing — the + * `darwin-arm64` job publishes the shared Apple Silicon payload). Upload is + * idempotent (HEAD-then-decide), so re-runs and the once-per-version nature + * of the stamped URL stay consistent. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { + buildCdnUrlTemplate, + getRuntimeTargetForBuild, + getRuntimeVersion, + type IDictationRuntimeResult, + KNOWN_VSCODE_PLATFORMS, + parseFlags, + shouldStampRuntime, +} from './common.ts'; +import { buildOne } from './package.ts'; +import { uploadOne } from './upload.ts'; + +const SCRIPT = 'produce.ts'; + +interface IProduceArgs { + readonly vscodePlatform: string; + readonly arch: string; + readonly tarballsDir: string; + readonly resultsFile: string; + readonly upload: boolean; +} + +async function main(): Promise { + const args = parseArgs(); + fs.mkdirSync(args.tarballsDir, { recursive: true }); + + const version = getRuntimeVersion(); + const target = getRuntimeTargetForBuild(args.vscodePlatform, args.arch); + + // PAYLOAD: build + upload this target's tarball on publish runs only. + if (args.upload && target) { + console.log(`[${SCRIPT}] producing payload for ${args.vscodePlatform}/${args.arch} → ${target}`); + const built = await buildOne({ target, outDir: args.tarballsDir }); + await uploadOne({ version: built.version, target, tgzPath: built.tgzPath, sha256: built.sha256 }); + console.log(`##vso[task.setvariable variable=DICTATION_RUNTIME_TARBALLS_PRODUCED]true`); + } else if (target) { + console.log(`[${SCRIPT}] upload=false — skipping payload build/upload for ${target} (publish-only).`); + } else { + console.log(`[${SCRIPT}] no CDN payload for ${args.vscodePlatform}/${args.arch} (not a supported target).`); + } + + // STAMP: written on every pipeline build that can host dictation, publish or + // not — the stamp is target-agnostic and packaged builds have no other source. + if (!shouldStampRuntime(args.vscodePlatform, args.arch)) { + console.log(`[${SCRIPT}] ${args.vscodePlatform}/${args.arch} cannot host dictation; product.json ships without dictationRuntime.`); + return; + } + + const result: IDictationRuntimeResult = { version, urlTemplate: buildCdnUrlTemplate(version) }; + fs.mkdirSync(path.dirname(args.resultsFile), { recursive: true }); + fs.writeFileSync(args.resultsFile, JSON.stringify(result, null, 2) + '\n'); + console.log(`[${SCRIPT}] Wrote dictationRuntime entry to ${args.resultsFile}`); + + // Tell Azure Pipelines: subsequent steps in this job see + // DICTATION_RUNTIME_RESULTS_FILE in their env (auto-injected from the variable). + console.log(`##vso[task.setvariable variable=DICTATION_RUNTIME_RESULTS_FILE]${args.resultsFile}`); +} + +function parseArgs(): IProduceArgs { + const flags = parseFlags(process.argv.slice(2)); + const vscodePlatform = flags.get('vscode-platform'); + if (!vscodePlatform || !KNOWN_VSCODE_PLATFORMS.has(vscodePlatform)) { + throw new Error(`--vscode-platform must be one of ${[...KNOWN_VSCODE_PLATFORMS].join(', ')}; got '${vscodePlatform}'`); + } + const arch = flags.get('arch'); + if (!arch) { + throw new Error('--arch= is required'); + } + // Fail loud on a bad pin before doing any work. + getRuntimeVersion(); + + const tarballsDir = path.resolve(process.cwd(), '.build', 'dictation-runtime', 'tarballs'); + const resultsFile = process.env.DICTATION_RUNTIME_RESULTS_FILE + ?? path.resolve(process.cwd(), '.build', 'dictation-runtime', `${vscodePlatform}-${arch}.json`); + const upload = (process.env.VSCODE_PUBLISH ?? '').toLowerCase() === 'true'; + return { vscodePlatform, arch, tarballsDir, resultsFile, upload }; +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/build/dictation-runtime/upload.ts b/build/dictation-runtime/upload.ts new file mode 100644 index 00000000000000..9969bd583f2389 --- /dev/null +++ b/build/dictation-runtime/upload.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Uploads one dictation-runtime tarball to the `main.vscode-cdn.net` storage + * account. Callable as both a library function (`uploadOne(...)`) and a thin CLI. + * + * Auth: reads `AZURE_STORAGE_ACCOUNT`, `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, + * `AZURE_ID_TOKEN` from env — same shape as `build/agent-sdk/upload.ts`. + * + * Idempotency: HEAD-first on the `.tgz` blob. + * - Absent → upload. + * - Present with matching sha256 (in `metadata.sha256`) → skip. + * - Present with different / no sha256 metadata → fail loud, refusing to + * overwrite content-addressed history. Recovery: delete the blob in the + * Azure Portal and re-run. + */ + +import { ClientAssertionCredential } from '@azure/identity'; +import { BlobServiceClient } from '@azure/storage-blob'; +import * as fs from 'fs'; +import * as path from 'path'; +import { buildCdnUrl, parseFlags, RUNTIME_ID, SDK_PACKAGE_NAME, sha256OfFile, SUPPORTED_TARGETS } from './common.ts'; + +const SCRIPT = 'upload.ts'; + +export interface IUploadArgs { + readonly version: string; + readonly target: string; + readonly tgzPath: string; + /** Pre-computed sha (e.g. from `buildOne()`); if omitted, computed here. */ + readonly sha256?: string; +} + +export interface IUploadResult { + readonly url: string; + readonly sha256: string; +} + +export async function uploadOne(args: IUploadArgs): Promise { + if (!fs.existsSync(args.tgzPath)) { + throw new Error(`[${SCRIPT}] Tarball does not exist: ${args.tgzPath}`); + } + const sha256 = args.sha256 ?? await sha256OfFile(args.tgzPath); + + const account = requireEnv('AZURE_STORAGE_ACCOUNT'); + const tenantId = requireEnv('AZURE_TENANT_ID'); + const clientId = requireEnv('AZURE_CLIENT_ID'); + const idToken = requireEnv('AZURE_ID_TOKEN'); + + const credential = new ClientAssertionCredential(tenantId, clientId, () => Promise.resolve(idToken)); + const service = new BlobServiceClient(`https://${account}.blob.core.windows.net`, credential); + const container = service.getContainerClient('$web'); + const blobName = `dictation-runtime/${RUNTIME_ID}/${args.version}/${args.target}.tgz`; + const blob = container.getBlockBlobClient(blobName); + + console.log(`[${SCRIPT}] target: https://${account}.blob.core.windows.net/$web/${blobName}`); + console.log(`[${SCRIPT}] local sha256: ${sha256}`); + + let existing; + try { + existing = await blob.getProperties(); + } catch (err) { + const status = (err as { statusCode?: number }).statusCode; + if (status !== 404) { + throw err; + } + existing = undefined; + } + + if (existing) { + const remoteSha = existing.metadata?.sha256; + if (remoteSha === sha256) { + console.log(`[${SCRIPT}] blob already present with matching sha256 — skipping upload (idempotent).`); + return { url: buildCdnUrl(args.version, args.target), sha256 }; + } + throw new Error( + `[${SCRIPT}] Blob already present with ${remoteSha ? 'DIFFERENT' : 'NO'} sha256 metadata — refusing to overwrite content-addressed history.\n` + + ` remote: ${remoteSha ?? ''}\n` + + ` local: ${sha256}\n` + + `If the local build is what should ship, delete the remote blob in Azure Portal and re-run. ` + + `Otherwise: investigate why the same ${SDK_PACKAGE_NAME}@${args.version} produced different bytes for ${args.target}.`, + ); + } + + console.log(`[${SCRIPT}] uploading ${fs.statSync(args.tgzPath).size} bytes…`); + await blob.uploadFile(args.tgzPath, { + blobHTTPHeaders: { + blobContentType: 'application/gzip', + blobCacheControl: 'max-age=31536000, immutable', + }, + metadata: { sha256 }, + }); + console.log(`[${SCRIPT}] ✓ uploaded.`); + return { url: buildCdnUrl(args.version, args.target), sha256 }; +} + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`[${SCRIPT}] Missing required environment variable: ${name}`); + } + return value; +} + +// #region CLI entry point + +function isCliInvocation(): boolean { + return import.meta.filename === process.argv[1]; +} + +function parseCliArgs(): IUploadArgs { + const flags = parseFlags(process.argv.slice(2)); + const tgzPath = flags.get('tarball'); + if (!tgzPath) { throw new Error('--tarball= is required'); } + const version = flags.get('version'); + if (!version) { throw new Error('--version= is required'); } + // Filename convention from package.ts: `.tgz`. + const target = path.basename(tgzPath).replace(/\.tgz$/, ''); + if (!SUPPORTED_TARGETS.has(target)) { + throw new Error(`Cannot derive a known target from tarball filename '${path.basename(tgzPath)}'`); + } + return { version, target, tgzPath: path.resolve(tgzPath) }; +} + +if (isCliInvocation()) { + uploadOne(parseCliArgs()).catch(err => { + console.error(err); + process.exit(1); + }); +} + +// #endregion diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 9a367085199b03..38c142d99ec50b 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -31,6 +31,7 @@ import { copyCodiconsTask } from './lib/compilation.ts'; import { ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getMxcExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; import { ensureOSProxyResolverPlatformPackage, getOSProxyResolverExcludeFilter, getOSProxyResolverPlatformFiles } from './lib/osProxyResolver.ts'; import { readAgentSdkResults } from './agent-sdk/common.ts'; +import { readDictationRuntimeResults } from './dictation-runtime/common.ts'; import { useEsbuildTranspile } from './buildConfig.ts'; import { promisify } from 'util'; import globCallback from 'glob'; @@ -329,6 +330,13 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d if (Object.keys(agentSdks).length > 0) { json.agentSdks = agentSdks; } + // Stamp dictationRuntime from the per-platform results file + // produced by `build/dictation-runtime/produce.ts`. Local dev / + // unsupported target: file absent → undefined → not stamped. + const dictationRuntime = readDictationRuntimeResults(); + if (dictationRuntime) { + json.dictationRuntime = dictationRuntime; + } return json; })) .pipe(es.through(function (file) { diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 5af093a6519bac..46881e33054155 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -83,6 +83,19 @@ export interface IAgentSdkProductConfig { readonly urlTemplate: string; } +/** + * Configuration for downloading the on-device dictation native runtime (the + * Foundry Local addon + core libraries) on demand. Produced per platform build + * and stamped by `build/dictation-runtime/produce.ts`; consumed by + * `foundryLocalRuntime.ts`, which substitutes `{target}` in `urlTemplate` + * against the host's `-` key. Absent in local dev builds, in + * which case the runtime falls back to the SDK's own `node_modules` payload. + */ +export interface IDictationRuntimeProductConfig { + readonly version: string; + readonly urlTemplate: string; +} + export interface IProductConfiguration { readonly version: string; readonly date?: string; @@ -139,6 +152,8 @@ export interface IProductConfiguration { readonly agentSdks?: { readonly [packageId: string]: IAgentSdkProductConfig }; + readonly dictationRuntime?: IDictationRuntimeProductConfig; + readonly mcpGallery?: { readonly serviceUrl: string; readonly itemWebUrl: string; diff --git a/src/vs/platform/localTranscription/common/localTranscription.ts b/src/vs/platform/localTranscription/common/localTranscription.ts index becccdfdc48c8c..8c696d940f20e9 100644 --- a/src/vs/platform/localTranscription/common/localTranscription.ts +++ b/src/vs/platform/localTranscription/common/localTranscription.ts @@ -114,8 +114,14 @@ export interface ILocalTranscriptionService { * authenticate to the proxy. TLS-intercepting proxies otherwise rely on the CA * being in the OS trust store (matching `@vscode/proxy-agent` and the desktop * app). + * + * `runtimeUrlTemplate`/`runtimeVersion` come from `product.dictationRuntime` + * (stamped by `build/dictation-runtime/produce.ts`). When set, the native + * runtime (Foundry Local addon + core libraries) is downloaded from VS Code's + * CDN for this host's target. When omitted (local dev builds), the runtime + * falls back to the SDK's own `node_modules` payload and nothing is downloaded. */ - start(options: { readonly cacheDir: string; readonly model?: string; readonly language?: string; readonly proxyUrl?: string; readonly noProxy?: string; readonly proxyStrictSSL?: boolean; readonly proxyAuthorization?: string }): Promise; + start(options: { readonly cacheDir: string; readonly model?: string; readonly language?: string; readonly proxyUrl?: string; readonly noProxy?: string; readonly proxyStrictSSL?: boolean; readonly proxyAuthorization?: string; readonly runtimeUrlTemplate?: string; readonly runtimeVersion?: string }): Promise; /** Append captured audio (raw little-endian PCM16 mono 16 kHz). */ pushAudio(chunk: VSBuffer): Promise; diff --git a/src/vs/platform/localTranscription/node/foundryLocalRuntime.ts b/src/vs/platform/localTranscription/node/foundryLocalRuntime.ts index ca43b5283ac8c6..5a268ab7e508c3 100644 --- a/src/vs/platform/localTranscription/node/foundryLocalRuntime.ts +++ b/src/vs/platform/localTranscription/node/foundryLocalRuntime.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import * as os from 'os'; import { dirname, join } from '../../../base/common/path.js'; +import { format2 } from '../../../base/common/strings.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; @@ -14,31 +15,31 @@ import { CancellationError } from '../../../base/common/errors.js'; * dictation. * * `foundry-local-sdk` ships a prebuilt N-API addon (`foundry_local_napi.node`) - * and downloads native core libraries (Foundry Local Core + ONNX Runtime + - * ONNX Runtime GenAI) next to it. The addon requires a newer glibc than VS - * Code's minimum supported Linux distros, so we deliberately do NOT bundle any - * of this native payload with the product (see `build/gulpfile.vscode.ts`). - * Instead we download it here, at runtime, only on supported platforms, into a - * per-user writable cache — keeping the shipped package's glibc floor intact. + * and native core libraries (Foundry Local Core + ONNX Runtime + ONNX Runtime + * GenAI). The addon requires a newer glibc than VS Code's minimum supported + * Linux distros, so we deliberately do NOT bundle any of this native payload + * with the product (see `build/gulpfile.vscode.ts`). Instead we republish a + * per-target tarball of the addon + core libraries to VS Code's CDN at build + * time (see `build/dictation-runtime/`) and download it here, at runtime, only + * on supported platforms, into a per-user writable cache — keeping the shipped + * package's glibc floor intact and avoiding any runtime dependency on the npm + * registry or NuGet. * * The SDK loader (`dist/detail/coreInterop.js`) is patched during * `postinstall` to honor `VSCODE_FOUNDRY_LOCAL_NATIVE_DIR`, pointing it at the - * cache directory this module populates. The cache layout mirrors the SDK's - * own package layout so the patched resolution is a trivial path join: + * cache directory this module populates. The tarball's internal layout mirrors + * the SDK's own package layout so the patched resolution is a trivial path join: * - * //prebuilds//foundry_local_napi.node - * //foundry-local-core// + * //prebuilds//foundry_local_napi.node + * //foundry-local-core// * - * NOTE: the two JavaScript download legs — the npm-registry addon tarball and - * the NuGet core-library fetch (via the SDK's own installer) — honor the - * standard proxy environment variables (`HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY`, - * with `NO_PROXY`), matching how the GitHub desktop app's Rust - * `foundry-local-sdk` reaches these endpoints. VS Code's `http.proxy`/ - * `http.noProxy` settings are applied as these same environment variables - * before provisioning (see `LocalTranscriptionService.start`), so a proxy - * configured only in VS Code is honored here and by the native model download - * too; `http.proxyAuthorization` (Basic) is folded into the proxy URL and - * `http.proxyStrictSSL === false` disables TLS verification for these Node legs. + * NOTE: the single CDN download leg honors the standard proxy environment + * variables (`HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY`, with `NO_PROXY`). VS Code's + * `http.proxy`/`http.noProxy` settings are applied as these same environment + * variables before provisioning (see `LocalTranscriptionService.start`), so a + * proxy configured only in VS Code is honored here and by the native model + * download too; `http.proxyAuthorization` (Basic) is folded into the proxy URL + * and `http.proxyStrictSSL === false` disables TLS verification for this leg. * TLS-intercepting proxies otherwise rely on the CA being in the OS trust store. */ @@ -68,6 +69,14 @@ export function isFoundryLocalRuntimeSupported(): boolean { /** Progress callback invoked while the native runtime is being fetched. */ export type FoundryLocalRuntimeProgress = (message: string) => void; +/** Where the native runtime tarball is published (from `product.dictationRuntime`). */ +export interface IFoundryLocalRuntimeDownload { + /** CDN URL template with a `{target}` placeholder for the host platform key. */ + readonly urlTemplate: string; + /** The published runtime version (the pinned `foundry-local-sdk` version). */ + readonly version: string; +} + /** De-dupes concurrent provisioning requests targeting the same cache dir. */ const inFlight = new Map>(); @@ -76,37 +85,34 @@ const DOWNLOAD_INACTIVITY_TIMEOUT_MS = 60_000; /** * Ensure the Foundry Local native runtime (addon + core libraries) is present - * in ``, downloading it if necessary. Returns the versioned override - * directory to set as `VSCODE_FOUNDRY_LOCAL_NATIVE_DIR` before loading the SDK. + * in ``, downloading the per-target CDN tarball if necessary. Returns + * the versioned override directory to set as `VSCODE_FOUNDRY_LOCAL_NATIVE_DIR` + * before loading the SDK. * * Idempotent: once a version is fully provisioned a per-platform `.complete` * marker is written and subsequent calls return immediately (after verifying the * payload) without touching the network. */ -export async function ensureFoundryLocalRuntime(cacheRoot: string, token: CancellationToken, onProgress?: FoundryLocalRuntimeProgress): Promise { +export async function ensureFoundryLocalRuntime(cacheRoot: string, download: IFoundryLocalRuntimeDownload, token: CancellationToken, onProgress?: FoundryLocalRuntimeProgress): Promise { const platformKey = foundryLocalPlatformKey(); if (!platformKey) { throw new Error(`Foundry Local native runtime is not available on ${process.platform}-${process.arch}.`); } - const nodeRequire = await getNativeRequire(); - const sdkVersion: string = nodeRequire('foundry-local-sdk/package.json').version; - const overrideDir = join(cacheRoot, sdkVersion); + const overrideDir = join(cacheRoot, download.version); // A single in-flight provisioning per override dir; late joiners share it. const existing = inFlight.get(overrideDir); if (existing) { return existing; } - const promise = doEnsure(overrideDir, platformKey, sdkVersion, nodeRequire, token, onProgress) + const promise = doEnsure(overrideDir, platformKey, download, token, onProgress) .finally(() => inFlight.delete(overrideDir)); inFlight.set(overrideDir, promise); return promise; } -async function doEnsure(overrideDir: string, platformKey: string, sdkVersion: string, nodeRequire: NodeJS.Require, token: CancellationToken, onProgress?: FoundryLocalRuntimeProgress): Promise { - const addonPath = foundryAddonPath(overrideDir, platformKey); - const coreDir = foundryCoreDir(overrideDir, platformKey); +async function doEnsure(overrideDir: string, platformKey: string, download: IFoundryLocalRuntimeDownload, token: CancellationToken, onProgress?: FoundryLocalRuntimeProgress): Promise { // The completion marker is per-platform: the shared `/` // dir can hold payloads for multiple architectures (e.g. a win32-arm64 // machine running x64 VS Code under emulation, then arm64 VS Code). Verify @@ -124,23 +130,36 @@ async function doEnsure(overrideDir: string, platformKey: string, sdkVersion: st assertRuntimeLoadable(platformKey); onProgress?.('Downloading dictation runtime…'); + await provisionRuntime(overrideDir, platformKey, download.urlTemplate, download.version, token); + return overrideDir; +} + +/** + * Download the `{target}`-substituted CDN tarball for `platformKey` into + * `overrideDir`, extract + verify + atomically promote its payload, and write the + * per-platform completion marker. Host-independent (does NOT run the glibc + * loadability gate); exported for tests. Callers that provision for the running + * host should use `ensureFoundryLocalRuntime`, which gates and de-dupes. + */ +export async function provisionRuntime(overrideDir: string, platformKey: string, urlTemplate: string, version: string, token: CancellationToken): Promise { + const addonPath = foundryAddonPath(overrideDir, platformKey); + const coreDir = foundryCoreDir(overrideDir, platformKey); // The cache is shared by the utility processes of every open VS Code window, // so provision into a process-unique staging dir and atomically promote each // payload directory into place. Two concurrent first-use downloads therefore // never write to the same final path; whichever process wins the rename is // the published copy and the loser accepts it as success. + const url = format2(urlTemplate, { target: platformKey }); const staging = join(overrideDir, `.staging-${process.pid}-${randomSuffix()}`); const stagingAddon = join(staging, 'prebuilds', platformKey, 'foundry_local_napi.node'); const stagingCore = join(staging, 'foundry-local-core', platformKey); try { - await ensureAddon(stagingAddon, platformKey, sdkVersion, token); - throwIfCancelled(token); - await ensureCoreLibraries(stagingCore, nodeRequire); + await downloadAndExtractTarball(url, staging, token); throwIfCancelled(token); if (!fs.existsSync(stagingAddon) || !hasAllCoreLibraries(stagingCore)) { - throw new Error('Foundry Local native runtime download completed but expected files are missing.'); + throw new Error(`Foundry Local native runtime download from ${url} completed but expected files are missing.`); } await promoteDir(dirname(stagingAddon), dirname(addonPath)); @@ -154,8 +173,7 @@ async function doEnsure(overrideDir: string, platformKey: string, sdkVersion: st throw new Error('Foundry Local native runtime is incomplete after provisioning.'); } - await fs.promises.writeFile(foundryMarkerPath(overrideDir, platformKey), `${sdkVersion}\n`).catch(() => { /* best effort marker */ }); - return overrideDir; + await fs.promises.writeFile(foundryMarkerPath(overrideDir, platformKey), `${version}\n`).catch(() => { /* best effort marker */ }); } /** Path of the per-platform completion marker inside a versioned override dir. */ @@ -246,71 +264,28 @@ function detectGlibcVersion(): [number, number] | undefined { } /** - * Download the prebuilt N-API addon for `platformKey` from the pinned - * `foundry-local-sdk` npm tarball and place it at `addonPath`. + * Download the per-target runtime tarball from `url` and extract it into + * `stagingDir`, which then contains `prebuilds//foundry_local_napi.node` + * and `foundry-local-core//` (the tarball's layout + * mirrors the cache layout). The tarball is published to VS Code's CDN by + * `build/dictation-runtime/`. */ -async function ensureAddon(addonPath: string, platformKey: string, sdkVersion: string, token: CancellationToken): Promise { - const tarballUrl = `https://registry.npmjs.org/foundry-local-sdk/-/foundry-local-sdk-${sdkVersion}.tgz`; - const entryName = `package/prebuilds/${platformKey}/foundry_local_napi.node`; - - const tmpDir = await fs.promises.mkdtemp(join(os.tmpdir(), 'vscode-foundry-addon-')); +async function downloadAndExtractTarball(url: string, stagingDir: string, token: CancellationToken): Promise { + await fs.promises.mkdir(stagingDir, { recursive: true }); + const tmpDir = await fs.promises.mkdtemp(join(os.tmpdir(), 'vscode-foundry-runtime-')); try { - const tarballPath = join(tmpDir, 'sdk.tgz'); - await downloadFile(tarballUrl, tarballPath, token); + const tarballPath = join(tmpDir, 'runtime.tgz'); + await downloadFile(url, tarballPath, token); throwIfCancelled(token); - // npm tarballs are gzip'd tar; extract only the single addon we need. // `tar` is a node_modules package, so it must be imported dynamically. const tar = await import('tar'); - await tar.x({ file: tarballPath, cwd: tmpDir, filter: p => p.replace(/\\/g, '/') === entryName }); - - const extracted = join(tmpDir, entryName); - if (!fs.existsSync(extracted)) { - throw new Error(`Foundry Local addon for ${platformKey} not found in ${tarballUrl}.`); - } - - await fs.promises.mkdir(dirname(addonPath), { recursive: true }); - // Publish atomically: copy to a sibling temp file, then rename into place. - const stagingPath = `${addonPath}.download`; - await fs.promises.copyFile(extracted, stagingPath); - await fs.promises.rename(stagingPath, addonPath); + await tar.x({ file: tarballPath, cwd: stagingDir }); } finally { await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => { /* best effort */ }); } } -/** - * Download the Foundry Local core libraries into `coreDir` by reusing the SDK's - * own NuGet installer (`script/install-utils.cjs`), targeted at our cache with - * its `binDir` option. Replicates the standard variant's artifact selection - * (`script/install-standard.cjs`), including the linux-x64 GPU ORT package. - */ -async function ensureCoreLibraries(coreDir: string, nodeRequire: NodeJS.Require): Promise { - const deps = nodeRequire('foundry-local-sdk/deps_versions.json'); - const { runInstall } = nodeRequire('foundry-local-sdk/script/install-utils.cjs') as { - runInstall(artifacts: { name: string; version: string }[], options?: { binDir?: string }): Promise; - }; - - // Microsoft.ML.OnnxRuntime.Gpu.Linux only ships x86_64 native binaries, so - // linux-arm64 falls back to the cross-platform Foundry ORT package. - const isLinuxX64 = process.platform === 'linux' && process.arch === 'x64'; - const ortPackageName = isLinuxX64 ? 'Microsoft.ML.OnnxRuntime.Gpu.Linux' : 'Microsoft.ML.OnnxRuntime.Foundry'; - - const artifacts = [ - { name: 'Microsoft.AI.Foundry.Local.Core', version: deps['foundry-local-core'].nuget }, - { name: ortPackageName, version: deps.onnxruntime.version }, - { name: 'Microsoft.ML.OnnxRuntimeGenAI.Foundry', version: deps['onnxruntime-genai'].version }, - ]; - - await fs.promises.mkdir(coreDir, { recursive: true }); - const restoreProxy = await applyGlobalProxyForNuget(); - try { - await runInstall(artifacts, { binDir: coreDir }); - } finally { - restoreProxy(); - } -} - /** The core library filenames required for the current platform. Exported for tests. */ export function requiredCoreLibraryNames(): string[] { const ext = process.platform === 'win32' ? '.dll' : process.platform === 'darwin' ? '.dylib' : '.so'; @@ -385,32 +360,13 @@ async function resolveProxyAgent(targetUrl: string): Promise void> { - const agent = await resolveProxyAgent('https://api.nuget.org/'); - if (!agent) { - return () => { /* nothing to restore */ }; - } - const https = await import('https'); - const previous = https.globalAgent; - // `HttpsProxyAgent` extends `http.Agent`, not `https.Agent`; the cast only - // satisfies the field's declared type. - https.globalAgent = agent as unknown as typeof previous; - return () => { - https.globalAgent = previous; - }; -} - /** Download `url` to `dest`, following redirects, honoring cancellation. */ async function downloadFile(url: string, dest: string, token: CancellationToken): Promise { - // `https` is a slow-to-load builtin; import it lazily at runtime. - const https = await import('https'); + // `http`/`https` are slow-to-load builtins; import them lazily at runtime. + // The CDN is always `https:`; `http:` support exists only so provisioning can + // be exercised against a local test server. + const [https, http] = await Promise.all([import('https'), import('http')]); + const getFor = (u: string) => (new URL(u).protocol === 'http:' ? http.get : https.get); // A single proxy agent tunnels to whatever host each request (including any // redirect target) addresses, so resolving it once from the initial URL is // sufficient. `undefined` means "go direct". @@ -454,7 +410,7 @@ async function downloadFile(url: string, dest: string, token: CancellationToken) return; } armTimeout(); - activeRequest = https.get(currentUrl, { agent }, response => { + activeRequest = getFor(currentUrl)(currentUrl, { agent }, response => { armTimeout(); const status = response.statusCode ?? 0; if (status >= 300 && status < 400 && response.headers.location) { @@ -488,20 +444,3 @@ function throwIfCancelled(token: CancellationToken): void { throw new CancellationError(); } } - -let cachedNativeRequire: NodeJS.Require | undefined; -/** - * A CommonJS `require` bound to this module for loading `foundry-local-sdk`'s - * package metadata and its NuGet installer at runtime. `foundry-local-sdk` has - * no `exports` map, so its subpaths resolve directly; it is kept external from - * the bundle (loaded from `node_modules`) like the SDK's own dynamic import. - * Uses a dynamic `import('node:module')` so the `node:` specifier is resolved - * lazily at runtime rather than at bundle/load time. - */ -async function getNativeRequire(): Promise { - if (!cachedNativeRequire) { - const nodeModule = await import('node:module'); - cachedNativeRequire = nodeModule.createRequire(import.meta.url); - } - return cachedNativeRequire; -} diff --git a/src/vs/platform/localTranscription/node/localTranscriptionService.ts b/src/vs/platform/localTranscription/node/localTranscriptionService.ts index 51a9dbfec87e38..9f1ea6a0dadc0d 100644 --- a/src/vs/platform/localTranscription/node/localTranscriptionService.ts +++ b/src/vs/platform/localTranscription/node/localTranscriptionService.ts @@ -202,6 +202,12 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans /** In-flight (or resolved) model download+load for the selected model. */ private _modelPromise: Promise | undefined; + /** + * Where to download the native runtime from (product.dictationRuntime), or + * `undefined` in dev builds where the SDK's own node_modules payload is used. + */ + private _runtimeDownload: { urlTemplate: string; version: string } | undefined; + /** The active streaming session, once `start()` has opened it. */ private _session: LiveAudioTranscriptionSession | undefined; /** Resolves when the background stream consumer for `_session` has drained. */ @@ -262,13 +268,19 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans this._onDidChangeModelStatus.fire(status); } - async start(options: { cacheDir: string; model?: string; language?: string; proxyUrl?: string; noProxy?: string; proxyStrictSSL?: boolean; proxyAuthorization?: string }): Promise { + async start(options: { cacheDir: string; model?: string; language?: string; proxyUrl?: string; noProxy?: string; proxyStrictSSL?: boolean; proxyAuthorization?: string; runtimeUrlTemplate?: string; runtimeVersion?: string }): Promise { // Bridge VS Code's proxy settings into this process's environment before any // first-use download, so both our own fetches and the native Foundry Local // model download route through the configured proxy (they read the OS/env // proxy, not VS Code settings directly). this._applyProxyEnv(options.proxyUrl, options.noProxy, options.proxyStrictSSL, options.proxyAuthorization); + // Record where the native runtime is published (from product.json). When + // unset (dev builds), the SDK's own node_modules payload is used instead. + this._runtimeDownload = options.runtimeUrlTemplate && options.runtimeVersion + ? { urlTemplate: options.runtimeUrlTemplate, version: options.runtimeVersion } + : undefined; + // Reset any prior session before starting a new one. await this._disposeSession(); this._generation++; @@ -455,11 +467,16 @@ export class LocalTranscriptionService extends Disposable implements ILocalTrans // Ensure the Foundry Local native runtime (N-API addon + core // libraries) is available before loading the SDK. We do not ship // it — the addon requires a newer glibc than our minimum supported - // Linux distros — so it is downloaded on demand into a per-user - // cache and the SDK loader is pointed at it via env var. This is a - // no-op once cached. - const nativeDir = await ensureFoundryLocalRuntime(runtimeCacheDir(cacheDir), CancellationToken.None); - process.env.VSCODE_FOUNDRY_LOCAL_NATIVE_DIR = nativeDir; + // Linux distros — so in packaged builds it is downloaded on demand + // from VS Code's CDN (per `product.dictationRuntime`) into a + // per-user cache and the SDK loader is pointed at it via env var. + // This is a no-op once cached. In dev builds (no product config) + // the SDK resolves its addon + core libs from node_modules, so we + // skip provisioning and leave the loader on its default path. + if (this._runtimeDownload) { + const nativeDir = await ensureFoundryLocalRuntime(runtimeCacheDir(cacheDir), this._runtimeDownload, CancellationToken.None); + process.env.VSCODE_FOUNDRY_LOCAL_NATIVE_DIR = nativeDir; + } if (!this._sdk) { this._sdk = await import('foundry-local-sdk'); diff --git a/src/vs/platform/localTranscription/test/node/foundryLocalRuntime.test.ts b/src/vs/platform/localTranscription/test/node/foundryLocalRuntime.test.ts index 34f4bdf034fb7c..10253beb4b878d 100644 --- a/src/vs/platform/localTranscription/test/node/foundryLocalRuntime.test.ts +++ b/src/vs/platform/localTranscription/test/node/foundryLocalRuntime.test.ts @@ -5,7 +5,10 @@ import * as assert from 'assert'; import * as fs from 'fs'; +import type { AddressInfo } from 'net'; import { tmpdir } from 'os'; +import * as tar from 'tar'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { join } from '../../../../base/common/path.js'; import { Promises } from '../../../../base/node/pfs.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -16,6 +19,7 @@ import { isFoundryLocalRuntimeSupported, isRuntimeProvisioned, promoteDir, + provisionRuntime, requiredCoreLibraryNames, resolveProxyUrl, } from '../../node/foundryLocalRuntime.js'; @@ -140,4 +144,103 @@ flakySuite('FoundryLocalRuntime', () => { invalidUrl: undefined, }); }); + + // --- provisionRuntime: download + {target} substitution + extract + verify --- + + /** + * Build a runtime tarball fixture (`.tgz`) whose internal layout + * matches what `provisionRuntime` extracts and verifies. Set `omitCoreLib` + * to leave one required core library out (an incomplete/corrupt payload). + */ + async function makeTarball(key: string, opts?: { omitCoreLib?: boolean }): Promise { + const src = join(testDir, `src-${key}`); + fs.mkdirSync(join(src, 'prebuilds', key), { recursive: true }); + fs.writeFileSync(join(src, 'prebuilds', key, 'foundry_local_napi.node'), 'addon'); + const coreDir = join(src, 'foundry-local-core', key); + fs.mkdirSync(coreDir, { recursive: true }); + const libs = requiredCoreLibraryNames(); + for (const name of opts?.omitCoreLib ? libs.slice(1) : libs) { + fs.writeFileSync(join(coreDir, name), 'lib'); + } + const tgz = join(testDir, `${key}.tgz`); + await tar.c({ file: tgz, cwd: src, gzip: true }, ['prebuilds', 'foundry-local-core']); + return tgz; + } + + /** + * Serve `/` → the file at `files[name]` (200), everything else 404. + * Records requested paths so `{target}` substitution can be asserted. + */ + async function startServer(files: Record): Promise<{ url: string; requested: string[]; dispose: () => Promise }> { + const http = await import('http'); + const requested: string[] = []; + const server = http.createServer((req, res) => { + const name = (req.url ?? '').replace(/^\//, ''); + requested.push(name); + const file = files[name]; + if (!file || !fs.existsSync(file)) { + res.statusCode = 404; + res.end('not found'); + return; + } + res.statusCode = 200; + fs.createReadStream(file).pipe(res); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${port}`, + requested, + dispose: () => new Promise(resolve => server.close(() => resolve())), + }; + } + + test('provisionRuntime: substitutes {target}, extracts the expected layout, writes the marker', async () => { + const tgz = await makeTarball(platformKey); + const server = await startServer({ [`${platformKey}.tgz`]: tgz }); + try { + const overrideDir = join(testDir, '1.2.3'); + await provisionRuntime(overrideDir, platformKey, `${server.url}/{target}.tgz`, '1.2.3', CancellationToken.None); + + // {target} was substituted with the platform key in the request URL. + assert.deepStrictEqual(server.requested, [`${platformKey}.tgz`]); + // Payload extracted into the cache layout + completion marker written. + assert.strictEqual(fs.existsSync(join(overrideDir, 'prebuilds', platformKey, 'foundry_local_napi.node')), true); + for (const name of requiredCoreLibraryNames()) { + assert.strictEqual(fs.existsSync(join(overrideDir, 'foundry-local-core', platformKey, name)), true); + } + assert.strictEqual(isRuntimeProvisioned(overrideDir, platformKey), true); + } finally { + await server.dispose(); + } + }); + + test('provisionRuntime: rejects and writes no marker when the download 404s', async () => { + const server = await startServer({}); + try { + const overrideDir = join(testDir, '1.2.3'); + await assert.rejects( + provisionRuntime(overrideDir, platformKey, `${server.url}/{target}.tgz`, '1.2.3', CancellationToken.None), + /status 404/, + ); + assert.strictEqual(isRuntimeProvisioned(overrideDir, platformKey), false); + } finally { + await server.dispose(); + } + }); + + test('provisionRuntime: rejects an incomplete payload (missing core library) and writes no marker', async () => { + const tgz = await makeTarball(platformKey, { omitCoreLib: true }); + const server = await startServer({ [`${platformKey}.tgz`]: tgz }); + try { + const overrideDir = join(testDir, '1.2.3'); + await assert.rejects( + provisionRuntime(overrideDir, platformKey, `${server.url}/{target}.tgz`, '1.2.3', CancellationToken.None), + /expected files are missing/, + ); + assert.strictEqual(isRuntimeProvisioned(overrideDir, platformKey), false); + } finally { + await server.dispose(); + } + }); }); diff --git a/src/vs/platform/utilityProcess/common/utilityProcessWorkerService.ts b/src/vs/platform/utilityProcess/common/utilityProcessWorkerService.ts index dd55f49a2d4773..bddd4ba8e718b4 100644 --- a/src/vs/platform/utilityProcess/common/utilityProcessWorkerService.ts +++ b/src/vs/platform/utilityProcess/common/utilityProcessWorkerService.ts @@ -20,6 +20,15 @@ export interface IUtilityProcessWorkerProcess { * A human-readable name for the utility process. */ readonly name: string; + + /** + * On macOS, allows the utility process to load native libraries that are + * not signed by the same Team ID as the app (or are unsigned) by routing + * it through the plugin helper, which has library validation disabled. + * Required when the process `dlopen`s a runtime-downloaded native addon + * signed by a third party (e.g. the on-device dictation runtime). + */ + readonly allowLoadingUnsignedLibraries?: boolean; } export interface IOnDidTerminateUtilityrocessWorkerProcess { diff --git a/src/vs/platform/utilityProcess/electron-main/utilityProcessWorkerMainService.ts b/src/vs/platform/utilityProcess/electron-main/utilityProcessWorkerMainService.ts index e708d7ed58c546..b498a522187c0a 100644 --- a/src/vs/platform/utilityProcess/electron-main/utilityProcessWorkerMainService.ts +++ b/src/vs/platform/utilityProcess/electron-main/utilityProcessWorkerMainService.ts @@ -130,6 +130,7 @@ class UtilityProcessWorker extends Disposable { type: this.configuration.process.type, name: this.configuration.process.name, entryPoint: this.configuration.process.moduleId, + allowLoadingUnsignedLibraries: this.configuration.process.allowLoadingUnsignedLibraries, parentLifecycleBound: windowPid, windowLifecycleBound: true, correlationId: `${this.configuration.reply.windowId}`, diff --git a/src/vs/workbench/services/localTranscription/electron-browser/localTranscriptionService.ts b/src/vs/workbench/services/localTranscription/electron-browser/localTranscriptionService.ts index b4c6147362deae..3c9484d9604203 100644 --- a/src/vs/workbench/services/localTranscription/electron-browser/localTranscriptionService.ts +++ b/src/vs/workbench/services/localTranscription/electron-browser/localTranscriptionService.ts @@ -7,6 +7,7 @@ import { getDelayedChannel, IChannel, ProxyChannel } from '../../../../base/part import { arch, platform } from '../../../../base/common/process.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { ILocalTranscriptionService, localTranscriptionChannelName } from '../../../../platform/localTranscription/common/localTranscription.js'; import { IUtilityProcessWorkerWorkbenchService } from '../../utilityProcess/electron-browser/utilityProcessWorkerWorkbenchService.js'; @@ -48,6 +49,7 @@ export class LocalTranscriptionService { constructor( @IUtilityProcessWorkerWorkbenchService private readonly utilityProcessWorkerWorkbenchService: IUtilityProcessWorkerWorkbenchService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IProductService private readonly productService: IProductService, ) { } private _getChannel(): IChannel { @@ -56,7 +58,12 @@ export class LocalTranscriptionService { const { client } = await this.utilityProcessWorkerWorkbenchService.createWorker({ moduleId: 'vs/platform/localTranscription/node/localTranscriptionMain', type: 'localTranscription', - name: 'local-transcription' + name: 'local-transcription', + // The on-device dictation runtime is downloaded from our CDN and its + // native addon (foundry_local_napi.node) is signed by a third party, + // so on macOS it must load in the plugin helper (library validation + // disabled) to avoid a Team ID mismatch dlopen failure. + allowLoadingUnsignedLibraries: true }); return client.getChannel(localTranscriptionChannelName); })()); @@ -77,7 +84,18 @@ export class LocalTranscriptionService { getModelStatus() { return this._getProxy().getModelStatus(); } start(options: { cacheDir: string; model?: string; language?: string }) { const { proxyUrl, noProxy, proxyStrictSSL, proxyAuthorization } = this._resolveProxyConfig(); - return this._getProxy().start({ cacheDir: options.cacheDir, model: options.model, language: options.language, proxyUrl, noProxy, proxyStrictSSL, proxyAuthorization }); + const runtime = this.productService.dictationRuntime; + return this._getProxy().start({ + cacheDir: options.cacheDir, + model: options.model, + language: options.language, + proxyUrl, + noProxy, + proxyStrictSSL, + proxyAuthorization, + runtimeUrlTemplate: runtime?.urlTemplate, + runtimeVersion: runtime?.version, + }); } pushAudio(chunk: Parameters[0]) { return this._getProxy().pushAudio(chunk); } stop() { return this._getProxy().stop(); } From 7af410e285ef88fa0388a8a1efa666b2b9531425 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Jul 2026 13:49:04 -0400 Subject: [PATCH 22/34] Extend built-in on-device dictation to editor and terminal (#326982) --- .../speechToText/chatSpeechToTextService.ts | 21 ++- .../speechToText/dictationDownloadRing.ts | 15 ++ .../browser/speechToText/dictationSession.ts | 47 ++++-- .../browser/dictation/editorDictation.ts | 72 +++++++- .../terminal/browser/media/terminalVoice.css | 36 +++- .../voice/browser/terminalVoice.ts | 157 +++++++++++++++++- .../voice/browser/terminalVoiceActions.ts | 6 +- 7 files changed, 326 insertions(+), 28 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index a3c73ba91a0426..b8cf4f346459ce 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -42,6 +42,7 @@ const MODE_SETTING = 'chat.speechToText.mode'; type SpeechToTextSessionEvent = { outcome: 'completed' | 'cancelled' | 'error'; + surface: string; mode: string; durationMs: number; segments: number; @@ -50,8 +51,9 @@ type SpeechToTextSessionEvent = { }; type SpeechToTextSessionClassification = { owner: 'meganrogge'; - comment: 'Tracks usage and reliability of chat-input dictation (speech-to-text).'; + comment: 'Tracks usage and reliability of built-in on-device dictation (speech-to-text).'; outcome: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the dictation session ended.' }; + surface: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Which surface dictated: chat, editor, or terminal.' }; mode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Configured dictation shortcut mode (auto, toggle, or pushToTalk).' }; durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Recording duration in milliseconds.' }; segments: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of transcript segments returned.' }; @@ -83,6 +85,13 @@ export const enum ChatSpeechToTextState { Transcribing = 'transcribing', } +/** + * The surface a dictation session was started from. Reported in telemetry so + * built-in dictation usage can be attributed to the chat input, an editor, or + * the terminal. + */ +export type ChatDictationSurface = 'chat' | 'editor' | 'terminal'; + /** A live dictation transcript update. */ export interface IChatDictationTranscript { /** Full cumulative transcript to display. */ @@ -141,9 +150,10 @@ export interface IChatSpeechToTextService { /** * Begin capturing microphone audio in the given window and streaming it to * the on-device transcription model. Rejects if the microphone cannot be - * accessed. + * accessed. `surface` identifies the dictation surface for telemetry + * (defaults to the chat input). */ - start(window: Window & typeof globalThis): Promise; + start(window: Window & typeof globalThis, surface?: ChatDictationSurface): Promise; /** * Stop capturing, flush the final utterance, and resolve with the complete @@ -227,6 +237,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo private _sessionStartMs = 0; private _sessionSegments = 0; private _sessionErrorCode = ''; + private _sessionSurface: ChatDictationSurface = 'chat'; // Model-preparation telemetry accumulator. `_prepareStartMs` is non-zero // while a preparation is being tracked, so the terminal Ready/Error status @@ -289,6 +300,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo const durationMs = Date.now() - this._sessionStartMs; this._telemetryService.publicLog2('chatSpeechToText.session', { outcome, + surface: this._sessionSurface, mode: this._getDictationMode(), durationMs, segments: this._sessionSegments, @@ -340,7 +352,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo return [this._finalizedText, this._deltaText].filter(Boolean).join(' ').replace(/\s{2,}/g, ' ').trim(); } - async start(window: Window & typeof globalThis): Promise { + async start(window: Window & typeof globalThis, surface: ChatDictationSurface = 'chat'): Promise { if (this._state !== ChatSpeechToTextState.Idle) { return; } @@ -360,6 +372,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._sessionStartMs = Date.now(); this._sessionSegments = 0; this._sessionErrorCode = ''; + this._sessionSurface = surface; let stream: MediaStream; try { diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadRing.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadRing.ts index 78ba688fa6ba93..35ce1871a7e236 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadRing.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadRing.ts @@ -86,3 +86,18 @@ export function getDictationDownloadHoverContent(): IManagedHoverContent { markdown.appendMarkdown(localize('chatStt.hover.preparing', "Preparing the on-device model. This happens only the first time you dictate.")); return { markdown, markdownNotSupportedFallback: markdown.value }; } + +/** + * A short, live label describing the on-device model's preparation state, for + * surfaces without a progress ring (editor placeholder, terminal decoration). + * Reads out the download percentage when known, otherwise a generic preparing + * message (indeterminate download or loading into memory). + */ +export function getDictationPreparingLabel(service: IChatSpeechToTextService): string { + const progress = service.modelDownloadProgress; + if (typeof progress === 'number') { + const percent = Math.max(0, Math.min(100, Math.round(progress * 100))); + return localize('chatStt.preparing.downloading', "Downloading speech-to-text model… {0}%", percent); + } + return localize('chatStt.preparing.preparing', "Preparing speech-to-text model…"); +} diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts index 8efa7affd85d86..9fc8906670847a 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationSession.ts @@ -14,7 +14,8 @@ import { Range } from '../../../../../editor/common/core/range.js'; import { Selection } from '../../../../../editor/common/core/selection.js'; import { localize } from '../../../../../nls.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { ChatSpeechToTextState, IChatSpeechToTextService } from './chatSpeechToTextService.js'; +import { ChatDictationSurface, ChatSpeechToTextState, IChatSpeechToTextService } from './chatSpeechToTextService.js'; +import { getDictationPreparingLabel } from './dictationDownloadRing.js'; /** * Inline decoration class for the still-processing tail of not-yet-finalized @@ -296,7 +297,7 @@ export function activeDictationEditor(): ICodeEditor | undefined { } /** Start dictating into `editor`, rendering the transcript live. */ -export async function startDictation(service: IChatSpeechToTextService, editor: ICodeEditor, window: Window & typeof globalThis, logService: ILogService): Promise { +export async function startDictation(service: IChatSpeechToTextService, editor: ICodeEditor, window: Window & typeof globalThis, logService: ILogService, surface: ChatDictationSurface = 'chat'): Promise { if (_active || service.state !== ChatSpeechToTextState.Idle) { return; } @@ -309,37 +310,47 @@ export async function startDictation(service: IChatSpeechToTextService, editor: const HIDE_CURSOR_CLASS = 'dictation-hide-cursor'; editor.getDomNode()?.classList.add(HIDE_CURSOR_CLASS); disposables.add(toDisposable(() => editor.getDomNode()?.classList.remove(HIDE_CURSOR_CLASS))); - // Show a "Listening…" placeholder only once the session is actually - // connected and recording, i.e. the service is in the Recording state and - // the on-device model has finished preparing (downloading/loading). It must - // not appear during microphone acquisition or while the model is still being - // prepared, since transcription cannot happen yet. The placeholder remains - // visible until transcript text is inserted, and is restored to its previous - // value when the session ends. + // Show a "Listening…" placeholder once the session is actually connected + // and recording, i.e. the service is in the Recording state and the + // on-device model has finished preparing. While the model is still being + // prepared on first use (downloading/loading, which can take a while), show + // a "Preparing…/Downloading… X%" placeholder instead so the user knows why + // dictation has not started yet rather than staring at an idle editor. The + // placeholder must not appear during microphone acquisition. It remains + // visible until transcript text is inserted, and is restored to its + // previous value when the session ends. const previousPlaceholder = editor.getOption(EditorOption.placeholder); const listeningPlaceholder = localize('chatStt.listening', "Listening…"); + // The placeholder we last applied (listening or a preparing label), so we + // only ever restore the previous placeholder when it was ours to restore. + let appliedPlaceholder: string | undefined; const applyPlaceholder = () => { if (!editor.getModel()) { return; } - const shouldListen = service.state === ChatSpeechToTextState.Recording && !service.isPreparingModel; - const current = editor.getOption(EditorOption.placeholder); - if (shouldListen) { - if (current !== listeningPlaceholder) { - editor.updateOptions({ placeholder: listeningPlaceholder }); + const recording = service.state === ChatSpeechToTextState.Recording; + const desired = recording + ? (service.isPreparingModel ? getDictationPreparingLabel(service) : listeningPlaceholder) + : undefined; + if (desired !== undefined) { + if (appliedPlaceholder !== desired) { + editor.updateOptions({ placeholder: desired }); + appliedPlaceholder = desired; } - } else if (current === listeningPlaceholder) { + } else if (appliedPlaceholder !== undefined) { editor.updateOptions({ placeholder: previousPlaceholder }); + appliedPlaceholder = undefined; } }; disposables.add(toDisposable(() => { // Ensure the interim shimmer never lingers, regardless of how the session // ends (final transcript, cancel, editor disposal, or a service-side error). inserter.clearShimmer(); - if (!editor.getModel() || editor.getOption(EditorOption.placeholder) !== listeningPlaceholder) { + if (!editor.getModel() || appliedPlaceholder === undefined) { return; } editor.updateOptions({ placeholder: previousPlaceholder }); + appliedPlaceholder = undefined; })); const idleSettle = disposables.add(new MutableDisposable()); disposables.add(service.onDidUpdateTranscript(update => { @@ -350,6 +361,8 @@ export async function startDictation(service: IChatSpeechToTextService, editor: idleSettle.value = disposableTimeout(() => inserter.settleShimmer(), IDLE_SETTLE_MS); })); disposables.add(service.onDidChangePreparingModel(() => applyPlaceholder())); + // Refresh the "Downloading… X%" placeholder as the download progresses. + disposables.add(service.onDidChangeModelDownloadProgress(() => applyPlaceholder())); disposables.add(service.onDidChangeState(state => { logService.trace(`${LOG_PREFIX} onDidChangeState ${state}`); if (state === ChatSpeechToTextState.Idle && _active?.service === service) { @@ -368,7 +381,7 @@ export async function startDictation(service: IChatSpeechToTextService, editor: disposables.add(editor.onDidDispose(() => cancelDictation())); _active = { service, editor, inserter, disposables, logService }; try { - await service.start(window); + await service.start(window, surface); } catch { // Acquisition/connection failure is surfaced by the service. if (_active?.service === service) { diff --git a/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts index 12178f16a0f59a..8207e91bc65fc2 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts @@ -5,13 +5,17 @@ import './editorDictation.css'; import { localize, localize2 } from '../../../../../nls.js'; -import { IDimension } from '../../../../../base/browser/dom.js'; +import { getActiveWindow, getWindow, IDimension } from '../../../../../base/browser/dom.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ContentWidgetPositionPreference, ICodeEditor, IContentWidget, IContentWidgetPosition } from '../../../../../editor/browser/editorBrowser.js'; import { IEditorContribution } from '../../../../../editor/common/editorCommon.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; import { HasSpeechProvider, ISpeechService, SpeechToTextInProgress, SpeechToTextStatus } from '../../../speech/common/speechService.js'; +import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; +import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../../chat/browser/speechToText/chatSpeechToTextService.js'; +import { activeDictationEditor, isDictating, startDictation, stopDictation } from '../../../chat/browser/speechToText/dictationSession.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { EditorOption } from '../../../../../editor/common/config/editorOptions.js'; import { EditorAction2, EditorContributionInstantiation, registerEditorContribution } from '../../../../../editor/browser/editorExtensions.js'; @@ -34,6 +38,14 @@ import { isWindows } from '../../../../../base/common/platform.js'; const EDITOR_DICTATION_IN_PROGRESS = new RawContextKey('editorDictation.inProgress', false); const VOICE_CATEGORY = localize2('voiceCategory', "Voice"); +/** + * True when the built-in on-device dictation engine is available (and AI + * features are enabled). Mirrors the chat input's `ChatSpeechToTextConfigured` + * gate so editor dictation can run through the built-in engine even when the + * `ms-vscode.vscode-speech` extension is not installed. + */ +const BuiltinDictationConfigured = ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.speechToTextConfigured); + export class EditorDictationStartAction extends EditorAction2 { constructor() { @@ -42,7 +54,9 @@ export class EditorDictationStartAction extends EditorAction2 { title: localize2('startDictation', "Start Dictation in Editor"), category: VOICE_CATEGORY, precondition: ContextKeyExpr.and( - HasSpeechProvider, + // Available either through the built-in on-device engine or the + // speech extension's provider. + ContextKeyExpr.or(HasSpeechProvider, BuiltinDictationConfigured), SpeechToTextInProgress.toNegated(), // disable when any speech-to-text is in progress EditorContextKeys.readOnly.toNegated() // disable in read-only editors ), @@ -196,6 +210,8 @@ export class EditorDictation extends Disposable implements IEditorContribution { constructor( private readonly editor: ICodeEditor, @ISpeechService private readonly speechService: ISpeechService, + @IChatSpeechToTextService private readonly chatSpeechToTextService: IChatSpeechToTextService, + @ILogService private readonly logService: ILogService, @IContextKeyService contextKeyService: IContextKeyService, @IKeybindingService keybindingService: IKeybindingService ) { @@ -206,6 +222,52 @@ export class EditorDictation extends Disposable implements IEditorContribution { } async start(): Promise { + // Prefer the built-in on-device engine (private, in-box) when it is + // configured, falling back to the speech extension's provider otherwise. + if (this.chatSpeechToTextService.isConfigured) { + return this.startBuiltin(); + } + return this.startWithProvider(); + } + + /** + * Run editor dictation through the built-in on-device engine, reusing the + * shared chat dictation renderer (live transcript, interim shimmer, and + * "Listening…" placeholder). A floating stop widget is shown so the mic can + * be stopped by mouse as well as by the Escape keybinding. + */ + private async startBuiltin(): Promise { + const disposables = new DisposableStore(); + this.sessionDisposables.value = disposables; + + this.widget.show(); + this.widget.active(); + disposables.add(toDisposable(() => this.widget.hide())); + + this.editorDictationInProgress.set(true); + disposables.add(toDisposable(() => this.editorDictationInProgress.reset())); + + disposables.add(this.editor.onDidChangeCursorPosition(() => this.widget.layout())); + + // When the shared session ends on its own (final transcript applied, an + // error, or the model failing to load), tear down the editor-side UI. + disposables.add(this.chatSpeechToTextService.onDidChangeState(state => { + if (state === ChatSpeechToTextState.Idle) { + this.sessionDisposables.clear(); + } + })); + + const window = getWindow(this.editor.getDomNode()) ?? getActiveWindow(); + await startDictation(this.chatSpeechToTextService, this.editor, window, this.logService, 'editor'); + + // If the session did not take (already dictating elsewhere, or start + // failed without a state transition), do not leave the widget stranded. + if (activeDictationEditor() !== this.editor) { + this.sessionDisposables.clear(); + } + } + + private async startWithProvider(): Promise { const disposables = new DisposableStore(); this.sessionDisposables.value = disposables; @@ -295,6 +357,12 @@ export class EditorDictation extends Disposable implements IEditorContribution { } stop(): void { + // Built-in dictation into this editor is owned by the shared chat + // dictation session; stop it there so the final transcript is applied. + if (isDictating() && activeDictationEditor() === this.editor) { + stopDictation(); + return; + } this.sessionDisposables.clear(); } } diff --git a/src/vs/workbench/contrib/terminal/browser/media/terminalVoice.css b/src/vs/workbench/contrib/terminal/browser/media/terminalVoice.css index fda61dd435ade1..78db8d038bd5f5 100644 --- a/src/vs/workbench/contrib/terminal/browser/media/terminalVoice.css +++ b/src/vs/workbench/contrib/terminal/browser/media/terminalVoice.css @@ -39,9 +39,41 @@ } } +/* Live (in-progress) dictation transcript. Match the chat input and editor + * dictation surfaces: render in the input placeholder color with the same + * shimmer animation instead of the static italic ghost-text style, so all + * three speech surfaces look identical while words stream in. */ +@keyframes terminal-voice-progress-shimmer { + 0% { + background-position: 120% 0; + } + + 100% { + background-position: -120% 0; + } +} + .terminal-voice-progress-text { + background: linear-gradient(90deg, + var(--vscode-input-placeholderForeground) 0%, + var(--vscode-input-placeholderForeground) 30%, + var(--vscode-foreground) 50%, + var(--vscode-input-placeholderForeground) 70%, + var(--vscode-input-placeholderForeground) 100%); + background-size: 400% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: terminal-voice-progress-shimmer 2s linear infinite; + will-change: background-position; + z-index: 1000; +} + +/* First-use "Preparing…/Downloading… X%" hint shown while the on-device model + * loads. Static placeholder-colored italic text (no shimmer) so it reads as a + * status message rather than dictated speech. */ +.terminal-voice-preparing-text { font-style: italic; - color: var(--vscode-editorGhostText-foreground) !important; - border: 1px solid var(--vscode-editorGhostText-border); + color: var(--vscode-input-placeholderForeground); z-index: 1000; } diff --git a/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoice.ts b/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoice.ts index 62807eb68d23cd..912151b50b513d 100644 --- a/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoice.ts +++ b/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoice.ts @@ -15,8 +15,11 @@ import { IContextKey, IContextKeyService } from '../../../../../platform/context import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { SpeechTimeoutDefault } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { ISpeechService, AccessibilityVoiceSettingId, ISpeechToTextEvent, SpeechToTextStatus } from '../../../speech/common/speechService.js'; +import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../../chat/browser/speechToText/chatSpeechToTextService.js'; +import { getDictationPreparingLabel } from '../../../chat/browser/speechToText/dictationDownloadRing.js'; import type { IMarker, IDecoration } from '@xterm/xterm'; import { alert } from '../../../../../base/browser/ui/aria/aria.js'; +import { getActiveWindow } from '../../../../../base/browser/dom.js'; import { ITerminalService } from '../../../terminal/browser/terminal.js'; import { TerminalContextKeys } from '../../../terminal/common/terminalContextKey.js'; @@ -59,6 +62,10 @@ export class TerminalVoiceSession extends Disposable { private static _instance: TerminalVoiceSession | undefined = undefined; private _acceptTranscriptionScheduler: RunOnceScheduler | undefined; private readonly _terminalDictationInProgress: IContextKey; + /** True while the current session is driven by the built-in on-device engine. */ + private _usingBuiltin = false; + /** True while awaiting the built-in engine's final transcript during accept. */ + private _builtinFinalizing = false; static getInstance(instantiationService: IInstantiationService): TerminalVoiceSession { if (!TerminalVoiceSession._instance) { TerminalVoiceSession._instance = instantiationService.createInstance(TerminalVoiceSession); @@ -70,6 +77,7 @@ export class TerminalVoiceSession extends Disposable { private readonly _disposables: DisposableStore; constructor( @ISpeechService private readonly _speechService: ISpeechService, + @IChatSpeechToTextService private readonly _chatSpeechToTextService: IChatSpeechToTextService, @ITerminalService private readonly _terminalService: ITerminalService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, @@ -88,11 +96,25 @@ export class TerminalVoiceSession extends Disposable { voiceTimeout = SpeechTimeoutDefault; } this._acceptTranscriptionScheduler = this._disposables.add(new RunOnceScheduler(() => { + // The built-in engine returns its final utterance only from + // stopAndTranscribe(), so accept through stop(true) rather than + // sending the interim text and discarding the recording. + if (this._usingBuiltin) { + this.stop(true); + return; + } this._sendText(); this.stop(); }, voiceTimeout)); this._cancellationTokenSource = new CancellationTokenSource(); this._register(toDisposable(() => this._cancellationTokenSource?.dispose(true))); + + // Prefer the built-in on-device engine (private, in-box) when configured, + // falling back to the speech extension's provider otherwise. + if (this._chatSpeechToTextService.isConfigured) { + return this._startBuiltin(voiceTimeout); + } + const session = await this._speechService.createSpeechToTextSession(this._cancellationTokenSource?.token, 'terminal'); this._disposables.add(session.onDidChange((e) => { @@ -134,7 +156,119 @@ export class TerminalVoiceSession extends Disposable { } })); } + + /** + * Drive terminal dictation from the built-in on-device engine. Unlike the + * extension provider (which emits discrete `Recognizing`/`Recognized` events + * per utterance), the built-in engine streams a single growing cumulative + * transcript. We render it live as ghost text and keep it staged in + * `_input`, then send it once the silence timeout elapses or the user stops. + */ + private async _startBuiltin(voiceTimeout: number): Promise { + const service = this._chatSpeechToTextService; + + // Only one dictation can run at a time (the on-device engine is a shared + // singleton). If it is already recording elsewhere (chat input or an + // editor), `service.start()` would no-op while these listeners stayed + // attached and streamed that other surface's transcript into the + // terminal. Reject a non-idle engine before subscribing. + if (service.state !== ChatSpeechToTextState.Idle) { + this.stop(); + return; + } + + this._usingBuiltin = true; + this._terminalDictationInProgress.set(true); + if (!this._decoration) { + this._createDecoration(); + } + + // On first use the model downloads/loads before any transcript arrives. + // Unlike the chat input (which has a toolbar download ring), the terminal + // has no progress affordance, so surface a "Preparing…/Downloading… X%" + // hint in the ghost-text slot until the model is ready and real + // transcripts start streaming. + const renderPreparing = () => { + if (this._cancellationTokenSource?.token.isCancellationRequested || this._builtinFinalizing) { + return; + } + if (service.isPreparingModel) { + this._renderPreparingText(getDictationPreparingLabel(service)); + } + }; + renderPreparing(); + this._disposables.add(service.onDidChangePreparingModel(() => renderPreparing())); + this._disposables.add(service.onDidChangeModelDownloadProgress(() => renderPreparing())); + + this._disposables.add(service.onDidUpdateTranscript(update => { + if (this._cancellationTokenSource?.token.isCancellationRequested || this._builtinFinalizing) { + return; + } + // Reuse the provider-path rendering by shaping the cumulative + // transcript as a recognizing event. The staged text is only sent + // once accepted (silence timeout or Stop Dictation), which fetches + // the engine's final transcript. The first real transcript replaces + // any lingering "Preparing…" hint. + const event: ISpeechToTextEvent = { status: SpeechToTextStatus.Recognizing, text: update.text }; + this._updateInput(event); + this._renderGhostText(event); + this._updateDecoration(); + if (voiceTimeout > 0) { + this._acceptTranscriptionScheduler!.cancel(); + this._acceptTranscriptionScheduler!.schedule(); + } + })); + + // If the engine ends the session on its own (e.g. the model failed to + // load), abort the terminal-side rendering. Guarded so neither the + // accept-triggered nor the abort-triggered Idle transition re-enters. + this._disposables.add(service.onDidChangeState(state => { + if (state === ChatSpeechToTextState.Idle && !this._builtinFinalizing && !this._cancellationTokenSource?.token.isCancellationRequested) { + this.stop(); + } + })); + + try { + await service.start(getActiveWindow(), 'terminal'); + } catch { + // Microphone acquisition/connection failure is surfaced by the service. + this.stop(); + } + } + + /** + * Accept the built-in dictation: fetch the engine's final transcript (the + * last utterance is only returned by `stopAndTranscribe`, not the interim + * stream), stage it, then tear down and send it. Used by the silence timeout + * and the Stop Dictation action; abort/error teardown uses `cancel()` instead. + */ + private async _finalizeBuiltinThenStop(): Promise { + let finalText: string | undefined; + try { + finalText = await this._chatSpeechToTextService.stopAndTranscribe(); + } catch { + // Fall back to the last interim text already staged in `_input`. + } + // A concurrent abort (e.g. the terminal was disposed) already tore down. + if (!this._usingBuiltin || this._cancellationTokenSource?.token.isCancellationRequested) { + return; + } + if (finalText !== undefined) { + this._updateInput({ status: SpeechToTextStatus.Recognized, text: finalText }); + } + // _builtinFinalizing is set, so this reaches the synchronous teardown and + // sends the staged (final) text. + this.stop(true); + } + stop(send?: boolean): void { + // Built-in accept path: fetch the final transcript before tearing down. + if (this._usingBuiltin && send && !this._builtinFinalizing) { + this._builtinFinalizing = true; + this._acceptTranscriptionScheduler?.cancel(); + this._finalizeBuiltinThenStop(); + return; + } this._setInactive(); if (send) { this._acceptTranscriptionScheduler!.cancel(); @@ -147,9 +281,16 @@ export class TerminalVoiceSession extends Disposable { this._marker = undefined; this._ghostTextMarker = undefined; this._cancellationTokenSource?.cancel(); + // Abort the on-device engine on teardown. On the accept path the engine + // has already finished via stopAndTranscribe(), so this is a no-op there. + if (this._usingBuiltin) { + this._chatSpeechToTextService.cancel(); + } this._disposables.clear(); this._input = ''; this._terminalDictationInProgress.reset(); + this._usingBuiltin = false; + this._builtinFinalizing = false; } private _sendText(): void { @@ -213,8 +354,20 @@ export class TerminalVoiceSession extends Disposable { } private _renderGhostText(e: ISpeechToTextEvent): void { + this._renderGhostTextContent(e.text, 'terminal-voice-progress-text'); + } + + /** + * Render a non-transcript hint (e.g. "Preparing…/Downloading… X%") in the + * ghost-text slot while the on-device model is still preparing on first use. + * Styled distinctly from the live transcript so it does not read as speech. + */ + private _renderPreparingText(label: string): void { + this._renderGhostTextContent(label, 'terminal-voice-preparing-text'); + } + + private _renderGhostTextContent(text: string | undefined, className: string): void { this._ghostText?.dispose(); - const text = e.text; if (!text) { return; } @@ -238,7 +391,7 @@ export class TerminalVoiceSession extends Disposable { this._disposables.add(this._ghostText); } this._ghostText?.onRender((e: HTMLElement) => { - e.classList.add('terminal-voice-progress-text'); + e.classList.add(className); e.textContent = text; e.style.width = (xterm.cols - xterm.buffer.active.cursorX) / xterm.cols * 100 + '%'; }); diff --git a/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoiceActions.ts b/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoiceActions.ts index 0f23e5a52315df..b59a9077e7df66 100644 --- a/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoiceActions.ts +++ b/src/vs/workbench/contrib/terminalContrib/voice/browser/terminalVoiceActions.ts @@ -14,6 +14,7 @@ import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { EnablementState, IWorkbenchExtensionEnablementService } from '../../../../services/extensionManagement/common/extensionManagement.js'; import { HasSpeechProvider, SpeechToTextInProgress } from '../../../speech/common/speechService.js'; +import { IChatSpeechToTextService } from '../../../chat/browser/speechToText/chatSpeechToTextService.js'; import { registerActiveInstanceAction, sharedWhenClause } from '../../../terminal/browser/terminalActions.js'; import { TerminalCommandId } from '../../../terminal/common/terminal.js'; import { TerminalContextKeys } from '../../../terminal/common/terminalContextKey.js'; @@ -38,7 +39,10 @@ export function registerTerminalVoiceActions() { const dialogService = accessor.get(IDialogService); const workbenchExtensionEnablementService = accessor.get(IWorkbenchExtensionEnablementService); const extensionManagementService = accessor.get(IExtensionManagementService); - if (HasSpeechProvider.getValue(contextKeyService)) { + const chatSpeechToTextService = accessor.get(IChatSpeechToTextService); + // Start dictation whenever an engine is available: the built-in + // on-device engine (preferred), or the speech extension's provider. + if (chatSpeechToTextService.isConfigured || HasSpeechProvider.getValue(contextKeyService)) { const instantiationService = accessor.get(IInstantiationService); TerminalVoiceSession.getInstance(instantiationService).start(); return; From 2ea4fd32e4efa8225c0d641dc3013809e0174832 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 22 Jul 2026 11:10:19 -0700 Subject: [PATCH 23/34] agentHost: expand multi-chat end-to-end coverage (#326829) * Document Agent Host E2E coverage strategy Capture the public principles for coverage-guided test selection, model-boundary choices, portable oracles, shared-server isolation, and completion criteria.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Expand Agent Host multi-chat E2E coverage Add cross-provider peer chat lifecycle and provider-backed history tests, validate actual replay requests carry prior context, and document provider capability gaps through targeted skips.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refresh Agent Host E2E coverage metrics Regenerate the loaded-file coverage summary after rebasing the multi-chat E2E expansion onto current main.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Expand Agent Host peer chat E2E coverage Add 21 peer-chat scenarios covering local commands, workspace tools, provider context isolation and attachment projection. Record strict Claude and Copilot fixtures and refresh aggregate coverage metrics.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Document disabled Agent Host E2E tests Add a persistent inventory and reevaluation process for suspected provider bugs, platform limitations, record-only scenarios, and capability skips. Gate the two Copilot peer file mutations whose recorded POSIX tool plans do not produce Windows filesystem effects.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Strengthen Agent Host context assertions Require prior assistant responses to be non-empty before using them to verify provider request history.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Agent Host E2E approval loop Deduplicate retained toolCallReady notifications by server sequence so the permission test sends one confirmation instead of flooding and terminating the shared Copilot host.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Recycle shared Agent Host E2E server to prevent cumulative wedge The shared replay server caches one provider SDK/CLI subprocess and reuses it across the whole suite. After enough sessions that subprocess degrades and eventually wedges a turn (turn starts, no model response arrives even though replay is instant), which times out and, via teardown of the mid-turn session, leaves the host unavailable so the next test hits ECONNREFUSED. Proactively recycle the server every 25 tests, and restart it after any failed test so a wedge cannot cascade into unrelated tests. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/agent-host-e2e-tests/SKILL.md | 3 +- .../agentHost/test/node/e2e/KNOWN_ISSUES.md | 330 ++++++ .../agentHost/test/node/e2e/README.md | 15 + ...eted-turn-removes-it-from-the-catalog.yaml | 12 + ...ude-peer-chat-completes-a-simple-turn.yaml | 12 + ...-creates-a-file-in-a-nested-directory.yaml | 43 + ...reates-a-file-in-the-parent-workspace.yaml | 43 + ...chat-edits-an-existing-workspace-file.yaml | 134 +++ ...ssing-workspace-file-without-an-error.yaml | 41 + ...-reads-a-file-from-a-nested-directory.yaml | 41 + ...eads-a-file-from-the-parent-workspace.yaml | 41 + ...at-reads-a-filename-containing-spaces.yaml | 38 + ...ains-context-across-consecutive-turns.yaml | 31 + ...ttachments-reach-the-provider-request.yaml | 19 + ...-survives-unsubscribe-and-resubscribe.yaml | 31 + ...tachment-reaches-the-provider-request.yaml | 12 + ...ttachment-includes-its-line-reference.yaml | 12 + ...tachment-reaches-the-provider-request.yaml | 16 + ...-is-omitted-from-the-provider-request.yaml | 12 + ...at-starts-with-fresh-provider-context.yaml | 24 + ...ts-keep-independent-provider-contexts.yaml | 59 ++ ...-chats-write-distinct-workspace-files.yaml | 83 ++ ...eted-turn-removes-it-from-the-catalog.yaml | 12 + ...s-source-history-through-the-provider.yaml | 25 + ...does-not-inherit-default-chat-context.yaml | 21 + ...cli-peer-chat-completes-a-simple-turn.yaml | 12 + ...-creates-a-file-in-a-nested-directory.yaml | 80 ++ ...reates-a-file-in-the-parent-workspace.yaml | 39 + ...chat-edits-an-existing-workspace-file.yaml | 49 + ...ssing-workspace-file-without-an-error.yaml | 41 + ...-reads-a-file-from-a-nested-directory.yaml | 41 + ...eads-a-file-from-the-parent-workspace.yaml | 37 + ...at-reads-a-filename-containing-spaces.yaml | 37 + ...ains-context-across-consecutive-turns.yaml | 25 + ...ttachments-reach-the-provider-request.yaml | 24 + ...-survives-unsubscribe-and-resubscribe.yaml | 25 + ...tachment-reaches-the-provider-request.yaml | 17 + ...ttachment-includes-its-line-reference.yaml | 21 + ...tachment-reaches-the-provider-request.yaml | 19 + ...-is-omitted-from-the-provider-request.yaml | 12 + ...at-starts-with-fresh-provider-context.yaml | 21 + ...ts-keep-independent-provider-contexts.yaml | 47 + ...-chats-write-distinct-workspace-files.yaml | 75 ++ ...s-not-inherit-source-provider-context.yaml | 21 + .../test/node/e2e/coverage/summary.json | 980 +++++++++--------- .../e2e/harness/agentHostE2ETestHarness.ts | 69 +- .../test/node/e2e/harness/capiReplayProxy.ts | 12 + .../claudeAgentHostE2E.integrationTest.ts | 5 + .../e2e/providers/codexTestConfiguration.ts | 3 + .../copilotAgentHostE2E.integrationTest.ts | 3 + .../node/e2e/suites/agentHostE2ESuites.ts | 9 +- .../test/node/e2e/suites/e2eTestContext.ts | 1 + .../test/node/e2e/suites/multiChatSuite.ts | 789 ++++++++++++++ .../node/e2e/suites/turnLifecycleSuite.ts | 17 +- 54 files changed, 3137 insertions(+), 504 deletions(-) create mode 100644 src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-disposing-a-peer-after-a-completed-turn-removes-it-from-the-catalog.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-completes-a-simple-turn.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-a-nested-directory.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-the-parent-workspace.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-edits-an-existing-workspace-file.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-handles-a-missing-workspace-file-without-an-error.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-a-nested-directory.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-the-parent-workspace.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-filename-containing-spaces.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-retains-context-across-consecutive-turns.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-multiple-simple-attachments-reach-the-provider-request.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-provider-context-survives-unsubscribe-and-resubscribe.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-attachment-reaches-the-provider-request.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-selection-attachment-includes-its-line-reference.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-reaches-the-provider-request.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-without-a-model-representation-is-omitted-from-the-provider-request.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-recreated-peer-chat-starts-with-fresh-provider-context.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-keep-independent-provider-contexts.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-write-distinct-workspace-files.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-disposing-a-peer-after-a-completed-turn-removes-it-from-the-catalog.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-forked-peer-chat-inherits-source-history-through-the-provider.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-fresh-peer-chat-does-not-inherit-default-chat-context.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-completes-a-simple-turn.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-creates-a-file-in-a-nested-directory.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-creates-a-file-in-the-parent-workspace.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-edits-an-existing-workspace-file.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-handles-a-missing-workspace-file-without-an-error.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-reads-a-file-from-a-nested-directory.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-reads-a-file-from-the-parent-workspace.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-reads-a-filename-containing-spaces.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-retains-context-across-consecutive-turns.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-multiple-simple-attachments-reach-the-provider-request.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-provider-context-survives-unsubscribe-and-resubscribe.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-resource-attachment-reaches-the-provider-request.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-resource-selection-attachment-includes-its-line-reference.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-simple-attachment-reaches-the-provider-request.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-simple-attachment-without-a-model-representation-is-omitted-from-the-provider-request.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-recreated-peer-chat-starts-with-fresh-provider-context.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-two-peer-chats-keep-independent-provider-contexts.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-two-peer-chats-write-distinct-workspace-files.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-unknown-turn-fork-does-not-inherit-source-provider-context.yaml create mode 100644 src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts diff --git a/.github/skills/agent-host-e2e-tests/SKILL.md b/.github/skills/agent-host-e2e-tests/SKILL.md index 985b61fd775e71..cecb3a0abccc66 100644 --- a/.github/skills/agent-host-e2e-tests/SKILL.md +++ b/.github/skills/agent-host-e2e-tests/SKILL.md @@ -19,6 +19,7 @@ It documents the mental model, the fixture format, every config flag, and a symp 3. **Recording needs a real token** (`GITHUB_TOKEN` or `gh auth token`) and talks to real CAPI. Only run it intentionally, with trivial/read-only prompts in temp dirs. 4. **Never hand-write or hand-edit fixture contents** (especially not secrets/paths). Fixtures are always produced by recording; normalization/redaction is the proxy's job. 5. **Gate, don't fight.** If a behavior can't replay deterministically, gate the test (see Workflow C) instead of loosening timeouts or the strict check. +6. **Track every disabled variant.** Keep `e2e/KNOWN_ISSUES.md` current with the test title, scope, expected and observed behavior, and a focused reproduction command. Record symptoms, not speculative root causes. ## Workflow A — Add a cross-provider test @@ -54,7 +55,7 @@ Real-time streaming, mid-turn aborts, and POSIX-specific local execution (shell - **POSIX-only** (fails on Windows): gate with `!isWindows`, or a targeted per-provider flag when only one provider diverges. See the worktree and subagent-reopen tests. - **Provider/OS-specific replay**: add a targeted config gate that still permits recording and unaffected platforms. See the Codex shell-tool Linux gate. -Always add a comment explaining *why* the gate exists. +Always add a comment explaining *why* the gate exists. Also add or update the corresponding entry in `e2e/KNOWN_ISSUES.md`. When the variant is enabled again, remove or update the entry in the same change. ## Verifying & troubleshooting diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md new file mode 100644 index 00000000000000..14c7f7c2523a05 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -0,0 +1,330 @@ +# Disabled and conditional E2E tests + +This document inventories bundled-provider E2E tests that are disabled for at +least one provider, platform, or execution mode. It exists so a human or agent +can periodically reevaluate the gaps instead of treating every pending test as +expected forever. + +The test remains the executable specification. This document records the +observed symptom and scope, not a speculative root cause. + +## Process + +When a valid E2E scenario exposes behavior that may be a product bug: + +1. Minimize the scenario and confirm which provider, platform, and execution + mode reproduce it. +2. Keep the failing test case in the suite, but disable only the affected + variant. Do not weaken its assertions to make it pass. +3. Add a short comment at the gate and an entry here with: + - the exact test title + - affected provider/platform/mode + - expected and observed behavior + - a focused reproduction command +4. Record symptoms only. Root-cause hypotheses belong in an investigation, + issue, or fix, where they can be tested. +5. Keep generated captures for provider variants that are expected to run + again. Never hand-edit captures. +6. When the behavior is fixed or the limitation is removed, enable the test, + verify it fails without the fix when practical, and remove or update the + entry. + +Capability skips are tracked separately from suspected bugs. A provider that +does not advertise a capability is expected to skip positive-path tests for +that capability. + +## Suspected product bugs + +### Claude provider-context fork + +- Tests: + - `forked peer chat inherits source history through the provider` + - `unknown-turn fork does not inherit source provider context` +- Scope: Claude. +- Expected: Claude advertises multi-chat fork support, and a provider-backed + fork can continue from the requested source history. +- Observed: exercising a real provider-context fork rejects the AHP turn id as + an invalid `upToMessageId`. The unknown-turn context test currently shares + the same provider E2E fork gate. +- Gate: `supportsChatForkE2E: false`. +- Reproduce: + + ```bash + ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts \ + --grep "forked peer chat inherits source history through the provider" + ``` + + Temporarily enable `supportsChatForkE2E` to execute the disabled test. + +### Claude fresh peer context after a long shared-suite sequence + +- Test: `fresh peer chat does not inherit default chat context`. +- Scope: Claude deterministic replay with the shared provider process. +- Expected: materializing a fresh peer after using the default chat succeeds, + and the peer request does not contain the default chat's history. +- Observed: after the preceding peer-lifecycle sequence, the peer turn can fail + with `sendFailed: Server not found: host`. The same test passes by itself and + while recording with a per-test process. +- Gate: disabled only for Claude. +- Reproduce: + + ```bash + ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts + ``` + + Temporarily enable the Claude variant. Run the whole provider file; a focused + run does not reproduce the shared-process symptom. + +### Copilot file-operation turns that do not complete reliably + +- Scope: Copilot. +- Tests and observed symptoms: + - `reads an existing text file`: the recorded turn did not complete. + - `reads a value from JSON`: the replayed turn did not complete. + - `creates a new text file`: tool completion is not emitted consistently. + - `edits an existing text file`: the replayed turn did not complete. + - `deletes a workspace file`: the replayed turn did not complete. +- Expected: each turn reaches `chat/turnComplete` and the direct filesystem or + response assertion succeeds. +- Gate: provider-specific conditions in `fileOperationsSuite.ts`. +- Reproduce: + + ```bash + ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts \ + --grep "" + ``` + + Temporarily enable the selected Copilot variant. Re-record narrowly if the + current capture does not exist. + +### Codex duplicated or unstable response behavior + +- Scope: Codex deterministic replay. +- Expected: a model-backed scenario emits one coherent response and honors + exact-response prompts. +- Observed: Codex duplicates response content in these newer behavior + scenarios, and some exact-response prompts do not produce the expected + response. +- Gate: `stableNewScenarioResponse` is false for Codex. +- Tests covered by this broad gate: + - `retains context across consecutive turns` + - `reads an existing text file` + - `reads a file from a nested directory` + - `lists workspace entries` + - `reads a value from JSON` + - `counts lines in a file` + - `handles a missing file without a session error` + - `creates a new text file` + - `edits an existing text file` + - `creates a file in a new nested directory` + - `renames a workspace file` + - `deletes a workspace file` + - `runs a deterministic shell command` + - `inspects git status` + - `reads a filename containing spaces` +- Reproduce: + + ```bash + ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/codexAgentHostE2E.integrationTest.ts \ + --grep "" + ``` + + Temporarily enable only the selected scenario. This broad gate should be + narrowed as individual Codex scenarios become stable. + +## Platform and deterministic-replay limitations + +### Windows shell and filesystem behavior + +The committed model captures can select POSIX shell commands, and several +host-owned shell behaviors differ on Windows. These tests remain enabled on +unaffected providers and platforms. + +| Test | Disabled scope | Observed limitation | +|---|---|---| +| `a bang command runs locally and exposes terminal output` | Windows | The successful bang command produces output but does not complete reliably. | +| `session configuration resolves and completes git branches` | Windows | Git-backed config discovery can retain the temporary repository lock after session disposal. | +| `worktree session uses the resolved worktree as working directory` | Windows | The recorded paths and `pwd` behavior are POSIX-shaped. | +| `tool call triggers permission request and can be approved` | Windows | The scenario executes a recorded shell command. | +| `lists workspace entries` | Windows | The scenario depends on provider shell execution. | +| `counts lines in a file` | Windows | The scenario depends on provider shell execution. | +| `renames a workspace file` | Windows | The scenario depends on provider shell execution. | +| `runs a deterministic shell command` | Windows | The scenario directly exercises a shell command. | +| `reads a file from a nested directory` | Copilot on Windows | The Copilot capture uses shell behavior that is not portable to Windows. | +| `handles a missing file without a session error` | Copilot on Windows | The Copilot capture uses shell behavior that is not portable to Windows. | +| `creates a file in a new nested directory` | Copilot on Windows | The Copilot capture uses a POSIX shell. | +| `inspects git status` | Copilot on Windows | The scenario depends on provider shell execution. | +| `edits an existing text file` | Claude on Windows | The scenario depends on provider shell execution. | +| `deletes a workspace file` | Claude on Windows | The scenario depends on provider shell execution. | +| `peer chat edits an existing workspace file` | Copilot on Windows | Replay completes, but the recorded tool plan does not mutate the Windows file. | +| `peer chat creates a file in a nested directory` | Copilot on Windows | Replay completes, but the recorded tool plan does not create the Windows file. | + +Use the affected provider command with `--grep ""` and +temporarily remove the platform gate to reevaluate a row. + +### Codex shell-tool replay on Linux + +- Scope: Codex on Linux in deterministic replay. +- Gate: `shellToolReplayUnstableOnLinux: true`. +- Tests directly affected by this gate: + - `tool call triggers permission request and can be approved` + - `worktree session uses the resolved worktree as working directory` + - `lists workspace entries` + - `counts lines in a file` + - `renames a workspace file` + - `runs a deterministic shell command` +- Recording mode remains enabled so a future capture or provider update can be + evaluated. + +### Claude subagent replay on Windows + +- Test: `reopening a session keeps sub-agent messages out of the parent transcript (replay path)`. +- Scope: Claude on Windows. +- Expected: the reopened parent transcript excludes subagent-only messages. +- Observed: Claude reconstructs the subagent transcript from + `subagents/agent-*.jsonl`, which is not reliably visible on Windows. +- Gate: `subagentReplayUnstableOnWindows: true`. +- Related investigation: [#325284](https://github.com/microsoft/vscode/pull/325284). + +### Git-status snapshot ordering + +- Test: `inspects git status`. +- Scope: Claude and Codex. +- Expected: the behavior snapshot contains stable semantic tool traffic. +- Observed: customization and changeset notifications occur at nondeterministic + points in the snapshot. +- Gate: enabled only for Copilot, subject to shell-platform gates. + +### Mid-turn abort is record-only + +- Test: `can abort a running turn`. +- Scope: deterministic replay for every provider. +- Reason: replay serves the intentionally truncated response immediately, so + there is no real streaming window in which to abort. +- Run: + + ```bash + AGENT_HOST_REPLAY_RECORD=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts \ + --grep "can abort a running turn" + ``` + +This is an intentional test-mode limitation, not a suspected product bug. + +### Live Codex steering suite is opt-in + +The tests in `codexAgentHostLive.integrationTest.ts` require +`AGENT_HOST_REAL_CODEX=1` because they exercise live, timing-sensitive Codex +behavior that is not represented by deterministic model replay: + +- `mid-turn steering surfaces as a new turn and never sticks in pending` +- `client tool is registered and invoked end-to-end` +- `client tool registered after the thread prewarms restarts the thread and still works` +- `server tool (listComments) is registered and executed in-process` +- `file-change approval is surfaced and can be approved` +- `truncate rolls back trailing turns and archive/unarchive reach codex` +- `Plan mode (Agent Mode control) makes request_user_input reachable end-to-end` + +These are opt-in live tests, not known failures. + +## Test-design limitations + +### Claude plan-mode prompt + +- Test: `planning-mode session-state writes are auto-approved in default mode`. +- Scope: Claude. +- Expected: the shared prompt drives the provider to invoke `ExitPlanMode`. +- Observed: plan mode is wired, but the Copilot-oriented prompt does not + reliably cause Claude to invoke the tool. +- Gate: `supportsPlanMode: false`. +- Evaluation goal: make the test prompt provider-neutral or add an equivalent + Claude-specific prompt without weakening the plan-mode assertions. + +## Expected capability skips + +These pending tests do not currently indicate bugs. + +### Codex multi-chat + +Codex does not advertise `multipleChats`. The negative capability test +`provider without multiple chat capability rejects peer creation` runs for +Codex; these positive peer-chat declarations are skipped: + +- `creating a peer chat adds it to the session catalog` +- `peer chat subscription starts empty and idle` +- `creating the same peer chat twice is idempotent` +- `creating two peer chats preserves both catalog entries` +- `disposing a peer chat removes its catalog entry` +- `disposing one peer chat preserves its sibling` +- `recreating a disposed peer chat starts empty` +- `renaming a peer chat updates its catalog title` +- `renaming a peer chat leaves the session title unchanged` +- `peer chat survives unsubscribe and resubscribe` +- `peer creation does not leak a provider backing as a top-level session` +- `peer file completion uses the parent workspace` +- `first peer chat snapshots the session title onto the default chat` +- `session rename after peer creation preserves the default chat title` +- `forking an unknown turn creates a fresh empty peer chat` +- `peer chat completes a simple turn` +- `peer chat retains context across consecutive turns` +- `forked peer chat inherits source history through the provider` +- `disposing a peer after a completed turn removes it from the catalog` +- `peer rename command updates the peer title and records a local turn` +- `empty peer rename command leaves the peer title unchanged` +- `failing peer bang command records a failed terminal tool call` +- `peer chat reads a file from the parent workspace` +- `peer chat reads a file from a nested directory` +- `peer chat creates a file in the parent workspace` +- `peer chat edits an existing workspace file` +- `peer chat creates a file in a nested directory` +- `peer chat handles a missing workspace file without an error` +- `peer chat reads a filename containing spaces` +- `two peer chats write distinct workspace files` +- `fresh peer chat does not inherit default chat context` +- `two peer chats keep independent provider contexts` +- `peer provider context survives unsubscribe and resubscribe` +- `recreated peer chat starts with fresh provider context` +- `unknown-turn fork does not inherit source provider context` +- `peer simple attachment reaches the provider request` +- `peer simple attachment without a model representation is omitted from the provider request` +- `peer multiple simple attachments reach the provider request` +- `peer resource attachment reaches the provider request` +- `peer resource selection attachment includes its line reference` + +### Codex subagents + +Codex does not advertise subagent support, so these tests are skipped: + +- `subagent tool calls are routed to the subagent session, not flat in the parent` +- `reopening a session keeps sub-agent messages out of the parent transcript (replay path)` + +### Codex plan mode + +Codex does not enable the shared plan-mode contract, so +`planning-mode session-state writes are auto-approved in default mode` is +skipped. + +### Provider package availability + +The complete Claude or Codex deterministic suite is skipped when its bundled +SDK package is unavailable. This is an environment prerequisite, not a product +or test failure. + +## Review checklist + +Periodically: + +1. Run the full provider files, not only focused tests, because shared-process + failures may depend on suite order. +2. Reevaluate broad gates such as `stableNewScenarioResponse` one test at a + time. +3. Check whether new provider SDK/CLI versions changed tool selection or + completion behavior. +4. Re-record narrowly when wire behavior changed, then review every generated + capture. +5. Enable fixed variants and remove stale entries, comments, config flags, and + orphaned captures together. diff --git a/src/vs/platform/agentHost/test/node/e2e/README.md b/src/vs/platform/agentHost/test/node/e2e/README.md index 35434bf52d5a79..73a06f3a644265 100644 --- a/src/vs/platform/agentHost/test/node/e2e/README.md +++ b/src/vs/platform/agentHost/test/node/e2e/README.md @@ -68,6 +68,7 @@ Key properties: | `harness/` | Record/replay, AHP snapshots, shared turn drivers, and server lifecycle. | | `captures/*.yaml` | Committed model fixtures, plus one shared strict empty fixture for tests that declare no model traffic. | | `providers/__snapshots__/` | Semantic AHP snapshots for deterministic provider tests. | +| [`KNOWN_ISSUES.md`](./KNOWN_ISSUES.md) | Inventory and reevaluation process for disabled or conditional tests. | Use these deterministic E2E tests when the value comes from running the bundled provider process with realistic captured model behavior: SDK event ordering, tool schemas and execution, provider persistence, protocol-to-provider mapping, or cross-provider parity. Use `../providerIntegration/` for a real provider with a synthetic local LLM, `../protocol/` when `ScriptedMockAgent` can express the AHP contract precisely, and an ordinary unit test when no server process is required. @@ -173,6 +174,20 @@ Every successful coverage run rewrites the checked-in stats. Test, report, or no Per-provider reports are deferred until there is a concrete need. Per-test attribution is also intentionally out of scope for native aggregate coverage; it would require inspector-based precise coverage snapshots and deltas. +### Coverage expansion strategy + +Coverage is a discovery tool, not the goal by itself. A coverage expansion round should add tests for meaningful full-stack contracts, not manufacture line hits or add equivalent prompt variants. + +1. **Measure before selecting work.** Save a fresh baseline from `npm run test-agent-host-e2e-coverage`, rank loaded files by uncovered executable lines/functions, then inspect the exact LCOV ranges and existing lower-layer tests. Compare the final result against that same run, not an older checked-in baseline. +2. **Choose behavior that belongs at this boundary.** Prefer behavior whose value comes from the real server, provider SDK/CLI, AHP transport, persistence, or local tools working together. Pure reducer rules and provider-independent validation usually belong in protocol or unit tests instead. +3. **Prioritize useful breadth.** Favor underrepresented host-owned behavior and cross-provider contracts over more variants of an already-covered prompt. Count shared test declarations separately from provider executions (one shared declaration normally executes once per enabled provider). +4. **Choose the model boundary explicitly.** Use `hostOnlyTest(...)` when crossing the model boundary would be a bug. Use a normal test plus a per-test capture when realistic model behavior drives the scenario. +5. **Use the narrowest durable oracle.** Follow the snapshot/direct/hybrid guidance below. Assert external effects directly, and snapshot a protocol transcript only when its ordering, routing, or lifecycle is part of the contract. +6. **Design for every CI platform.** Do not assume POSIX paths, shell syntax, PTY chunk boundaries, shell-integration events, persistent terminal titles, or immediately releasable filesystem locks. Use precise platform/provider gates for genuinely unsupported behavior rather than weakening assertions, and keep [`KNOWN_ISSUES.md`](./KNOWN_ISSUES.md) current when a variant is disabled. +7. **Keep shared-server isolation.** Drain every model-backed turn, dispose terminals and other owned resources, and keep temporary work inside tracked test directories. A failure that wedges later tests is a lifecycle bug in the test even if its own assertion passed. + +A round is complete when TypeScript type-checks, focused replay passes for every enabled provider, model-backed artifacts are reviewed, host-only tests remain strict in recording mode, the full coverage command succeeds, hygiene and layer checks pass, and the measured covered counts/percentages are reported. Native V8 totals have small asynchronous variance; treat broad unrelated failures by rerunning the exact failures in a fresh process before changing code. + --- ## Updating snapshots and fixtures diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-disposing-a-peer-after-a-completed-turn-removes-it-from-the-catalog.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-disposing-a-peer-after-a-completed-turn-removes-it-from-the-catalog.yaml new file mode 100644 index 00000000000000..e1505614be7b18 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-disposing-a-peer-after-a-completed-turn-removes-it-from-the-catalog.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Reply exactly "DONE". + response: + content: DONE + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-completes-a-simple-turn.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-completes-a-simple-turn.yaml new file mode 100644 index 00000000000000..81970d9a89a307 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-completes-a-simple-turn.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Reply exactly "PEER_OK". + response: + content: PEER_OK + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-a-nested-directory.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-a-nested-directory.yaml new file mode 100644 index 00000000000000..0ed78af68ccbe2 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-a-nested-directory.yaml @@ -0,0 +1,43 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/peer-output/report.txt containing exactly PEER_NESTED. + response: + content: + - type: text + text: I'll create that file for you. + - type: tool_use + id: toolcall_0 + name: Write + input: + file_path: ${workdir}/peer-output/report.txt + content: PEER_NESTED + stopReason: tool_use + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/peer-output/report.txt containing exactly PEER_NESTED. + - role: assistant + content: + - type: text + text: I'll create that file for you. + - type: tool_use + name: Write + input: + file_path: ${workdir}/peer-output/report.txt + content: PEER_NESTED + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'File created successfully at: ${workdir}/peer-output/report.txt (file state is current in your context — no need to Read it back)' + response: + content: Done. Created `report.txt` containing exactly `PEER_NESTED`. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-the-parent-workspace.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-the-parent-workspace.yaml new file mode 100644 index 00000000000000..f40650f4f9c8c7 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-creates-a-file-in-the-parent-workspace.yaml @@ -0,0 +1,43 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/peer-created.txt containing exactly PEER_CREATED. + response: + content: + - type: text + text: I'll create that file. + - type: tool_use + id: toolcall_0 + name: Write + input: + file_path: ${workdir}/peer-created.txt + content: PEER_CREATED + stopReason: tool_use + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/peer-created.txt containing exactly PEER_CREATED. + - role: assistant + content: + - type: text + text: I'll create that file. + - type: tool_use + name: Write + input: + file_path: ${workdir}/peer-created.txt + content: PEER_CREATED + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'File created successfully at: ${workdir}/peer-created.txt (file state is current in your context — no need to Read it back)' + response: + content: Done — created `peer-created.txt` containing exactly `PEER_CREATED`. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-edits-an-existing-workspace-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-edits-an-existing-workspace-file.yaml new file mode 100644 index 00000000000000..c6104ca4ef3fb7 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-edits-an-existing-workspace-file.yaml @@ -0,0 +1,134 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Replace the complete contents of ${workdir}/peer-edit.txt with AFTER_PEER. + response: + content: + - type: text + text: I'll replace the file contents. + - type: tool_use + id: toolcall_0 + name: Bash + input: + command: test -f ${workdir}/peer-edit.txt && echo EXISTS || echo MISSING + description: Check if target file exists + stopReason: tool_use + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Replace the complete contents of ${workdir}/peer-edit.txt with AFTER_PEER. + - role: assistant + content: + - type: text + text: I'll replace the file contents. + - type: tool_use + name: Bash + input: + command: test -f ${workdir}/peer-edit.txt && echo EXISTS || echo MISSING + description: Check if target file exists + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: EXISTS + response: + content: + - type: tool_use + id: toolcall_1 + name: Read + input: + file_path: ${workdir}/peer-edit.txt + stopReason: tool_use + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Replace the complete contents of ${workdir}/peer-edit.txt with AFTER_PEER. + - role: assistant + content: + - type: text + text: I'll replace the file contents. + - type: tool_use + name: Bash + input: + command: test -f ${workdir}/peer-edit.txt && echo EXISTS || echo MISSING + description: Check if target file exists + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: EXISTS + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/peer-edit.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: "1\tBEFORE_PEER" + response: + content: + - type: tool_use + id: toolcall_2 + name: Write + input: + file_path: ${workdir}/peer-edit.txt + content: AFTER_PEER + stopReason: tool_use + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Replace the complete contents of ${workdir}/peer-edit.txt with AFTER_PEER. + - role: assistant + content: + - type: text + text: I'll replace the file contents. + - type: tool_use + name: Bash + input: + command: test -f ${workdir}/peer-edit.txt && echo EXISTS || echo MISSING + description: Check if target file exists + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: EXISTS + - role: assistant + content: + - type: tool_use + name: Read + input: + file_path: ${workdir}/peer-edit.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: "1\tBEFORE_PEER" + - role: assistant + content: + - type: tool_use + name: Write + input: + file_path: ${workdir}/peer-edit.txt + content: AFTER_PEER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_2 + content: The file ${workdir}/peer-edit.txt has been updated successfully. (file state is current in your context — no need to Read it back) + response: + content: Done. Replaced the contents of `peer-edit.txt` (was `BEFORE_PEER`) with `AFTER_PEER`. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-handles-a-missing-workspace-file-without-an-error.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-handles-a-missing-workspace-file-without-an-error.yaml new file mode 100644 index 00000000000000..16c711d66c4e44 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-handles-a-missing-workspace-file-without-an-error.yaml @@ -0,0 +1,41 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Try to read ${workdir}/peer-missing.txt. If it does not exist, reply exactly "missing". + response: + content: + - type: text + text: I'll try to read that file. + - type: tool_use + id: toolcall_0 + name: Read + input: + file_path: ${workdir}/peer-missing.txt + stopReason: tool_use + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Try to read ${workdir}/peer-missing.txt. If it does not exist, reply exactly "missing". + - role: assistant + content: + - type: text + text: I'll try to read that file. + - type: tool_use + name: Read + input: + file_path: ${workdir}/peer-missing.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'File does not exist. Note: your current working directory is ${workdir}.' + response: + content: missing + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-a-nested-directory.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-a-nested-directory.yaml new file mode 100644 index 00000000000000..09413355ab8cab --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-a-nested-directory.yaml @@ -0,0 +1,41 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Read the file at ${workdir}/nested/peer.txt and reply with its exact contents only. + response: + content: + - type: text + text: I'll read the file. + - type: tool_use + id: toolcall_0 + name: Read + input: + file_path: ${workdir}/nested/peer.txt + stopReason: tool_use + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Read the file at ${workdir}/nested/peer.txt and reply with its exact contents only. + - role: assistant + content: + - type: text + text: I'll read the file. + - type: tool_use + name: Read + input: + file_path: ${workdir}/nested/peer.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tPEER_NESTED_READ" + response: + content: PEER_NESTED_READ + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-the-parent-workspace.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-the-parent-workspace.yaml new file mode 100644 index 00000000000000..211f935dd4bbec --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-file-from-the-parent-workspace.yaml @@ -0,0 +1,41 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Read the file at ${workdir}/peer-note.txt and reply with its exact contents only. + response: + content: + - type: text + text: I'll read the file. + - type: tool_use + id: toolcall_0 + name: Read + input: + file_path: ${workdir}/peer-note.txt + stopReason: tool_use + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Read the file at ${workdir}/peer-note.txt and reply with its exact contents only. + - role: assistant + content: + - type: text + text: I'll read the file. + - type: tool_use + name: Read + input: + file_path: ${workdir}/peer-note.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tPEER_FILE_VALUE" + response: + content: PEER_FILE_VALUE + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-filename-containing-spaces.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-filename-containing-spaces.yaml new file mode 100644 index 00000000000000..79e5e0c459be90 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-reads-a-filename-containing-spaces.yaml @@ -0,0 +1,38 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Read the file at ${workdir}/peer file.txt and reply with its exact contents only. + response: + content: + - type: tool_use + id: toolcall_0 + name: Read + input: + file_path: ${workdir}/peer file.txt + stopReason: tool_use + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Read the file at ${workdir}/peer file.txt and reply with its exact contents only. + - role: assistant + content: + - type: thinking + - type: tool_use + name: Read + input: + file_path: ${workdir}/peer file.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: "1\tPEER_SPACED" + response: + content: PEER_SPACED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-retains-context-across-consecutive-turns.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-retains-context-across-consecutive-turns.yaml new file mode 100644 index 00000000000000..5c8182bca15aca --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-chat-retains-context-across-consecutive-turns.yaml @@ -0,0 +1,31 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Remember the code word PEAR. Reply exactly "ready". + response: + content: |- + I'll remember the code word PEAR. + + ready + stopReason: end_turn + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Remember the code word PEAR. Reply exactly "ready". + - role: assistant + content: |- + I'll remember the code word PEAR. + + ready + - role: user + content: What code word did I ask you to remember? Reply with only the code word. + response: + content: PEAR + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-multiple-simple-attachments-reach-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-multiple-simple-attachments-reach-the-provider-request.yaml new file mode 100644 index 00000000000000..bf226e496da938 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-multiple-simple-attachments-reach-the-provider-request.yaml @@ -0,0 +1,19 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: + - type: text + text: Reply exactly "attachments". + - type: text + text: |- + PEER_FIRST_ATTACHMENT + + PEER_SECOND_ATTACHMENT + response: + content: attachments + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-provider-context-survives-unsubscribe-and-resubscribe.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-provider-context-survives-unsubscribe-and-resubscribe.yaml new file mode 100644 index 00000000000000..175cfd8eca1e8b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-provider-context-survives-unsubscribe-and-resubscribe.yaml @@ -0,0 +1,31 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Remember the code word RESUME_PEER. Reply exactly "ready". + response: + content: |- + I'll remember the code word RESUME_PEER. + + ready + stopReason: end_turn + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Remember the code word RESUME_PEER. Reply exactly "ready". + - role: assistant + content: |- + I'll remember the code word RESUME_PEER. + + ready + - role: user + content: Reply exactly "resumed". + response: + content: resumed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-attachment-reaches-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-attachment-reaches-the-provider-request.yaml new file mode 100644 index 00000000000000..2fec9bc2bf3631 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-attachment-reaches-the-provider-request.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Reply exactly "attachment". + response: + content: attachment + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-selection-attachment-includes-its-line-reference.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-selection-attachment-includes-its-line-reference.yaml new file mode 100644 index 00000000000000..acd49a1083b80b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-resource-selection-attachment-includes-its-line-reference.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Reply exactly "selection". + response: + content: selection + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-reaches-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-reaches-the-provider-request.yaml new file mode 100644 index 00000000000000..e9b3cb79eed905 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-reaches-the-provider-request.yaml @@ -0,0 +1,16 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: + - type: text + text: Reply exactly "attachment". + - type: text + text: PEER_SIMPLE_ATTACHMENT + response: + content: attachment + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-without-a-model-representation-is-omitted-from-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-without-a-model-representation-is-omitted-from-the-provider-request.yaml new file mode 100644 index 00000000000000..2fec9bc2bf3631 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-peer-simple-attachment-without-a-model-representation-is-omitted-from-the-provider-request.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Reply exactly "attachment". + response: + content: attachment + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-recreated-peer-chat-starts-with-fresh-provider-context.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-recreated-peer-chat-starts-with-fresh-provider-context.yaml new file mode 100644 index 00000000000000..444dd611cb5335 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-recreated-peer-chat-starts-with-fresh-provider-context.yaml @@ -0,0 +1,24 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Remember the code word OLD_PEER. Reply exactly "ready". + response: + content: |- + I'll remember the code word OLD_PEER. + + ready + stopReason: end_turn + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Reply exactly "new". + response: + content: new + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-keep-independent-provider-contexts.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-keep-independent-provider-contexts.yaml new file mode 100644 index 00000000000000..ff6adcb6df4241 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-keep-independent-provider-contexts.yaml @@ -0,0 +1,59 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Remember the code word ALPHA_PEER. Reply exactly "ready". + response: + content: |- + I'll remember the code word ALPHA_PEER. + + ready + stopReason: end_turn + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Remember the code word BETA_PEER. Reply exactly "ready". + response: + content: |- + I'll remember the code word BETA_PEER. + + ready + stopReason: end_turn + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Remember the code word ALPHA_PEER. Reply exactly "ready". + - role: assistant + content: |- + I'll remember the code word ALPHA_PEER. + + ready + - role: user + content: Reply exactly "first". + response: + content: first + stopReason: end_turn + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Remember the code word BETA_PEER. Reply exactly "ready". + - role: assistant + content: |- + I'll remember the code word BETA_PEER. + + ready + - role: user + content: Reply exactly "second". + response: + content: second + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-write-distinct-workspace-files.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-write-distinct-workspace-files.yaml new file mode 100644 index 00000000000000..6961edb311a3b6 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/claude-two-peer-chats-write-distinct-workspace-files.yaml @@ -0,0 +1,83 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/first-peer.txt containing exactly FIRST_PEER. + response: + content: + - type: text + text: I'll create that file for you. + - type: tool_use + id: toolcall_0 + name: Write + input: + file_path: ${workdir}/first-peer.txt + content: FIRST_PEER + stopReason: tool_use + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/first-peer.txt containing exactly FIRST_PEER. + - role: assistant + content: + - type: text + text: I'll create that file for you. + - type: tool_use + name: Write + input: + file_path: ${workdir}/first-peer.txt + content: FIRST_PEER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 'File created successfully at: ${workdir}/first-peer.txt (file state is current in your context — no need to Read it back)' + response: + content: Created `first-peer.txt` containing exactly `FIRST_PEER`. + stopReason: end_turn + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/second-peer.txt containing exactly SECOND_PEER. + response: + content: + - type: text + text: I'll create that file. + - type: tool_use + id: toolcall_1 + name: Write + input: + file_path: ${workdir}/second-peer.txt + content: SECOND_PEER + stopReason: tool_use + - request: + model: claude-opus-4.8 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/second-peer.txt containing exactly SECOND_PEER. + - role: assistant + content: + - type: text + text: I'll create that file. + - type: tool_use + name: Write + input: + file_path: ${workdir}/second-peer.txt + content: SECOND_PEER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: 'File created successfully at: ${workdir}/second-peer.txt (file state is current in your context — no need to Read it back)' + response: + content: Created `second-peer.txt` containing exactly `SECOND_PEER`. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-disposing-a-peer-after-a-completed-turn-removes-it-from-the-catalog.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-disposing-a-peer-after-a-completed-turn-removes-it-from-the-catalog.yaml new file mode 100644 index 00000000000000..79a63cd610adb1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-disposing-a-peer-after-a-completed-turn-removes-it-from-the-catalog.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "DONE". + response: + content: DONE + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-forked-peer-chat-inherits-source-history-through-the-provider.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-forked-peer-chat-inherits-source-history-through-the-provider.yaml new file mode 100644 index 00000000000000..5d5b4edbfb423a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-forked-peer-chat-inherits-source-history-through-the-provider.yaml @@ -0,0 +1,25 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the code word FORKCODE. Reply exactly "ready". + response: + content: ready + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the code word FORKCODE. Reply exactly "ready". + - role: assistant + content: ready + - role: user + content: What code word did I ask you to remember? Reply with only the code word. + response: + content: FORKCODE + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-fresh-peer-chat-does-not-inherit-default-chat-context.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-fresh-peer-chat-does-not-inherit-default-chat-context.yaml new file mode 100644 index 00000000000000..f7462742a8b72e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-fresh-peer-chat-does-not-inherit-default-chat-context.yaml @@ -0,0 +1,21 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the code word DEFAULTSECRET. Reply exactly "ready". + response: + content: ready + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "fresh". + response: + content: fresh + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-completes-a-simple-turn.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-completes-a-simple-turn.yaml new file mode 100644 index 00000000000000..2fb1a3d63f87cd --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-completes-a-simple-turn.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "PEER_OK". + response: + content: PEER_OK + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-creates-a-file-in-a-nested-directory.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-creates-a-file-in-a-nested-directory.yaml new file mode 100644 index 00000000000000..ae1f6c5797ab2f --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-creates-a-file-in-a-nested-directory.yaml @@ -0,0 +1,80 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/peer-output/report.txt containing exactly PEER_NESTED. + response: + content: + - type: tool_use + id: toolcall_0 + name: bash + input: + command: mkdir -p ${workdir}/peer-output + description: Create peer-output directory + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/peer-output/report.txt containing exactly PEER_NESTED. + - role: assistant + content: + - type: thinking + - type: tool_use + name: bash + input: + command: mkdir -p ${workdir}/peer-output + description: Create peer-output directory + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '' + response: + content: + - type: tool_use + id: toolcall_1 + name: create + input: + path: ${workdir}/peer-output/report.txt + file_text: PEER_NESTED + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/peer-output/report.txt containing exactly PEER_NESTED. + - role: assistant + content: + - type: thinking + - type: tool_use + name: bash + input: + command: mkdir -p ${workdir}/peer-output + description: Create peer-output directory + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: '' + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/peer-output/report.txt + file_text: PEER_NESTED + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Created file ${workdir}/peer-output/report.txt with 11 characters + response: + content: Created the file with exactly `PEER_NESTED` (no trailing newline) at [report.txt](${workdir}/peer-output/report.txt). + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-creates-a-file-in-the-parent-workspace.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-creates-a-file-in-the-parent-workspace.yaml new file mode 100644 index 00000000000000..14638d4bd2e2d8 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-creates-a-file-in-the-parent-workspace.yaml @@ -0,0 +1,39 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/peer-created.txt containing exactly PEER_CREATED. + response: + content: + - type: tool_use + id: toolcall_0 + name: create + input: + path: ${workdir}/peer-created.txt + file_text: PEER_CREATED + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/peer-created.txt containing exactly PEER_CREATED. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/peer-created.txt + file_text: PEER_CREATED + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/peer-created.txt with 12 characters + response: + content: Created [peer-created.txt](${workdir}/peer-created.txt) with the content `PEER_CREATED`. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-edits-an-existing-workspace-file.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-edits-an-existing-workspace-file.yaml new file mode 100644 index 00000000000000..51106c633c7abe --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-edits-an-existing-workspace-file.yaml @@ -0,0 +1,49 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Replace the complete contents of ${workdir}/peer-edit.txt with AFTER_PEER. + response: + content: + - type: tool_use + id: toolcall_0 + name: bash + input: + command: |- + cat > "${workdir}/peer-edit.txt" << 'EOF' + AFTER_PEER + EOF + cat "${workdir}/peer-edit.txt" + description: Replace contents of peer-edit.txt + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Replace the complete contents of ${workdir}/peer-edit.txt with AFTER_PEER. + - role: assistant + content: + - type: tool_use + name: bash + input: + command: |- + cat > "${workdir}/peer-edit.txt" << 'EOF' + AFTER_PEER + EOF + cat "${workdir}/peer-edit.txt" + description: Replace contents of peer-edit.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + AFTER_PEER + + response: + content: Done — the file now contains only `AFTER_PEER`. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-handles-a-missing-workspace-file-without-an-error.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-handles-a-missing-workspace-file-without-an-error.yaml new file mode 100644 index 00000000000000..2a728c18a4d977 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-handles-a-missing-workspace-file-without-an-error.yaml @@ -0,0 +1,41 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Try to read ${workdir}/peer-missing.txt. If it does not exist, reply exactly "missing". + response: + content: + - type: tool_use + id: toolcall_0 + name: bash + input: + command: cat "${workdir}/peer-missing.txt" 2>&1 + description: Check if file exists + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Try to read ${workdir}/peer-missing.txt. If it does not exist, reply exactly "missing". + - role: assistant + content: + - type: tool_use + name: bash + input: + command: cat "${workdir}/peer-missing.txt" 2>&1 + description: Check if file exists + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + cat: ${workdir}/peer-missing.txt: No such file or directory + + response: + content: missing + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-reads-a-file-from-a-nested-directory.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-reads-a-file-from-a-nested-directory.yaml new file mode 100644 index 00000000000000..4e954fe57f8529 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-reads-a-file-from-a-nested-directory.yaml @@ -0,0 +1,41 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Read the file at ${workdir}/nested/peer.txt and reply with its exact contents only. + response: + content: + - type: tool_use + id: toolcall_0 + name: bash + input: + command: cat "${workdir}/nested/peer.txt" + description: Read peer.txt file + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Read the file at ${workdir}/nested/peer.txt and reply with its exact contents only. + - role: assistant + content: + - type: tool_use + name: bash + input: + command: cat "${workdir}/nested/peer.txt" + description: Read peer.txt file + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: |- + PEER_NESTED_READ + + response: + content: PEER_NESTED_READ + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-reads-a-file-from-the-parent-workspace.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-reads-a-file-from-the-parent-workspace.yaml new file mode 100644 index 00000000000000..4e1aee98405598 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-reads-a-file-from-the-parent-workspace.yaml @@ -0,0 +1,37 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Read the file at ${workdir}/peer-note.txt and reply with its exact contents only. + response: + content: + - type: tool_use + id: toolcall_0 + name: view + input: + path: ${workdir}/peer-note.txt + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Read the file at ${workdir}/peer-note.txt and reply with its exact contents only. + - role: assistant + content: + - type: tool_use + name: view + input: + path: ${workdir}/peer-note.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 1. PEER_FILE_VALUE + response: + content: PEER_FILE_VALUE + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-reads-a-filename-containing-spaces.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-reads-a-filename-containing-spaces.yaml new file mode 100644 index 00000000000000..669cb2841f05df --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-reads-a-filename-containing-spaces.yaml @@ -0,0 +1,37 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Read the file at ${workdir}/peer file.txt and reply with its exact contents only. + response: + content: + - type: tool_use + id: toolcall_0 + name: view + input: + path: ${workdir}/peer file.txt + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Read the file at ${workdir}/peer file.txt and reply with its exact contents only. + - role: assistant + content: + - type: tool_use + name: view + input: + path: ${workdir}/peer file.txt + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: 1. PEER_SPACED + response: + content: PEER_SPACED + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-retains-context-across-consecutive-turns.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-retains-context-across-consecutive-turns.yaml new file mode 100644 index 00000000000000..5ec623e4dfad74 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-chat-retains-context-across-consecutive-turns.yaml @@ -0,0 +1,25 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the code word PEAR. Reply exactly "ready". + response: + content: ready + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the code word PEAR. Reply exactly "ready". + - role: assistant + content: ready + - role: user + content: What code word did I ask you to remember? Reply with only the code word. + response: + content: PEAR + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-multiple-simple-attachments-reach-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-multiple-simple-attachments-reach-the-provider-request.yaml new file mode 100644 index 00000000000000..5c9567ca8100a6 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-multiple-simple-attachments-reach-the-provider-request.yaml @@ -0,0 +1,24 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: + - type: text + text: Reply exactly "attachments". + - type: text + text: |- + + PEER_FIRST_ATTACHMENT + + - type: text + text: |- + + PEER_SECOND_ATTACHMENT + + response: + content: attachments + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-provider-context-survives-unsubscribe-and-resubscribe.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-provider-context-survives-unsubscribe-and-resubscribe.yaml new file mode 100644 index 00000000000000..5d70b6a6c9765e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-provider-context-survives-unsubscribe-and-resubscribe.yaml @@ -0,0 +1,25 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the code word RESUME_PEER. Reply exactly "ready". + response: + content: ready + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the code word RESUME_PEER. Reply exactly "ready". + - role: assistant + content: ready + - role: user + content: Reply exactly "resumed". + response: + content: resumed + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-resource-attachment-reaches-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-resource-attachment-reaches-the-provider-request.yaml new file mode 100644 index 00000000000000..fbb61e5774ae14 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-resource-attachment-reaches-the-provider-request.yaml @@ -0,0 +1,17 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: |- + Reply exactly "attachment". + + + * ${workdir}/peer-resource.txt (1 lines) + + response: + content: attachment + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-resource-selection-attachment-includes-its-line-reference.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-resource-selection-attachment-includes-its-line-reference.yaml new file mode 100644 index 00000000000000..f5a18a9bfd79bf --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-resource-selection-attachment-includes-its-line-reference.yaml @@ -0,0 +1,21 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: |- + + The user has the following text selected in their IDE. This may or may not be related to their request. + File: peer-selection.txt (line 2) + ``` + second + ``` + + + Reply exactly "selection". + response: + content: selection + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-simple-attachment-reaches-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-simple-attachment-reaches-the-provider-request.yaml new file mode 100644 index 00000000000000..c7c763b1f7bfbc --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-simple-attachment-reaches-the-provider-request.yaml @@ -0,0 +1,19 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: + - type: text + text: Reply exactly "attachment". + - type: text + text: |- + + PEER_SIMPLE_ATTACHMENT + + response: + content: attachment + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-simple-attachment-without-a-model-representation-is-omitted-from-the-provider-request.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-simple-attachment-without-a-model-representation-is-omitted-from-the-provider-request.yaml new file mode 100644 index 00000000000000..f636bfe09e7bc4 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-peer-simple-attachment-without-a-model-representation-is-omitted-from-the-provider-request.yaml @@ -0,0 +1,12 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "attachment". + response: + content: attachment + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-recreated-peer-chat-starts-with-fresh-provider-context.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-recreated-peer-chat-starts-with-fresh-provider-context.yaml new file mode 100644 index 00000000000000..7d707654420de5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-recreated-peer-chat-starts-with-fresh-provider-context.yaml @@ -0,0 +1,21 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the code word OLD_PEER. Reply exactly "ready". + response: + content: ready + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "new". + response: + content: new + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-two-peer-chats-keep-independent-provider-contexts.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-two-peer-chats-keep-independent-provider-contexts.yaml new file mode 100644 index 00000000000000..6fd361e93f9cf7 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-two-peer-chats-keep-independent-provider-contexts.yaml @@ -0,0 +1,47 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the code word ALPHA_PEER. Reply exactly "ready". + response: + content: ready + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the code word BETA_PEER. Reply exactly "ready". + response: + content: ready + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the code word ALPHA_PEER. Reply exactly "ready". + - role: assistant + content: ready + - role: user + content: Reply exactly "first". + response: + content: first + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the code word BETA_PEER. Reply exactly "ready". + - role: assistant + content: ready + - role: user + content: Reply exactly "second". + response: + content: second + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-two-peer-chats-write-distinct-workspace-files.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-two-peer-chats-write-distinct-workspace-files.yaml new file mode 100644 index 00000000000000..5ea8e553d4a590 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-two-peer-chats-write-distinct-workspace-files.yaml @@ -0,0 +1,75 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/first-peer.txt containing exactly FIRST_PEER. + response: + content: + - type: tool_use + id: toolcall_0 + name: create + input: + path: ${workdir}/first-peer.txt + file_text: FIRST_PEER + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/first-peer.txt containing exactly FIRST_PEER. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/first-peer.txt + file_text: FIRST_PEER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_0 + content: Created file ${workdir}/first-peer.txt with 10 characters + response: + content: Created [first-peer.txt](${workdir}/first-peer.txt) with the exact content `FIRST_PEER`. + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/second-peer.txt containing exactly SECOND_PEER. + response: + content: + - type: tool_use + id: toolcall_1 + name: create + input: + path: ${workdir}/second-peer.txt + file_text: SECOND_PEER + stopReason: tool_use + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Create the file at ${workdir}/second-peer.txt containing exactly SECOND_PEER. + - role: assistant + content: + - type: tool_use + name: create + input: + path: ${workdir}/second-peer.txt + file_text: SECOND_PEER + - role: user + content: + - type: tool_result + tool_use_id: toolcall_1 + content: Created file ${workdir}/second-peer.txt with 11 characters + response: + content: Created the file with the exact content `SECOND_PEER`. + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-unknown-turn-fork-does-not-inherit-source-provider-context.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-unknown-turn-fork-does-not-inherit-source-provider-context.yaml new file mode 100644 index 00000000000000..9eae2bb4ecfae2 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-unknown-turn-fork-does-not-inherit-source-provider-context.yaml @@ -0,0 +1,21 @@ +version: 1 +dialect: anthropic +exchanges: + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Remember the code word SOURCE_SECRET. Reply exactly "ready". + response: + content: ready + stopReason: end_turn + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: Reply exactly "fresh". + response: + content: fresh + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json index c72701cb155e11..7e03d44a562e20 100644 --- a/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json +++ b/src/vs/platform/agentHost/test/node/e2e/coverage/summary.json @@ -14,24 +14,24 @@ }, "total": { "statements": { - "covered": 59073, - "total": 84486, - "percentage": 69.92 + "covered": 60426, + "total": 85014, + "percentage": 71.07 }, "branches": { - "covered": 4716, - "total": 7563, - "percentage": 62.35 + "covered": 5099, + "total": 8061, + "percentage": 63.25 }, "functions": { - "covered": 1879, - "total": 3193, - "percentage": 58.84 + "covered": 1977, + "total": 3207, + "percentage": 61.64 }, "lines": { - "covered": 59073, - "total": 84486, - "percentage": 69.92 + "covered": 60426, + "total": 85014, + "percentage": 71.07 } }, "files": { @@ -169,31 +169,31 @@ }, "src/vs/platform/agentHost/common/agentHostConversationContext.ts": { "statements": { - "covered": 68, + "covered": 82, "total": 98, - "percentage": 69.38 + "percentage": 83.67 }, "branches": { - "covered": 3, - "total": 3, - "percentage": 100 + "covered": 4, + "total": 10, + "percentage": 40 }, "functions": { - "covered": 1, + "covered": 2, "total": 3, - "percentage": 33.33 + "percentage": 66.66 }, "lines": { - "covered": 68, + "covered": 82, "total": 98, - "percentage": 69.38 + "percentage": 83.67 } }, "src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts": { "statements": { - "covered": 114, - "total": 128, - "percentage": 89.06 + "covered": 127, + "total": 141, + "percentage": 90.07 }, "branches": { "covered": 1, @@ -206,9 +206,9 @@ "percentage": 0 }, "lines": { - "covered": 114, - "total": 128, - "percentage": 89.06 + "covered": 127, + "total": 141, + "percentage": 90.07 } }, "src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts": { @@ -323,14 +323,14 @@ }, "src/vs/platform/agentHost/common/agentHostSchema.ts": { "statements": { - "covered": 624, + "covered": 621, "total": 720, - "percentage": 86.66 + "percentage": 86.25 }, "branches": { - "covered": 50, - "total": 68, - "percentage": 73.52 + "covered": 46, + "total": 65, + "percentage": 70.76 }, "functions": { "covered": 15, @@ -338,9 +338,9 @@ "percentage": 68.18 }, "lines": { - "covered": 624, + "covered": 621, "total": 720, - "percentage": 86.66 + "percentage": 86.25 } }, "src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts": { @@ -455,9 +455,9 @@ }, "src/vs/platform/agentHost/common/agentService.ts": { "statements": { - "covered": 1982, - "total": 2154, - "percentage": 92.01 + "covered": 1991, + "total": 2163, + "percentage": 92.04 }, "branches": { "covered": 24, @@ -470,9 +470,9 @@ "percentage": 42.85 }, "lines": { - "covered": 1982, - "total": 2154, - "percentage": 92.01 + "covered": 1991, + "total": 2163, + "percentage": 92.04 } }, "src/vs/platform/agentHost/common/ahpJsonlLogger.ts": { @@ -851,24 +851,24 @@ }, "src/vs/platform/agentHost/common/otlp/otlpLogEmitter.ts": { "statements": { - "covered": 335, + "covered": 320, "total": 485, - "percentage": 69.07 + "percentage": 65.97 }, "branches": { - "covered": 31, - "total": 39, - "percentage": 79.48 + "covered": 22, + "total": 31, + "percentage": 70.96 }, "functions": { - "covered": 17, + "covered": 14, "total": 30, - "percentage": 56.66 + "percentage": 46.66 }, "lines": { - "covered": 335, + "covered": 320, "total": 485, - "percentage": 69.07 + "percentage": 65.97 } }, "src/vs/platform/agentHost/common/pendingRequestRegistry.ts": { @@ -1093,14 +1093,14 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts": { "statements": { - "covered": 59, + "covered": 62, "total": 121, - "percentage": 48.76 + "percentage": 51.23 }, "branches": { - "covered": 4, - "total": 14, - "percentage": 28.57 + "covered": 5, + "total": 15, + "percentage": 33.33 }, "functions": { "covered": 1, @@ -1108,21 +1108,21 @@ "percentage": 100 }, "lines": { - "covered": 59, + "covered": 62, "total": 121, - "percentage": 48.76 + "percentage": 51.23 } }, "src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts": { "statements": { - "covered": 567, + "covered": 575, "total": 809, - "percentage": 70.08 + "percentage": 71.07 }, "branches": { - "covered": 103, - "total": 171, - "percentage": 60.23 + "covered": 111, + "total": 175, + "percentage": 63.42 }, "functions": { "covered": 14, @@ -1130,9 +1130,9 @@ "percentage": 100 }, "lines": { - "covered": 567, + "covered": 575, "total": 809, - "percentage": 70.08 + "percentage": 71.07 } }, "src/vs/platform/agentHost/common/state/protocol/channels-resource-watch/reducer.ts": { @@ -1181,14 +1181,14 @@ }, "src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts": { "statements": { - "covered": 197, + "covered": 206, "total": 365, - "percentage": 53.97 + "percentage": 56.43 }, "branches": { - "covered": 35, - "total": 55, - "percentage": 63.63 + "covered": 37, + "total": 58, + "percentage": 63.79 }, "functions": { "covered": 3, @@ -1196,9 +1196,9 @@ "percentage": 75 }, "lines": { - "covered": 197, + "covered": 206, "total": 365, - "percentage": 53.97 + "percentage": 56.43 } }, "src/vs/platform/agentHost/common/state/protocol/channels-terminal/reducer.ts": { @@ -1599,24 +1599,24 @@ }, "src/vs/platform/agentHost/common/state/sessionState.ts": { "statements": { - "covered": 1004, + "covered": 1014, "total": 1306, - "percentage": 76.87 + "percentage": 77.64 }, "branches": { - "covered": 74, - "total": 109, - "percentage": 67.88 + "covered": 84, + "total": 120, + "percentage": 70 }, "functions": { - "covered": 32, + "covered": 34, "total": 52, - "percentage": 61.53 + "percentage": 65.38 }, "lines": { - "covered": 1004, + "covered": 1014, "total": 1306, - "percentage": 76.87 + "percentage": 77.64 } }, "src/vs/platform/agentHost/node/activeClientState.ts": { @@ -1648,9 +1648,9 @@ "percentage": 90.08 }, "branches": { - "covered": 28, - "total": 39, - "percentage": 71.79 + "covered": 27, + "total": 38, + "percentage": 71.05 }, "functions": { "covered": 11, @@ -1731,24 +1731,24 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts": { "statements": { - "covered": 252, + "covered": 257, "total": 340, - "percentage": 74.11 + "percentage": 75.58 }, "branches": { - "covered": 19, - "total": 39, - "percentage": 48.71 + "covered": 21, + "total": 42, + "percentage": 50 }, "functions": { - "covered": 11, + "covered": 13, "total": 15, - "percentage": 73.33 + "percentage": 86.66 }, "lines": { - "covered": 252, + "covered": 257, "total": 340, - "percentage": 74.11 + "percentage": 75.58 } }, "src/vs/platform/agentHost/node/agentHostChangesetFileMonitorCoordinator.ts": { @@ -1797,46 +1797,46 @@ }, "src/vs/platform/agentHost/node/agentHostChangesetService.ts": { "statements": { - "covered": 695, + "covered": 713, "total": 1123, - "percentage": 61.88 + "percentage": 63.49 }, "branches": { - "covered": 87, - "total": 124, - "percentage": 70.16 + "covered": 91, + "total": 132, + "percentage": 68.93 }, "functions": { - "covered": 31, + "covered": 33, "total": 52, - "percentage": 59.61 + "percentage": 63.46 }, "lines": { - "covered": 695, + "covered": 713, "total": 1123, - "percentage": 61.88 + "percentage": 63.49 } }, "src/vs/platform/agentHost/node/agentHostChangesetStateCache.ts": { "statements": { - "covered": 107, + "covered": 112, "total": 126, - "percentage": 84.92 + "percentage": 88.88 }, "branches": { - "covered": 11, - "total": 15, - "percentage": 73.33 + "covered": 13, + "total": 17, + "percentage": 76.47 }, "functions": { - "covered": 8, + "covered": 10, "total": 10, - "percentage": 80 + "percentage": 100 }, "lines": { - "covered": 107, + "covered": 112, "total": 126, - "percentage": 84.92 + "percentage": 88.88 } }, "src/vs/platform/agentHost/node/agentHostChangesetSubscriptionService.ts": { @@ -1863,14 +1863,14 @@ }, "src/vs/platform/agentHost/node/agentHostCheckpointService.ts": { "statements": { - "covered": 188, + "covered": 200, "total": 272, - "percentage": 69.11 + "percentage": 73.52 }, "branches": { - "covered": 42, - "total": 55, - "percentage": 76.36 + "covered": 46, + "total": 59, + "percentage": 77.96 }, "functions": { "covered": 11, @@ -1878,9 +1878,9 @@ "percentage": 91.66 }, "lines": { - "covered": 188, + "covered": 200, "total": 272, - "percentage": 69.11 + "percentage": 73.52 } }, "src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts": { @@ -2000,9 +2000,9 @@ "percentage": 86.85 }, "branches": { - "covered": 25, - "total": 37, - "percentage": 67.56 + "covered": 27, + "total": 39, + "percentage": 69.23 }, "functions": { "covered": 7, @@ -2017,14 +2017,14 @@ }, "src/vs/platform/agentHost/node/agentHostFileMonitorService.ts": { "statements": { - "covered": 141, + "covered": 143, "total": 185, - "percentage": 76.21 + "percentage": 77.29 }, "branches": { - "covered": 14, - "total": 22, - "percentage": 63.63 + "covered": 16, + "total": 23, + "percentage": 69.56 }, "functions": { "covered": 10, @@ -2032,9 +2032,9 @@ "percentage": 71.42 }, "lines": { - "covered": 141, + "covered": 143, "total": 185, - "percentage": 76.21 + "percentage": 77.29 } }, "src/vs/platform/agentHost/node/agentHostGitHubEndpointService.ts": { @@ -2105,14 +2105,14 @@ }, "src/vs/platform/agentHost/node/agentHostGitStateService.ts": { "statements": { - "covered": 133, + "covered": 135, "total": 202, - "percentage": 65.84 + "percentage": 66.83 }, "branches": { - "covered": 18, - "total": 34, - "percentage": 52.94 + "covered": 21, + "total": 36, + "percentage": 58.33 }, "functions": { "covered": 5, @@ -2120,9 +2120,9 @@ "percentage": 83.33 }, "lines": { - "covered": 133, + "covered": 135, "total": 202, - "percentage": 65.84 + "percentage": 66.83 } }, "src/vs/platform/agentHost/node/agentHostHeadlessTerminal.ts": { @@ -2149,24 +2149,24 @@ }, "src/vs/platform/agentHost/node/agentHostLocalTurns.ts": { "statements": { - "covered": 126, + "covered": 128, "total": 161, - "percentage": 78.26 + "percentage": 79.5 }, "branches": { - "covered": 9, - "total": 18, + "covered": 10, + "total": 20, "percentage": 50 }, "functions": { - "covered": 8, + "covered": 9, "total": 11, - "percentage": 72.72 + "percentage": 81.81 }, "lines": { - "covered": 126, + "covered": 128, "total": 161, - "percentage": 78.26 + "percentage": 79.5 } }, "src/vs/platform/agentHost/node/agentHostMicrosoftTelemetry.ts": { @@ -2264,9 +2264,9 @@ "percentage": 97.56 }, "branches": { - "covered": 10, - "total": 11, - "percentage": 90.9 + "covered": 11, + "total": 12, + "percentage": 91.66 }, "functions": { "covered": 3, @@ -2369,24 +2369,24 @@ }, "src/vs/platform/agentHost/node/agentHostSessionTitleController.ts": { "statements": { - "covered": 387, + "covered": 437, "total": 507, - "percentage": 76.33 + "percentage": 86.19 }, "branches": { - "covered": 49, - "total": 75, - "percentage": 65.33 + "covered": 79, + "total": 100, + "percentage": 79 }, "functions": { - "covered": 20, - "total": 26, - "percentage": 76.92 + "covered": 22, + "total": 28, + "percentage": 78.57 }, "lines": { - "covered": 387, + "covered": 437, "total": 507, - "percentage": 76.33 + "percentage": 86.19 } }, "src/vs/platform/agentHost/node/agentHostShellUtils.ts": { @@ -2457,24 +2457,24 @@ }, "src/vs/platform/agentHost/node/agentHostStateManager.ts": { "statements": { - "covered": 1207, + "covered": 1283, "total": 1437, - "percentage": 83.99 + "percentage": 89.28 }, "branches": { - "covered": 148, - "total": 193, - "percentage": 76.68 + "covered": 180, + "total": 226, + "percentage": 79.64 }, "functions": { - "covered": 44, + "covered": 50, "total": 60, - "percentage": 73.33 + "percentage": 83.33 }, "lines": { - "covered": 1207, + "covered": 1283, "total": 1437, - "percentage": 83.99 + "percentage": 89.28 } }, "src/vs/platform/agentHost/node/agentHostSyncOperationHandler.ts": { @@ -2572,9 +2572,9 @@ "percentage": 88.78 }, "branches": { - "covered": 98, - "total": 126, - "percentage": 77.77 + "covered": 97, + "total": 125, + "percentage": 77.6 }, "functions": { "covered": 29, @@ -2589,14 +2589,14 @@ }, "src/vs/platform/agentHost/node/agentHostToolCallTracker.ts": { "statements": { - "covered": 158, + "covered": 164, "total": 203, - "percentage": 77.83 + "percentage": 80.78 }, "branches": { - "covered": 21, + "covered": 23, "total": 30, - "percentage": 70 + "percentage": 76.66 }, "functions": { "covered": 10, @@ -2604,9 +2604,9 @@ "percentage": 100 }, "lines": { - "covered": 158, + "covered": 164, "total": 203, - "percentage": 77.83 + "percentage": 80.78 } }, "src/vs/platform/agentHost/node/agentHostTurnTracker.ts": { @@ -2660,9 +2660,9 @@ "percentage": 73.03 }, "branches": { - "covered": 11, - "total": 22, - "percentage": 50 + "covered": 13, + "total": 24, + "percentage": 54.16 }, "functions": { "covered": 5, @@ -2677,46 +2677,46 @@ }, "src/vs/platform/agentHost/node/agentPeerChats.ts": { "statements": { - "covered": 129, + "covered": 145, "total": 182, - "percentage": 70.87 + "percentage": 79.67 }, "branches": { - "covered": 9, - "total": 11, - "percentage": 81.81 + "covered": 23, + "total": 24, + "percentage": 95.83 }, "functions": { - "covered": 8, + "covered": 14, "total": 17, - "percentage": 47.05 + "percentage": 82.35 }, "lines": { - "covered": 129, + "covered": 145, "total": 182, - "percentage": 70.87 + "percentage": 79.67 } }, "src/vs/platform/agentHost/node/agentPluginManager.ts": { "statements": { - "covered": 183, + "covered": 152, "total": 292, - "percentage": 62.67 + "percentage": 52.05 }, "branches": { - "covered": 19, - "total": 23, - "percentage": 82.6 + "covered": 6, + "total": 7, + "percentage": 85.71 }, "functions": { - "covered": 6, + "covered": 3, "total": 19, - "percentage": 31.57 + "percentage": 15.78 }, "lines": { - "covered": 183, + "covered": 152, "total": 292, - "percentage": 62.67 + "percentage": 52.05 } }, "src/vs/platform/agentHost/node/agentSdkDownloader.ts": { @@ -2743,46 +2743,46 @@ }, "src/vs/platform/agentHost/node/agentService.ts": { "statements": { - "covered": 1934, + "covered": 2298, "total": 3742, - "percentage": 51.68 + "percentage": 61.41 }, "branches": { - "covered": 190, - "total": 296, - "percentage": 64.18 + "covered": 252, + "total": 410, + "percentage": 61.46 }, "functions": { - "covered": 64, + "covered": 80, "total": 136, - "percentage": 47.05 + "percentage": 58.82 }, "lines": { - "covered": 1934, + "covered": 2298, "total": 3742, - "percentage": 51.68 + "percentage": 61.41 } }, "src/vs/platform/agentHost/node/agentSideEffects.ts": { "statements": { - "covered": 1344, - "total": 1644, - "percentage": 81.75 + "covered": 1337, + "total": 1619, + "percentage": 82.58 }, "branches": { - "covered": 207, - "total": 288, - "percentage": 71.87 + "covered": 223, + "total": 295, + "percentage": 75.59 }, "functions": { "covered": 45, - "total": 49, - "percentage": 91.83 + "total": 48, + "percentage": 93.75 }, "lines": { - "covered": 1344, - "total": 1644, - "percentage": 81.75 + "covered": 1337, + "total": 1619, + "percentage": 82.58 } }, "src/vs/platform/agentHost/node/appNodeModules.ts": { @@ -2875,68 +2875,68 @@ }, "src/vs/platform/agentHost/node/claude/claudeAgent.ts": { "statements": { - "covered": 1385, + "covered": 1633, "total": 2241, - "percentage": 61.8 + "percentage": 72.86 }, "branches": { - "covered": 92, - "total": 151, - "percentage": 60.92 + "covered": 151, + "total": 241, + "percentage": 62.65 }, "functions": { - "covered": 51, + "covered": 66, "total": 89, - "percentage": 57.3 + "percentage": 74.15 }, "lines": { - "covered": 1385, + "covered": 1633, "total": 2241, - "percentage": 61.8 + "percentage": 72.86 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts": { "statements": { - "covered": 246, + "covered": 252, "total": 295, - "percentage": 83.38 + "percentage": 85.42 }, "branches": { - "covered": 11, - "total": 15, - "percentage": 73.33 + "covered": 13, + "total": 17, + "percentage": 76.47 }, "functions": { - "covered": 9, + "covered": 11, "total": 15, - "percentage": 60 + "percentage": 73.33 }, "lines": { - "covered": 246, + "covered": 252, "total": 295, - "percentage": 83.38 + "percentage": 85.42 } }, "src/vs/platform/agentHost/node/claude/claudeAgentSession.ts": { "statements": { - "covered": 905, + "covered": 911, "total": 1121, - "percentage": 80.73 + "percentage": 81.26 }, "branches": { - "covered": 43, - "total": 72, - "percentage": 59.72 + "covered": 51, + "total": 80, + "percentage": 63.75 }, "functions": { - "covered": 25, + "covered": 27, "total": 51, - "percentage": 49.01 + "percentage": 52.94 }, "lines": { - "covered": 905, + "covered": 911, "total": 1121, - "percentage": 80.73 + "percentage": 81.26 } }, "src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts": { @@ -3095,46 +3095,46 @@ }, "src/vs/platform/agentHost/node/claude/claudePromptQueue.ts": { "statements": { - "covered": 143, + "covered": 155, "total": 171, - "percentage": 83.62 + "percentage": 90.64 }, "branches": { - "covered": 12, - "total": 19, - "percentage": 63.15 + "covered": 16, + "total": 23, + "percentage": 69.56 }, "functions": { - "covered": 8, - "total": 10, - "percentage": 80 + "covered": 10, + "total": 11, + "percentage": 90.9 }, "lines": { - "covered": 143, + "covered": 155, "total": 171, - "percentage": 83.62 + "percentage": 90.64 } }, "src/vs/platform/agentHost/node/claude/claudePromptResolver.ts": { "statements": { - "covered": 38, + "covered": 81, "total": 94, - "percentage": 40.42 + "percentage": 86.17 }, "branches": { - "covered": 1, - "total": 3, - "percentage": 33.33 + "covered": 17, + "total": 22, + "percentage": 77.27 }, "functions": { - "covered": 1, + "covered": 2, "total": 2, - "percentage": 50 + "percentage": 100 }, "lines": { - "covered": 38, + "covered": 81, "total": 94, - "percentage": 40.42 + "percentage": 86.17 } }, "src/vs/platform/agentHost/node/claude/claudeProxyAuth.ts": { @@ -3249,24 +3249,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts": { "statements": { - "covered": 495, + "covered": 530, "total": 703, - "percentage": 70.41 + "percentage": 75.39 }, "branches": { - "covered": 29, - "total": 46, - "percentage": 63.04 + "covered": 37, + "total": 61, + "percentage": 60.65 }, "functions": { - "covered": 16, + "covered": 19, "total": 30, - "percentage": 53.33 + "percentage": 63.33 }, "lines": { - "covered": 495, + "covered": 530, "total": 703, - "percentage": 70.41 + "percentage": 75.39 } }, "src/vs/platform/agentHost/node/claude/claudeServerToolMcpServer.ts": { @@ -3293,24 +3293,24 @@ }, "src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.ts": { "statements": { - "covered": 122, + "covered": 169, "total": 204, - "percentage": 59.8 + "percentage": 82.84 }, "branches": { - "covered": 2, - "total": 4, - "percentage": 50 + "covered": 15, + "total": 28, + "percentage": 53.57 }, "functions": { - "covered": 2, + "covered": 6, "total": 7, - "percentage": 28.57 + "percentage": 85.71 }, "lines": { - "covered": 122, + "covered": 169, "total": 204, - "percentage": 59.8 + "percentage": 82.84 } }, "src/vs/platform/agentHost/node/claude/claudeSessionPermissionMode.ts": { @@ -3337,14 +3337,14 @@ }, "src/vs/platform/agentHost/node/claude/claudeSubagentRegistry.ts": { "statements": { - "covered": 242, - "total": 265, - "percentage": 91.32 + "covered": 248, + "total": 271, + "percentage": 91.51 }, "branches": { - "covered": 33, - "total": 42, - "percentage": 78.57 + "covered": 36, + "total": 45, + "percentage": 80 }, "functions": { "covered": 15, @@ -3352,9 +3352,9 @@ "percentage": 88.23 }, "lines": { - "covered": 242, - "total": 265, - "percentage": 91.32 + "covered": 248, + "total": 271, + "percentage": 91.51 } }, "src/vs/platform/agentHost/node/claude/claudeSubagentResolver.ts": { @@ -3381,14 +3381,14 @@ }, "src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts": { "statements": { - "covered": 276, - "total": 305, - "percentage": 90.49 + "covered": 282, + "total": 311, + "percentage": 90.67 }, "branches": { "covered": 17, - "total": 36, - "percentage": 47.22 + "total": 37, + "percentage": 45.94 }, "functions": { "covered": 5, @@ -3396,9 +3396,9 @@ "percentage": 100 }, "lines": { - "covered": 276, - "total": 305, - "percentage": 90.49 + "covered": 282, + "total": 311, + "percentage": 90.67 } }, "src/vs/platform/agentHost/node/claude/claudeToolCallRegistry.ts": { @@ -3452,9 +3452,9 @@ "percentage": 74.67 }, "branches": { - "covered": 50, - "total": 125, - "percentage": 40 + "covered": 51, + "total": 126, + "percentage": 40.47 }, "functions": { "covered": 14, @@ -3733,24 +3733,24 @@ }, "src/vs/platform/agentHost/node/codex/codexAgent.ts": { "statements": { - "covered": 2460, - "total": 4092, - "percentage": 60.11 + "covered": 2462, + "total": 4094, + "percentage": 60.13 }, "branches": { - "covered": 165, - "total": 316, - "percentage": 52.21 + "covered": 166, + "total": 317, + "percentage": 52.36 }, "functions": { - "covered": 85, + "covered": 86, "total": 145, - "percentage": 58.62 + "percentage": 59.31 }, "lines": { - "covered": 2460, - "total": 4092, - "percentage": 60.11 + "covered": 2462, + "total": 4094, + "percentage": 60.13 } }, "src/vs/platform/agentHost/node/codex/codexAppServerClient.ts": { @@ -4019,14 +4019,14 @@ }, "src/vs/platform/agentHost/node/codex/codexSessionMetadataStore.ts": { "statements": { - "covered": 97, + "covered": 107, "total": 112, - "percentage": 86.6 + "percentage": 95.53 }, "branches": { - "covered": 3, - "total": 10, - "percentage": 30 + "covered": 8, + "total": 11, + "percentage": 72.72 }, "functions": { "covered": 3, @@ -4034,9 +4034,9 @@ "percentage": 100 }, "lines": { - "covered": 97, + "covered": 107, "total": 112, - "percentage": 86.6 + "percentage": 95.53 } }, "src/vs/platform/agentHost/node/codex/codexShellCommand.ts": { @@ -4085,14 +4085,14 @@ }, "src/vs/platform/agentHost/node/commandAutoApprover.ts": { "statements": { - "covered": 512, + "covered": 523, "total": 595, - "percentage": 86.05 + "percentage": 87.89 }, "branches": { - "covered": 53, - "total": 88, - "percentage": 60.22 + "covered": 60, + "total": 91, + "percentage": 65.93 }, "functions": { "covered": 14, @@ -4100,9 +4100,9 @@ "percentage": 100 }, "lines": { - "covered": 512, + "covered": 523, "total": 595, - "percentage": 86.05 + "percentage": 87.89 } }, "src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts": { @@ -4112,9 +4112,9 @@ "percentage": 70.21 }, "branches": { - "covered": 5, + "covered": 4, "total": 6, - "percentage": 83.33 + "percentage": 66.66 }, "functions": { "covered": 4, @@ -4195,46 +4195,46 @@ }, "src/vs/platform/agentHost/node/copilot/copilotAgent.ts": { "statements": { - "covered": 2653, - "total": 4198, - "percentage": 63.19 + "covered": 2920, + "total": 4229, + "percentage": 69.04 }, "branches": { - "covered": 276, - "total": 444, - "percentage": 62.16 + "covered": 319, + "total": 531, + "percentage": 60.07 }, "functions": { - "covered": 122, - "total": 186, - "percentage": 65.59 + "covered": 140, + "total": 189, + "percentage": 74.07 }, "lines": { - "covered": 2653, - "total": 4198, - "percentage": 63.19 + "covered": 2920, + "total": 4229, + "percentage": 69.04 } }, "src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts": { "statements": { - "covered": 2766, - "total": 4354, - "percentage": 63.52 + "covered": 2796, + "total": 4357, + "percentage": 64.17 }, "branches": { - "covered": 343, - "total": 561, - "percentage": 61.14 + "covered": 380, + "total": 586, + "percentage": 64.84 }, "functions": { - "covered": 92, + "covered": 103, "total": 151, - "percentage": 60.92 + "percentage": 68.21 }, "lines": { - "covered": 2766, - "total": 4354, - "percentage": 63.52 + "covered": 2796, + "total": 4357, + "percentage": 64.17 } }, "src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts": { @@ -4283,14 +4283,14 @@ }, "src/vs/platform/agentHost/node/copilot/copilotPluginConverters.ts": { "statements": { - "covered": 312, + "covered": 266, "total": 516, - "percentage": 60.46 + "percentage": 51.55 }, "branches": { - "covered": 27, - "total": 54, - "percentage": 50 + "covered": 13, + "total": 31, + "percentage": 41.93 }, "functions": { "covered": 13, @@ -4298,21 +4298,21 @@ "percentage": 54.16 }, "lines": { - "covered": 312, + "covered": 266, "total": 516, - "percentage": 60.46 + "percentage": 51.55 } }, "src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts": { "statements": { - "covered": 474, + "covered": 485, "total": 635, - "percentage": 74.64 + "percentage": 76.37 }, "branches": { - "covered": 39, - "total": 62, - "percentage": 62.9 + "covered": 38, + "total": 59, + "percentage": 64.4 }, "functions": { "covered": 19, @@ -4320,43 +4320,43 @@ "percentage": 67.85 }, "lines": { - "covered": 474, + "covered": 485, "total": 635, - "percentage": 74.64 + "percentage": 76.37 } }, "src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts": { "statements": { - "covered": 274, + "covered": 275, "total": 275, - "percentage": 99.63 + "percentage": 100 }, "branches": { - "covered": 52, - "total": 52, + "covered": 54, + "total": 54, "percentage": 100 }, "functions": { - "covered": 49, + "covered": 50, "total": 51, - "percentage": 96.07 + "percentage": 98.03 }, "lines": { - "covered": 274, + "covered": 275, "total": 275, - "percentage": 99.63 + "percentage": 100 } }, "src/vs/platform/agentHost/node/copilot/copilotShellTools.ts": { "statements": { - "covered": 499, + "covered": 505, "total": 811, - "percentage": 61.52 + "percentage": 62.26 }, "branches": { - "covered": 20, - "total": 41, - "percentage": 48.78 + "covered": 21, + "total": 44, + "percentage": 47.72 }, "functions": { "covered": 16, @@ -4364,9 +4364,9 @@ "percentage": 51.61 }, "lines": { - "covered": 499, + "covered": 505, "total": 811, - "percentage": 61.52 + "percentage": 62.26 } }, "src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts": { @@ -4459,14 +4459,14 @@ }, "src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts": { "statements": { - "covered": 885, + "covered": 899, "total": 1201, - "percentage": 73.68 + "percentage": 74.85 }, "branches": { - "covered": 83, - "total": 209, - "percentage": 39.71 + "covered": 86, + "total": 216, + "percentage": 39.81 }, "functions": { "covered": 21, @@ -4474,21 +4474,21 @@ "percentage": 67.74 }, "lines": { - "covered": 885, + "covered": 899, "total": 1201, - "percentage": 73.68 + "percentage": 74.85 } }, "src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts": { "statements": { - "covered": 531, - "total": 802, - "percentage": 66.2 + "covered": 532, + "total": 796, + "percentage": 66.83 }, "branches": { "covered": 82, - "total": 142, - "percentage": 57.74 + "total": 144, + "percentage": 56.94 }, "functions": { "covered": 17, @@ -4496,31 +4496,31 @@ "percentage": 94.44 }, "lines": { - "covered": 531, - "total": 802, - "percentage": 66.2 + "covered": 532, + "total": 796, + "percentage": 66.83 } }, "src/vs/platform/agentHost/node/copilot/pendingEditContentStore.ts": { "statements": { - "covered": 41, + "covered": 47, "total": 47, - "percentage": 87.23 + "percentage": 100 }, "branches": { - "covered": 2, - "total": 2, + "covered": 3, + "total": 3, "percentage": 100 }, "functions": { - "covered": 2, + "covered": 3, "total": 3, - "percentage": 66.66 + "percentage": 100 }, "lines": { - "covered": 41, + "covered": 47, "total": 47, - "percentage": 87.23 + "percentage": 100 } }, "src/vs/platform/agentHost/node/copilot/prompts/allPrompts.ts": { @@ -4657,24 +4657,24 @@ }, "src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts": { "statements": { - "covered": 456, - "total": 627, - "percentage": 72.72 + "covered": 462, + "total": 1116, + "percentage": 41.39 }, "branches": { - "covered": 70, - "total": 88, - "percentage": 79.54 + "covered": 62, + "total": 80, + "percentage": 77.5 }, "functions": { "covered": 16, - "total": 23, - "percentage": 69.56 + "total": 32, + "percentage": 50 }, "lines": { - "covered": 456, - "total": 627, - "percentage": 72.72 + "covered": 462, + "total": 1116, + "percentage": 41.39 } }, "src/vs/platform/agentHost/node/diffComputeService.ts": { @@ -4772,14 +4772,14 @@ "percentage": 93.62 }, "branches": { - "covered": 24, - "total": 33, - "percentage": 72.72 + "covered": 25, + "total": 34, + "percentage": 73.52 }, "functions": { - "covered": 11, + "covered": 12, "total": 12, - "percentage": 91.66 + "percentage": 100 }, "lines": { "covered": 235, @@ -4811,14 +4811,14 @@ }, "src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts": { "statements": { - "covered": 67, + "covered": 70, "total": 70, - "percentage": 95.71 + "percentage": 100 }, "branches": { - "covered": 11, - "total": 14, - "percentage": 78.57 + "covered": 15, + "total": 16, + "percentage": 93.75 }, "functions": { "covered": 5, @@ -4826,9 +4826,9 @@ "percentage": 100 }, "lines": { - "covered": 67, + "covered": 70, "total": 70, - "percentage": 95.71 + "percentage": 100 } }, "src/vs/platform/agentHost/node/networkDiagnosticsService.ts": { @@ -4899,24 +4899,24 @@ }, "src/vs/platform/agentHost/node/protocolServerHandler.ts": { "statements": { - "covered": 1057, + "covered": 1103, "total": 1569, - "percentage": 67.36 + "percentage": 70.29 }, "branches": { - "covered": 129, - "total": 197, - "percentage": 65.48 + "covered": 139, + "total": 216, + "percentage": 64.35 }, "functions": { - "covered": 46, + "covered": 49, "total": 81, - "percentage": 56.79 + "percentage": 60.49 }, "lines": { - "covered": 1057, + "covered": 1103, "total": 1569, - "percentage": 67.36 + "percentage": 70.29 } }, "src/vs/platform/agentHost/node/serverUrls.ts": { @@ -4943,14 +4943,14 @@ }, "src/vs/platform/agentHost/node/sessionDataService.ts": { "statements": { - "covered": 153, + "covered": 156, "total": 197, - "percentage": 77.66 + "percentage": 79.18 }, "branches": { - "covered": 22, + "covered": 23, "total": 26, - "percentage": 84.61 + "percentage": 88.46 }, "functions": { "covered": 12, @@ -4958,31 +4958,31 @@ "percentage": 85.71 }, "lines": { - "covered": 153, + "covered": 156, "total": 197, - "percentage": 77.66 + "percentage": 79.18 } }, "src/vs/platform/agentHost/node/sessionDatabase.ts": { "statements": { - "covered": 516, + "covered": 596, "total": 778, - "percentage": 66.32 + "percentage": 76.6 }, "branches": { - "covered": 68, - "total": 91, - "percentage": 74.72 + "covered": 80, + "total": 105, + "percentage": 76.19 }, "functions": { - "covered": 27, + "covered": 32, "total": 51, - "percentage": 52.94 + "percentage": 62.74 }, "lines": { - "covered": 516, + "covered": 596, "total": 778, - "percentage": 66.32 + "percentage": 76.6 } }, "src/vs/platform/agentHost/node/sessionDiffAggregator.ts": { @@ -4992,9 +4992,9 @@ "percentage": 51.08 }, "branches": { - "covered": 13, - "total": 28, - "percentage": 46.42 + "covered": 15, + "total": 30, + "percentage": 50 }, "functions": { "covered": 3, @@ -5009,24 +5009,24 @@ }, "src/vs/platform/agentHost/node/sessionPermissions.ts": { "statements": { - "covered": 448, + "covered": 458, "total": 583, - "percentage": 76.84 + "percentage": 78.55 }, "branches": { - "covered": 57, - "total": 94, - "percentage": 60.63 + "covered": 64, + "total": 104, + "percentage": 61.53 }, "functions": { - "covered": 20, + "covered": 23, "total": 24, - "percentage": 83.33 + "percentage": 95.83 }, "lines": { - "covered": 448, + "covered": 458, "total": 583, - "percentage": 76.84 + "percentage": 78.55 } }, "src/vs/platform/agentHost/node/shared/agentBranchNameGenerator.ts": { @@ -5141,14 +5141,14 @@ }, "src/vs/platform/agentHost/node/shared/editChunkExtractor.ts": { "statements": { - "covered": 122, + "covered": 123, "total": 227, - "percentage": 53.74 + "percentage": 54.18 }, "branches": { - "covered": 4, + "covered": 5, "total": 15, - "percentage": 26.66 + "percentage": 33.33 }, "functions": { "covered": 2, @@ -5156,9 +5156,9 @@ "percentage": 40 }, "lines": { - "covered": 122, + "covered": 123, "total": 227, - "percentage": 53.74 + "percentage": 54.18 } }, "src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts": { @@ -5168,9 +5168,9 @@ "percentage": 88.16 }, "branches": { - "covered": 9, - "total": 26, - "percentage": 34.61 + "covered": 11, + "total": 27, + "percentage": 40.74 }, "functions": { "covered": 5, @@ -5190,9 +5190,9 @@ "percentage": 86.86 }, "branches": { - "covered": 17, - "total": 25, - "percentage": 68 + "covered": 18, + "total": 26, + "percentage": 69.23 }, "functions": { "covered": 6, @@ -5212,9 +5212,9 @@ "percentage": 87.44 }, "branches": { - "covered": 20, - "total": 26, - "percentage": 76.92 + "covered": 22, + "total": 28, + "percentage": 78.57 }, "functions": { "covered": 7, @@ -5273,24 +5273,24 @@ }, "src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts": { "statements": { - "covered": 422, + "covered": 334, "total": 555, - "percentage": 76.03 + "percentage": 60.18 }, "branches": { - "covered": 43, - "total": 66, - "percentage": 65.15 + "covered": 20, + "total": 30, + "percentage": 66.66 }, "functions": { - "covered": 17, + "covered": 7, "total": 29, - "percentage": 58.62 + "percentage": 24.13 }, "lines": { - "covered": 422, + "covered": 334, "total": 555, - "percentage": 76.03 + "percentage": 60.18 } }, "src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts": { @@ -5383,24 +5383,24 @@ }, "src/vs/platform/agentHost/node/shared/worktreeIsolation.ts": { "statements": { - "covered": 637, + "covered": 639, "total": 822, - "percentage": 77.49 + "percentage": 77.73 }, "branches": { "covered": 66, - "total": 102, - "percentage": 64.7 + "total": 103, + "percentage": 64.07 }, "functions": { - "covered": 29, + "covered": 30, "total": 40, - "percentage": 72.5 + "percentage": 75 }, "lines": { - "covered": 637, + "covered": 639, "total": 822, - "percentage": 77.49 + "percentage": 77.73 } }, "src/vs/platform/agentHost/node/webSocketTransport.ts": { diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index 5118d8b1bef835..051767803d8bf7 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -46,6 +46,13 @@ const UPDATE_SNAPSHOTS = process.env[AgentHostUpdateSnapshotsEnvVar] === '1'; const RECORD = process.env['AGENT_HOST_REPLAY_RECORD'] === '1' || UPDATE_SNAPSHOTS; const REPLAY_MODE: CapiReplayMode = RECORD ? 'record' : 'replay'; const SERVER_SHUTDOWN_TIMEOUT_MS = 30_000; + +/** + * Upper bound on tests served by a single shared replay server before it is + * proactively recycled. Amortizes startup across many tests while keeping each + * cached provider subprocess well within the range where it stays healthy. + */ +const MAX_TESTS_PER_SHARED_SERVER = 25; const TEMP_DIR_CLEANUP_TIMEOUT_MS = 30_000; /** A synthetic token used on replay (no real credential needed). */ export const REPLAY_PLACEHOLDER_TOKEN = 'replay-no-token'; @@ -236,6 +243,11 @@ export interface IAgentHostE2EProviderConfig { * shared test prompt doesn't reliably drive it to `ExitPlanMode`. */ readonly supportsPlanMode: boolean; + /** Whether the provider supports additional peer chats and chat forks. */ + readonly supportsMultipleChats: boolean; + readonly supportsChatFork: boolean; + /** Whether provider-backed fork context can be tested end-to-end. */ + readonly supportsChatForkE2E: boolean; /** * The github token to use. If not provided, the test will attempt to resolve it from the environment or `gh auth token`. @@ -605,6 +617,15 @@ export class AgentHostE2EServerLease { private _client: TestProtocolClient | undefined; private readonly _shared: boolean; private _dataDir: string | undefined; + /** + * Number of tests served by the current shared server. A single long-lived + * host caches one provider SDK/CLI subprocess and reuses it across every + * test; after enough sessions that subprocess can accumulate state and + * eventually wedge a turn (turn starts, but no model response arrives even + * though replay is instant). Recycling the server well before that keeps each + * host instance within its reliable range while still amortizing startup. + */ + private _testsOnCurrentServer = 0; private readonly _startOptions: { readonly claudeSdkRoot?: string; readonly codexSdkRoot?: string; readonly homeDir: string; readonly userDataDir: string }; constructor( @@ -628,6 +649,11 @@ export class AgentHostE2EServerLease { /** Acquire a server + connected client for a test, returning both. */ async acquire(testTitle: string, modelTraffic: AgentHostE2EModelTraffic = 'recorded'): Promise<{ server: IServerHandle; client: TestProtocolClient }> { const capiReplay = capiReplayFor(this._config.provider, testTitle, modelTraffic); + // Proactively recycle a shared server that has served enough tests, before + // its cached provider subprocess can degrade and wedge a turn. + if (this._shared && this._server && this._testsOnCurrentServer >= MAX_TESTS_PER_SHARED_SERVER) { + await this._recycleSharedServer(); + } if (this._shared && this._server) { const proxy = this._server.capiReplay; if (!proxy) { @@ -636,7 +662,9 @@ export class AgentHostE2EServerLease { proxy.resetForReplay(capiReplay.fixturePath); } else { this._server = await startRealServer({ ...this._startOptions, capiReplay }); + this._testsOnCurrentServer = 0; } + this._testsOnCurrentServer++; this._client = new TestProtocolClient( this._server.port, () => this._server?.capiReplay?.takeCacheMissError(), @@ -646,12 +674,35 @@ export class AgentHostE2EServerLease { return { server: this._server, client: this._client }; } + /** Stop the current shared server so the next {@link acquire} starts a fresh one. */ + private async _recycleSharedServer(): Promise { + try { + await this._server?.capiReplay?.close(); + } finally { + await stopServer(this._server); + this._server = undefined; + this._testsOnCurrentServer = 0; + } + } + + get observedModelRequestBodies(): readonly string[] { + return this._server?.capiReplay?.observedModelRequestBodies ?? []; + } + /** * Release a test: dispose its sessions, disconnect the client, and verify the - * replay traffic. A shared server is kept alive (with its cached SDK client) - * for the next test; a per-test server is stopped. + * replay traffic. A shared server is normally kept alive (with its cached SDK + * client) for the next test; a per-test server is stopped. + * + * Pass `forceRestart` when the just-run test failed. A failed test can leave + * a mid-turn session that wedges (or has already killed) the shared host, so + * reusing it would cascade `ECONNREFUSED` / `createSession` timeouts into the + * next, unrelated test. Restarting isolates the failure to the one test that + * caused it. The strict cache-miss assertion is also skipped on restart: the + * test already failed for its own reason, and a secondary cache-miss throw + * would only obscure it. */ - async release(createdSessions: string[]): Promise { + async release(createdSessions: string[], forceRestart = false): Promise { const client = this._client; if (client) { for (const session of createdSessions) { @@ -672,15 +723,19 @@ export class AgentHostE2EServerLease { createdSessions.length = 0; this._client = undefined; - if (this._shared) { + if (this._shared && !forceRestart) { // Surface this test's strict cache-misses but keep the server (and its // cached SDK client) alive for the next test. this._server?.capiReplay?.assertNoCacheMisses(); } else { - // Flush the recording / surface strict replay cache-misses before the - // process goes away. Kill even if the strict check throws. + // Per-test server, or a shared server being restarted after a failure. + // Flush the recording / surface strict replay cache-misses (unless the + // test already failed) before the process goes away. Kill even if the + // strict check throws. try { - await this._server?.capiReplay?.stop(); + if (!forceRestart) { + await this._server?.capiReplay?.stop(); + } } finally { await stopServer(this._server); this._server = undefined; diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts b/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts index 4ea2e838299380..3984ed96856cbb 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts @@ -223,6 +223,7 @@ export class CapiReplayProxy { private readonly _replayBuckets = new Map(); /** Exchanges captured during recording, in arrival order. */ private readonly _recorded: IRecordedExchange[] = []; + private readonly _observedModelRequestBodies: string[] = []; private readonly _cacheMisses: string[] = []; private _workingDirectory: string | undefined; @@ -321,6 +322,7 @@ export class CapiReplayProxy { this._fixturePath = fixturePath; this._workingDirectory = undefined; this._replayBuckets.clear(); + this._observedModelRequestBodies.length = 0; this._cacheMisses.length = 0; this._loadFixture(); } @@ -329,6 +331,10 @@ export class CapiReplayProxy { this._workingDirectory = workingDirectory; } + get observedModelRequestBodies(): readonly string[] { + return this._observedModelRequestBodies; + } + /** * Surface strict replay cache-misses without stopping the proxy. Lets a * shared replay server verify each test's traffic in `teardown` while keeping @@ -412,6 +418,9 @@ export class CapiReplayProxy { } const key = `${method} ${path}`; + if (MODEL_ENDPOINTS.has(path)) { + this._observedModelRequestBodies.push(this._normalize(body)); + } const bucket = this._replayBuckets.get(key); let item: IReplayItem | undefined; @@ -451,6 +460,9 @@ export class CapiReplayProxy { private _record(req: http.IncomingMessage, body: string, res: http.ServerResponse): void { const method = req.method ?? 'GET'; const path = new URL(req.url ?? '/', 'http://localhost').pathname; + if (MODEL_ENDPOINTS.has(path)) { + this._observedModelRequestBodies.push(this._normalize(body)); + } const upstreamBase = this._upstreamFor(path); const upstream = new URL(req.url ?? '/', upstreamBase); const isHttps = upstream.protocol === 'https:'; diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts index f5a5c277e33083..2a7d8b0d7162f0 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts @@ -78,6 +78,11 @@ const CLAUDE_CONFIG: IAgentHostE2EProviderConfig = { // shared test's Copilot-flavoured prompt doesn't reliably drive Claude // to invoke it. TODO: rework the prompt for Claude conventions. supportsPlanMode: false, + supportsMultipleChats: true, + supportsChatFork: true, + // The Claude SDK currently receives the AHP turn id as upToMessageId and + // rejects it as invalid when the fork is exercised against a real transcript. + supportsChatForkE2E: false, }; defineAgentHostE2ETests(CLAUDE_CONFIG); diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts index 3b4b9c9c75bdd4..d227b342b97efa 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts @@ -27,5 +27,8 @@ export const CODEX_CONFIG: IAgentHostE2EProviderConfig = { supportsHostTerminalTool: false, supportsSubagents: false, supportsPlanMode: false, + supportsMultipleChats: false, + supportsChatFork: false, + supportsChatForkE2E: false, shellToolReplayUnstableOnLinux: true, }; diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts index d8d31cf79c0bcf..16cfe760306830 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts @@ -53,6 +53,9 @@ const COPILOT_CONFIG: IAgentHostE2EProviderConfig = { supportsHostTerminalTool: true, supportsSubagents: true, supportsPlanMode: true, + supportsMultipleChats: true, + supportsChatFork: true, + supportsChatForkE2E: true, }; defineAgentHostE2ETests(COPILOT_CONFIG); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts index cf7d85bd5072d8..361ec2267c2992 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/agentHostE2ESuites.ts @@ -8,6 +8,7 @@ import type { TestProtocolClient } from '../../serverIntegrationTestHelpers.js'; import { defineCoreTests } from './coreSuite.js'; import { defineFileOperationsTests } from './fileOperationsSuite.js'; import { defineHostFeaturesTests } from './hostFeaturesSuite.js'; +import { defineMultiChatTests } from './multiChatSuite.js'; import { defineStateOperationsTests } from './stateOperationsSuite.js'; import { defineSubagentTests } from './subagentSuite.js'; import { defineTurnLifecycleTests } from './turnLifecycleSuite.js'; @@ -38,6 +39,7 @@ export function defineAgentHostE2ETests(config: IAgentHostE2EProviderConfig): vo isWindows, runRecordOnlyTests: RUN_RECORD_ONLY_TESTS, registerNoModelTrafficTest: title => noModelTrafficTestTitles.add(title), + get observedModelRequestBodies() { return lease?.observedModelRequestBodies ?? []; }, }; suiteSetup(async function () { @@ -71,11 +73,16 @@ export function defineAgentHostE2ETests(config: IAgentHostE2EProviderConfig): vo if (!lease) { throw new Error('Agent Host E2E server lease was not initialized.'); } - await lease.release(createdSessions); + // A failed test can leave a mid-turn session that wedges (or already + // killed) the shared host; restart it so the failure does not cascade + // into the next, unrelated test. + const failed = this.currentTest?.state === 'failed'; + await lease.release(createdSessions, failed); }); defineCoreTests(context); defineHostFeaturesTests(context); + defineMultiChatTests(context); defineStateOperationsTests(context); defineFileOperationsTests(context); defineTurnLifecycleTests(context); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/e2eTestContext.ts b/src/vs/platform/agentHost/test/node/e2e/suites/e2eTestContext.ts index 87418151217fab..aa241fae43ebbe 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/e2eTestContext.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/e2eTestContext.ts @@ -16,6 +16,7 @@ export interface IAgentHostE2ETestContext { readonly isWindows: boolean; readonly runRecordOnlyTests: boolean; readonly registerNoModelTrafficTest: (title: string) => void; + readonly observedModelRequestBodies: readonly string[]; } /** diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts new file mode 100644 index 00000000000000..71319f6459486a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/suites/multiChatSuite.ts @@ -0,0 +1,789 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../../../base/common/path.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ActionType, type ChatErrorAction, type ChatToolCallReadyAction } from '../../../../common/state/sessionActions.js'; +import { CompletionItemKind, type CompletionsResult, type ListSessionsResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js'; +import { + buildChatUri, + buildDefaultChatUri, + isAhpChatChannel, + MessageAttachmentKind, + MessageKind, + parseRequiredSessionUriFromChatUri, + ResponsePartKind, + ROOT_STATE_URI, + SessionStatus, + ToolCallConfirmationReason, + type ChatState, + type MessageAttachment, + type RootState, + type SessionState, +} from '../../../../common/state/sessionState.js'; +import { createRealSession } from '../harness/agentHostE2ETestHarness.js'; +import { getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; +import { hostOnlyTest, type IAgentHostE2ETestContext } from './e2eTestContext.js'; + +export function defineMultiChatTests(context: IAgentHostE2ETestContext): void { + const { config, createdSessions, tempDirs, shellToolReplayEnabled } = context; + + async function createSession(prefix: string): Promise<{ sessionUri: string; defaultChatUri: string; workspace: string }> { + const workspace = mkdtempSync(join(tmpdir(), `ahp-multichat-${prefix}-`)); + tempDirs.push(workspace); + const sessionUri = await createRealSession( + context.client, + config, + `${prefix}-${config.provider}`, + createdSessions, + URI.file(workspace), + ); + return { sessionUri, defaultChatUri: buildDefaultChatUri(sessionUri), workspace }; + } + + async function createPeer(sessionUri: string, id: string, source?: { chat: string; turnId: string }): Promise { + const chat = buildChatUri(sessionUri, id); + await context.client.call('createChat', { + channel: sessionUri, + chat, + ...(source ? { source } : {}), + }, 30_000); + return chat; + } + + async function sessionState(sessionUri: string): Promise { + const result = await context.client.call('subscribe', { channel: sessionUri }); + return result.snapshot!.state as SessionState; + } + + async function chatState(chatUri: string): Promise { + const result = await context.client.call('subscribe', { channel: chatUri }); + return result.snapshot!.state as ChatState; + } + + async function rename(channel: string, title: string, clientSeq = 1): Promise { + context.client.clearReceived(); + context.client.dispatch({ + channel, + clientSeq, + action: { type: ActionType.SessionTitleChanged, title }, + }); + if (isAhpChatChannel(channel)) { + const session = parseRequiredSessionUriFromChatUri(channel); + await context.client.waitForNotification(n => { + if (!isActionNotification(n, 'session/chatUpdated') || getActionEnvelope(n).channel !== session) { + return false; + } + const action = getActionEnvelope(n).action as { chat: string; changes: { title?: string } }; + return action.chat === channel && action.changes.title === title; + }); + } else { + await context.client.waitForNotification(n => + isActionNotification(n, 'session/titleChanged') + && getActionEnvelope(n).channel === channel, + ); + } + } + + function providerTest(title: string, run: Mocha.AsyncFunc, enabled = config.supportsMultipleChats): void { + (enabled ? test : test.skip)(title, function () { + this.timeout(180_000); + return run.call(this); + }); + } + + interface IObservedModelMessage { + readonly role: string; + readonly content: string; + } + + function observedModelMessages(body: string): readonly IObservedModelMessage[] { + const request: unknown = JSON.parse(body); + if (!isRecord(request) || !Array.isArray(request.messages)) { + return []; + } + return request.messages.flatMap(message => { + if (!isRecord(message) || typeof message.role !== 'string') { + return []; + } + return [{ role: message.role, content: modelContentText(message.content) }]; + }); + } + + function modelContentText(value: unknown): string { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value)) { + return value.map(modelContentText).join(''); + } + if (isRecord(value)) { + if (typeof value.text === 'string') { + return value.text; + } + return modelContentText(value.content); + } + return ''; + } + + function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; + } + + function forkProviderTest(title: string, run: Mocha.AsyncFunc): void { + (config.supportsChatForkE2E ? test : test.skip)(title, function () { + this.timeout(180_000); + return run.call(this); + }); + } + + async function driveTurn( + chatUri: string, + turnId: string, + text: string, + clientSeq: number, + attachments?: readonly MessageAttachment[], + ): Promise { + context.client.clearReceived(); + context.client.dispatch({ + channel: chatUri, + clientSeq, + action: { + type: ActionType.ChatTurnStarted, + turnId, + startedAt: '2025-01-01T00:00:00.000Z', + message: { text, origin: { kind: MessageKind.User }, ...(attachments ? { attachments: [...attachments] } : {}) }, + }, + }); + const seen = new Set(); + let nextClientSeq = clientSeq + 1; + while (true) { + const notification = await context.client.waitForNotification(n => { + if (seen.has(n as object) + || (!isActionNotification(n, 'chat/toolCallReady') + && !isActionNotification(n, 'chat/turnComplete') + && !isActionNotification(n, 'chat/error')) + ) { + return false; + } + return getActionEnvelope(n).channel === chatUri; + }, 90_000); + seen.add(notification as object); + if (isActionNotification(notification, 'chat/error')) { + const action = getActionEnvelope(notification).action as ChatErrorAction; + throw new Error(`Peer chat error during ${turnId}: ${JSON.stringify(action.error)}`); + } + if (isActionNotification(notification, 'chat/turnComplete')) { + break; + } + const action = getActionEnvelope(notification).action as ChatToolCallReadyAction; + if (!action.confirmed) { + context.client.dispatch({ + channel: chatUri, + clientSeq: nextClientSeq++, + action: { + type: ActionType.ChatToolCallConfirmed, + turnId, + toolCallId: action.toolCallId, + approved: true, + confirmed: ToolCallConfirmationReason.UserAction, + }, + }); + } + } + + const markdownPartIds = new Set(); + const pieces: string[] = []; + for (const notification of context.client.receivedNotifications(n => + (isActionNotification(n, 'chat/responsePart') || isActionNotification(n, 'chat/delta')) + && getActionEnvelope(n).channel === chatUri + )) { + const action = getActionEnvelope(notification).action; + if (action.type === ActionType.ChatResponsePart && action.part.kind === ResponsePartKind.Markdown) { + markdownPartIds.add(action.part.id); + pieces.push(action.part.content); + } else if (action.type === ActionType.ChatDelta && markdownPartIds.has(action.partId)) { + pieces.push(action.content); + } + } + return pieces.join(''); + } + + hostOnlyTest(context, 'agent advertises its multiple chat capability', async function () { + await createSession('capability'); + const root = await context.client.call('subscribe', { channel: ROOT_STATE_URI }); + const agent = (root.snapshot!.state as RootState).agents.find(agent => agent.provider === config.provider); + + assert.deepStrictEqual({ + multipleChats: !!agent?.capabilities?.multipleChats, + fork: agent?.capabilities?.multipleChats?.fork ?? false, + }, { + multipleChats: config.supportsMultipleChats, + fork: config.supportsChatFork, + }); + }); + + hostOnlyTest(context, 'provider without multiple chat capability rejects peer creation', async function () { + const { sessionUri } = await createSession('unsupported'); + + await assert.rejects( + () => createPeer(sessionUri, 'unsupported-peer'), + /does not support multiple chats/i, + ); + }, !config.supportsMultipleChats); + + hostOnlyTest(context, 'creating a peer chat adds it to the session catalog', async function () { + const { sessionUri } = await createSession('catalog-add'); + const peer = await createPeer(sessionUri, 'peer'); + + assert.ok((await sessionState(sessionUri)).chats.some(chat => chat.resource === peer)); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'peer chat subscription starts empty and idle', async function () { + const { sessionUri } = await createSession('empty-peer'); + const peer = await createPeer(sessionUri, 'peer'); + + const state = await chatState(peer); + + assert.deepStrictEqual({ turns: state.turns, activeTurn: state.activeTurn, status: state.status }, { + turns: [], + activeTurn: undefined, + status: SessionStatus.Idle, + }); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'creating the same peer chat twice is idempotent', async function () { + const { sessionUri } = await createSession('idempotent'); + const peer = await createPeer(sessionUri, 'peer'); + + await createPeer(sessionUri, 'peer'); + + assert.strictEqual((await sessionState(sessionUri)).chats.filter(chat => chat.resource === peer).length, 1); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'creating two peer chats preserves both catalog entries', async function () { + const { sessionUri } = await createSession('two-peers'); + const first = await createPeer(sessionUri, 'first'); + const second = await createPeer(sessionUri, 'second'); + + const peers = (await sessionState(sessionUri)).chats.map(chat => chat.resource); + + assert.ok(peers.includes(first) && peers.includes(second)); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'disposing a peer chat removes its catalog entry', async function () { + const { sessionUri } = await createSession('dispose'); + const peer = await createPeer(sessionUri, 'peer'); + + await context.client.call('disposeChat', { channel: peer }, 30_000); + + assert.strictEqual((await sessionState(sessionUri)).chats.some(chat => chat.resource === peer), false); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'disposing one peer chat preserves its sibling', async function () { + const { sessionUri } = await createSession('dispose-one'); + const first = await createPeer(sessionUri, 'first'); + const second = await createPeer(sessionUri, 'second'); + + await context.client.call('disposeChat', { channel: first }, 30_000); + + const peers = (await sessionState(sessionUri)).chats.map(chat => chat.resource); + assert.ok(!peers.includes(first) && peers.includes(second)); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'recreating a disposed peer chat starts empty', async function () { + const { sessionUri } = await createSession('recreate'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('disposeChat', { channel: peer }, 30_000); + + await createPeer(sessionUri, 'peer'); + + assert.deepStrictEqual((await chatState(peer)).turns, []); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'renaming a peer chat updates its catalog title', async function () { + const { sessionUri } = await createSession('rename-peer'); + const peer = await createPeer(sessionUri, 'peer'); + + await rename(peer, 'Peer Title'); + + assert.strictEqual((await sessionState(sessionUri)).chats.find(chat => chat.resource === peer)?.title, 'Peer Title'); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'renaming a peer chat leaves the session title unchanged', async function () { + const { sessionUri } = await createSession('rename-isolated'); + await rename(sessionUri, 'Session Title'); + const peer = await createPeer(sessionUri, 'peer'); + + await rename(peer, 'Peer Title', 2); + + assert.strictEqual((await sessionState(sessionUri)).title, 'Session Title'); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'peer chat survives unsubscribe and resubscribe', async function () { + const { sessionUri } = await createSession('resubscribe'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + context.client.notify('unsubscribe', { channel: peer }); + + assert.strictEqual((await chatState(peer)).resource, peer); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'peer creation does not leak a provider backing as a top-level session', async function () { + const { sessionUri } = await createSession('session-list'); + const before = await context.client.call('listSessions', { channel: ROOT_STATE_URI }); + + await createPeer(sessionUri, 'peer'); + + const after = await context.client.call('listSessions', { channel: ROOT_STATE_URI }); + const beforeResources = new Set(before.items.map(item => item.resource)); + const unexpected = after.items + .map(item => item.resource) + .filter(resource => !beforeResources.has(resource) && resource !== sessionUri); + + assert.deepStrictEqual(unexpected, []); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'peer file completion uses the parent workspace', async function () { + const { sessionUri, workspace } = await createSession('completion'); + writeFileSync(join(workspace, 'peer-target.txt'), 'target'); + const peer = await createPeer(sessionUri, 'peer'); + + const completions = await context.client.call('completions', { + channel: peer, + kind: CompletionItemKind.UserMessage, + text: '@peer-t', + offset: '@peer-t'.length, + }); + + assert.deepStrictEqual(completions.items.map(item => item.insertText), ['@peer-target.txt']); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'first peer chat snapshots the session title onto the default chat', async function () { + const { sessionUri, defaultChatUri } = await createSession('default-title'); + await rename(sessionUri, 'Original Session'); + + await createPeer(sessionUri, 'peer'); + + assert.strictEqual((await sessionState(sessionUri)).chats.find(chat => chat.resource === defaultChatUri)?.title, 'Original Session'); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'session rename after peer creation preserves the default chat title', async function () { + const { sessionUri, defaultChatUri } = await createSession('independent-title'); + await rename(sessionUri, 'Original Session'); + await createPeer(sessionUri, 'peer'); + + await rename(sessionUri, 'Renamed Session', 2); + + assert.strictEqual((await sessionState(sessionUri)).chats.find(chat => chat.resource === defaultChatUri)?.title, 'Original Session'); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'forking an unknown turn creates a fresh empty peer chat', async function () { + const { sessionUri, defaultChatUri } = await createSession('unknown-fork'); + + const peer = await createPeer(sessionUri, 'fork', { chat: defaultChatUri, turnId: 'missing-turn' }); + + assert.deepStrictEqual((await chatState(peer)).turns, []); + }, config.supportsMultipleChats); + + providerTest('peer chat completes a simple turn', async function () { + const { sessionUri } = await createSession('peer-turn'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + const response = await driveTurn(peer, 'peer-turn', 'Reply exactly "PEER_OK".', 1); + + assert.match(response, /PEER_OK/); + }); + + providerTest('peer chat retains context across consecutive turns', async function () { + const { sessionUri } = await createSession('peer-context'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + const firstResponse = await driveTurn(peer, 'peer-context-1', 'Remember the code word PEAR. Reply exactly "ready".', 1); + const response = await driveTurn(peer, 'peer-context-2', 'What code word did I ask you to remember? Reply with only the code word.', 2); + const messages = observedModelMessages(context.observedModelRequestBodies.at(-1) ?? ''); + const priorAssistantResponse = firstResponse.trim(); + + assert.deepStrictEqual({ + priorAssistantResponseIsNonEmpty: priorAssistantResponse.length > 0, + responseHasCodeWord: /PEAR/i.test(response), + requestHasPriorUserMessage: messages.some(message => message.role === 'user' && message.content.includes('Remember the code word PEAR')), + requestHasPriorAssistantMessage: messages.some(message => message.role === 'assistant' && message.content.includes(priorAssistantResponse)), + }, { + priorAssistantResponseIsNonEmpty: true, + responseHasCodeWord: true, + requestHasPriorUserMessage: true, + requestHasPriorAssistantMessage: true, + }); + }); + + forkProviderTest('forked peer chat inherits source history through the provider', async function () { + const { sessionUri, defaultChatUri } = await createSession('fork-history'); + const sourceResponse = await driveTurn(defaultChatUri, 'fork-source', 'Remember the code word FORKCODE. Reply exactly "ready".', 1); + + const peer = await createPeer(sessionUri, 'fork', { chat: defaultChatUri, turnId: 'fork-source' }); + await context.client.call('subscribe', { channel: peer }); + const response = await driveTurn(peer, 'fork-turn', 'What code word did I ask you to remember? Reply with only the code word.', 2); + const messages = observedModelMessages(context.observedModelRequestBodies.at(-1) ?? ''); + const priorAssistantResponse = sourceResponse.trim(); + + assert.deepStrictEqual({ + seededMessages: (await chatState(peer)).turns.map(turn => turn.message.text), + priorAssistantResponseIsNonEmpty: priorAssistantResponse.length > 0, + responseHasCodeWord: /FORKCODE/i.test(response), + requestHasPriorUserMessage: messages.some(message => message.role === 'user' && message.content.includes('Remember the code word FORKCODE')), + requestHasPriorAssistantMessage: messages.some(message => message.role === 'assistant' && message.content.includes(priorAssistantResponse)), + }, { + seededMessages: [ + 'Remember the code word FORKCODE. Reply exactly "ready".', + 'What code word did I ask you to remember? Reply with only the code word.', + ], + priorAssistantResponseIsNonEmpty: true, + responseHasCodeWord: true, + requestHasPriorUserMessage: true, + requestHasPriorAssistantMessage: true, + }); + }); + + providerTest('disposing a peer after a completed turn removes it from the catalog', async function () { + const { sessionUri } = await createSession('dispose-after-turn'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + await driveTurn(peer, 'peer-turn', 'Reply exactly "DONE".', 1); + + await context.client.call('disposeChat', { channel: peer }, 30_000); + + assert.strictEqual((await sessionState(sessionUri)).chats.some(chat => chat.resource === peer), false); + }); + + hostOnlyTest(context, 'peer rename command updates the peer title and records a local turn', async function () { + const { sessionUri } = await createSession('local-rename'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + await driveTurn(peer, 'peer-rename', '/rename Renamed Peer', 1); + + const state = await chatState(peer); + assert.deepStrictEqual({ + title: state.title, + messages: state.turns.map(turn => turn.message.text), + }, { + title: 'Renamed Peer', + messages: ['/rename Renamed Peer'], + }); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'empty peer rename command leaves the peer title unchanged', async function () { + const { sessionUri } = await createSession('local-empty-rename'); + const peer = await createPeer(sessionUri, 'peer'); + await rename(peer, 'Original Peer'); + await context.client.call('subscribe', { channel: peer }); + + await driveTurn(peer, 'peer-empty-rename', '/rename', 2); + + assert.strictEqual((await chatState(peer)).title, 'Original Peer'); + }, config.supportsMultipleChats); + + hostOnlyTest(context, 'failing peer bang command records a failed terminal tool call', async function () { + const { sessionUri } = await createSession('local-bang-failure'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + await driveTurn(peer, 'peer-bang-failure', '!node -e "process.exit(7)"', 1); + + const toolCalls = (await chatState(peer)).turns.flatMap(turn => turn.responseParts) + .filter(part => part.kind === ResponsePartKind.ToolCall) + .map(part => part.toolCall); + assert.ok(toolCalls.some(toolCall => toolCall.status === 'completed' && !toolCall.success)); + }, config.supportsMultipleChats); + + providerTest('peer chat reads a file from the parent workspace', async function () { + const { sessionUri, workspace } = await createSession('read-file'); + const file = join(workspace, 'peer-note.txt'); + writeFileSync(file, 'PEER_FILE_VALUE'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + const response = await driveTurn(peer, 'peer-read', `Read the file at ${file} and reply with its exact contents only.`, 1); + + assert.match(response, /PEER_FILE_VALUE/); + }); + + providerTest('peer chat reads a file from a nested directory', async function () { + const { sessionUri, workspace } = await createSession('read-nested-file'); + mkdirSync(join(workspace, 'nested')); + const file = join(workspace, 'nested', 'peer.txt'); + writeFileSync(file, 'PEER_NESTED_READ'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + const response = await driveTurn(peer, 'peer-read-nested', `Read the file at ${file} and reply with its exact contents only.`, 1); + + assert.match(response, /PEER_NESTED_READ/); + }); + + providerTest('peer chat creates a file in the parent workspace', async function () { + const { sessionUri, workspace } = await createSession('create-file'); + const file = join(workspace, 'peer-created.txt'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + await driveTurn(peer, 'peer-create', `Create the file at ${file} containing exactly PEER_CREATED.`, 1); + + assert.strictEqual(readFileSync(file, 'utf8'), 'PEER_CREATED'); + }); + + // Copilot's fixture uses a POSIX shell for this mutation. + providerTest('peer chat edits an existing workspace file', async function () { + const { sessionUri, workspace } = await createSession('edit-file'); + const file = join(workspace, 'peer-edit.txt'); + writeFileSync(file, 'BEFORE_PEER'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + await driveTurn(peer, 'peer-edit', `Replace the complete contents of ${file} with AFTER_PEER.`, 1); + + assert.strictEqual(readFileSync(file, 'utf8').trim(), 'AFTER_PEER'); + }, config.supportsMultipleChats && (config.provider !== 'copilotcli' || shellToolReplayEnabled)); + + // Copilot's fixture uses a POSIX shell for this mutation. + providerTest('peer chat creates a file in a nested directory', async function () { + const { sessionUri, workspace } = await createSession('nested-create'); + const file = join(workspace, 'peer-output', 'report.txt'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + await driveTurn(peer, 'peer-nested-create', `Create the file at ${file} containing exactly PEER_NESTED.`, 1); + + assert.strictEqual(readFileSync(file, 'utf8'), 'PEER_NESTED'); + }, config.supportsMultipleChats && (config.provider !== 'copilotcli' || shellToolReplayEnabled)); + + providerTest('peer chat handles a missing workspace file without an error', async function () { + const { sessionUri, workspace } = await createSession('missing-file'); + const file = join(workspace, 'peer-missing.txt'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + const response = await driveTurn(peer, 'peer-missing', `Try to read ${file}. If it does not exist, reply exactly "missing".`, 1); + + assert.match(response, /missing/i); + }); + + providerTest('peer chat reads a filename containing spaces', async function () { + const { sessionUri, workspace } = await createSession('spaces'); + const file = join(workspace, 'peer file.txt'); + writeFileSync(file, 'PEER_SPACED'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + const response = await driveTurn(peer, 'peer-spaces', `Read the file at ${file} and reply with its exact contents only.`, 1); + + assert.match(response, /PEER_SPACED/); + }); + + providerTest('two peer chats write distinct workspace files', async function () { + const { sessionUri, workspace } = await createSession('two-writers'); + const firstFile = join(workspace, 'first-peer.txt'); + const secondFile = join(workspace, 'second-peer.txt'); + const first = await createPeer(sessionUri, 'first'); + const second = await createPeer(sessionUri, 'second'); + await context.client.call('subscribe', { channel: first }); + await context.client.call('subscribe', { channel: second }); + + await driveTurn(first, 'first-write', `Create the file at ${firstFile} containing exactly FIRST_PEER.`, 1); + await driveTurn(second, 'second-write', `Create the file at ${secondFile} containing exactly SECOND_PEER.`, 10); + + assert.deepStrictEqual({ + first: readFileSync(firstFile, 'utf8'), + second: readFileSync(secondFile, 'utf8'), + }, { + first: 'FIRST_PEER', + second: 'SECOND_PEER', + }); + }); + + // Claude's shared SDK process loses its `host` MCP server after the preceding peer-lifecycle sequence. + providerTest('fresh peer chat does not inherit default chat context', async function () { + const { sessionUri, defaultChatUri } = await createSession('fresh-context'); + await driveTurn(defaultChatUri, 'default-secret', 'Remember the code word DEFAULTSECRET. Reply exactly "ready".', 1); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + await driveTurn(peer, 'peer-fresh-context', 'Reply exactly "fresh".', 10); + const messages = observedModelMessages(context.observedModelRequestBodies.at(-1) ?? ''); + + assert.strictEqual(messages.some(message => message.content.includes('DEFAULTSECRET')), false); + }, config.supportsMultipleChats && config.provider !== 'claude'); + + providerTest('two peer chats keep independent provider contexts', async function () { + const { sessionUri } = await createSession('two-contexts'); + const first = await createPeer(sessionUri, 'first'); + const second = await createPeer(sessionUri, 'second'); + await context.client.call('subscribe', { channel: first }); + await context.client.call('subscribe', { channel: second }); + await driveTurn(first, 'first-context', 'Remember the code word ALPHA_PEER. Reply exactly "ready".', 1); + await driveTurn(second, 'second-context', 'Remember the code word BETA_PEER. Reply exactly "ready".', 10); + + await driveTurn(first, 'first-followup', 'Reply exactly "first".', 20); + const firstMessages = observedModelMessages(context.observedModelRequestBodies.at(-1) ?? ''); + await driveTurn(second, 'second-followup', 'Reply exactly "second".', 30); + const secondMessages = observedModelMessages(context.observedModelRequestBodies.at(-1) ?? ''); + + assert.deepStrictEqual({ + firstHasAlpha: firstMessages.some(message => message.content.includes('ALPHA_PEER')), + firstHasBeta: firstMessages.some(message => message.content.includes('BETA_PEER')), + secondHasBeta: secondMessages.some(message => message.content.includes('BETA_PEER')), + secondHasAlpha: secondMessages.some(message => message.content.includes('ALPHA_PEER')), + }, { + firstHasAlpha: true, + firstHasBeta: false, + secondHasBeta: true, + secondHasAlpha: false, + }); + }); + + providerTest('peer provider context survives unsubscribe and resubscribe', async function () { + const { sessionUri } = await createSession('resume-context'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + await driveTurn(peer, 'peer-resume-1', 'Remember the code word RESUME_PEER. Reply exactly "ready".', 1); + context.client.notify('unsubscribe', { channel: peer }); + await context.client.call('subscribe', { channel: peer }); + + await driveTurn(peer, 'peer-resume-2', 'Reply exactly "resumed".', 10); + const messages = observedModelMessages(context.observedModelRequestBodies.at(-1) ?? ''); + + assert.ok(messages.some(message => message.content.includes('RESUME_PEER'))); + }); + + providerTest('recreated peer chat starts with fresh provider context', async function () { + const { sessionUri } = await createSession('reset-context'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + await driveTurn(peer, 'peer-old-context', 'Remember the code word OLD_PEER. Reply exactly "ready".', 1); + await context.client.call('disposeChat', { channel: peer }, 30_000); + await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + + await driveTurn(peer, 'peer-new-context', 'Reply exactly "new".', 10); + const messages = observedModelMessages(context.observedModelRequestBodies.at(-1) ?? ''); + + assert.strictEqual(messages.some(message => message.content.includes('OLD_PEER')), false); + }); + + forkProviderTest('unknown-turn fork does not inherit source provider context', async function () { + const { sessionUri, defaultChatUri } = await createSession('unknown-fork-context'); + await driveTurn(defaultChatUri, 'source-secret', 'Remember the code word SOURCE_SECRET. Reply exactly "ready".', 1); + const peer = await createPeer(sessionUri, 'fork', { chat: defaultChatUri, turnId: 'missing-turn' }); + await context.client.call('subscribe', { channel: peer }); + + await driveTurn(peer, 'fresh-fork-turn', 'Reply exactly "fresh".', 10); + const messages = observedModelMessages(context.observedModelRequestBodies.at(-1) ?? ''); + + assert.strictEqual(messages.some(message => message.content.includes('SOURCE_SECRET')), false); + }); + + providerTest('peer simple attachment reaches the provider request', async function () { + const { sessionUri } = await createSession('simple-attachment'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + const attachments: MessageAttachment[] = [{ + type: MessageAttachmentKind.Simple, + label: 'peer-note.txt', + displayKind: 'document', + modelRepresentation: 'PEER_SIMPLE_ATTACHMENT', + }]; + + await driveTurn(peer, 'peer-simple-attachment', 'Reply exactly "attachment".', 1, attachments); + + assert.ok((context.observedModelRequestBodies.at(-1) ?? '').includes('PEER_SIMPLE_ATTACHMENT')); + }); + + providerTest('peer simple attachment without a model representation is omitted from the provider request', async function () { + const { sessionUri } = await createSession('simple-attachment-omitted'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + const attachments: MessageAttachment[] = [{ + type: MessageAttachmentKind.Simple, + label: 'PEER_OMITTED_ATTACHMENT', + }]; + + await driveTurn(peer, 'peer-simple-attachment-omitted', 'Reply exactly "attachment".', 1, attachments); + + assert.strictEqual((context.observedModelRequestBodies.at(-1) ?? '').includes('PEER_OMITTED_ATTACHMENT'), false); + }); + + providerTest('peer multiple simple attachments reach the provider request', async function () { + const { sessionUri } = await createSession('multiple-attachments'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + const attachments: MessageAttachment[] = [ + { + type: MessageAttachmentKind.Simple, + label: 'first', + modelRepresentation: 'PEER_FIRST_ATTACHMENT', + }, + { + type: MessageAttachmentKind.Simple, + label: 'second', + modelRepresentation: 'PEER_SECOND_ATTACHMENT', + }, + ]; + + await driveTurn(peer, 'peer-multiple-attachments', 'Reply exactly "attachments".', 1, attachments); + + const request = context.observedModelRequestBodies.at(-1) ?? ''; + assert.ok(request.includes('PEER_FIRST_ATTACHMENT') && request.includes('PEER_SECOND_ATTACHMENT')); + }); + + providerTest('peer resource attachment reaches the provider request', async function () { + const { sessionUri, workspace } = await createSession('resource-attachment'); + const file = join(workspace, 'peer-resource.txt'); + writeFileSync(file, 'PEER_RESOURCE_ATTACHMENT'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + const attachments: MessageAttachment[] = [{ + type: MessageAttachmentKind.Resource, + uri: URI.file(file).toString(), + label: 'peer-resource.txt', + displayKind: 'document', + }]; + + await driveTurn(peer, 'peer-resource-attachment', 'Reply exactly "attachment".', 1, attachments); + + assert.ok((context.observedModelRequestBodies.at(-1) ?? '').includes('peer-resource.txt')); + }); + + providerTest('peer resource selection attachment includes its line reference', async function () { + const { sessionUri, workspace } = await createSession('resource-selection'); + const file = join(workspace, 'peer-selection.txt'); + writeFileSync(file, 'first\nsecond\nthird'); + const peer = await createPeer(sessionUri, 'peer'); + await context.client.call('subscribe', { channel: peer }); + const attachments: MessageAttachment[] = [{ + type: MessageAttachmentKind.Resource, + uri: URI.file(file).toString(), + label: 'peer-selection.txt', + displayKind: 'selection', + selection: { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 6 }, + }, + }, + }]; + + await driveTurn(peer, 'peer-resource-selection', 'Reply exactly "selection".', 1, attachments); + + const request = context.observedModelRequestBodies.at(-1) ?? ''; + assert.ok(request.includes('peer-selection.txt') && (request.includes('peer-selection.txt:2') || request.includes('(line 2)'))); + }); +} diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/turnLifecycleSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/turnLifecycleSuite.ts index b571959b8279bc..0c592605feb43e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/turnLifecycleSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/turnLifecycleSuite.ts @@ -43,13 +43,20 @@ export function defineTurnLifecycleTests(context: IAgentHostE2ETestContext): voi // continuation can outlive any reasonable test timeout for trivial // prompts like this one. let nextSeq = 2; + // waitForNotification retains matched notifications, so skip ones already handled. + const processedSeqs = new Set(); while (true) { - const next = await context.client.waitForNotification(n => - (isActionNotification(n, 'chat/toolCallReady') + const next = await context.client.waitForNotification(n => { + const isRelevant = (isActionNotification(n, 'chat/toolCallReady') && (getActionEnvelope(n).action as { confirmed?: string }).confirmed === undefined) - || isActionNotification(n, 'chat/toolCallComplete') - || isActionNotification(n, 'chat/error'), - 90_000); + || isActionNotification(n, 'chat/toolCallComplete') + || isActionNotification(n, 'chat/error'); + if (!isRelevant) { + return false; + } + return !processedSeqs.has(getActionEnvelope(n).serverSeq); + }, 90_000); + processedSeqs.add(getActionEnvelope(next).serverSeq); if (isActionNotification(next, 'chat/error')) { throw new Error('Session error during permission test'); } From 82a00be53e5ad7ad843f62830f68e2dd250745e6 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Wed, 22 Jul 2026 18:19:00 +0000 Subject: [PATCH 24/34] fix: memory leak in terminalProcessManager (#326930) * fix: dispose terminal environment listener * test: cover terminal environment listener cleanup --- .../browser/terminalProcessManager.ts | 6 ++++-- .../browser/terminalProcessManager.test.ts | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index cc20eaff8d4118..1fb4120662e358 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -90,6 +90,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce private _preLaunchInputQueue: string[] = []; private _initialCwd: string | undefined; private _extEnvironmentVariableCollection: IMergedEnvironmentVariableCollection | undefined; + private readonly _environmentVariableCollectionListener = this._register(new MutableDisposable()); private _ackDataBufferer: AckDataBufferer; private _hasWrittenData: boolean = false; private _hasChildProcesses: boolean = false; @@ -186,7 +187,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce if (environmentVariableCollections) { this._extEnvironmentVariableCollection = new MergedEnvironmentVariableCollection(environmentVariableCollections); - this._register(this._environmentVariableService.onDidChangeCollections(newCollection => this._onEnvironmentVariableCollectionChange(newCollection))); + this._environmentVariableCollectionListener.value = this._environmentVariableService.onDidChangeCollections(newCollection => this._onEnvironmentVariableCollectionChange(newCollection)); this.environmentVariableInfo = this._instantiationService.createInstance(EnvironmentVariableInfoChangesActive, this._extEnvironmentVariableCollection); this._onEnvironmentVariableInfoChange.fire(this.environmentVariableInfo); } @@ -469,10 +470,11 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce } const env = await terminalEnvironment.createTerminalEnvironment(shellLaunchConfig, envFromConfigValue, variableResolver, this._productService.version, this._terminalConfigurationService.config.detectLocale, baseEnv); this._logService.debug(`Terminal environment created with ${Object.keys(env).length} variables: ${Object.keys(env).sort().join(', ')}`); + this._environmentVariableCollectionListener.clear(); if (!this._isDisposed && shouldUseEnvironmentVariableCollection(shellLaunchConfig)) { this._extEnvironmentVariableCollection = this._environmentVariableService.mergedCollection; - this._register(this._environmentVariableService.onDidChangeCollections(newCollection => this._onEnvironmentVariableCollectionChange(newCollection))); + this._environmentVariableCollectionListener.value = this._environmentVariableService.onDidChangeCollections(newCollection => this._onEnvironmentVariableCollectionChange(newCollection)); // For remote terminals, this is a copy of the mergedEnvironmentCollection created on // the remote side. Since the environment collection is synced between the remote and // local sides immediately this is a fairly safe way of enabling the env var diffing and diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts index 85738f7f593215..062814acc2ac08 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts @@ -14,8 +14,13 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { ITerminalChildProcess, type ITerminalBackend } from '../../../../../platform/terminal/common/terminal.js'; import { ITerminalInstanceService, ITerminalService } from '../../browser/terminal.js'; import { TerminalProcessManager } from '../../browser/terminalProcessManager.js'; +import { IEnvironmentVariableService } from '../../common/environmentVariable.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +function listenerCount(emitter: Emitter): number { + return (emitter as unknown as { _size: number })._size ?? 0; +} + class TestTerminalChildProcess implements ITerminalChildProcess { id: number = 0; get capabilities() { return []; } @@ -80,6 +85,7 @@ class TestTerminalInstanceService implements Partial { suite('Workbench - TerminalProcessManager', () => { let manager: TerminalProcessManager; let terminalInstanceService: TestTerminalInstanceService; + let environmentVariableService: IEnvironmentVariableService; const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -103,10 +109,24 @@ suite('Workbench - TerminalProcessManager', () => { store.add(terminalInstanceService.ptyHostRestartEmitter); instantiationService.stub(ITerminalInstanceService, terminalInstanceService); instantiationService.stub(ITerminalService, { setNextCommandId: async () => { } } as Partial); + environmentVariableService = instantiationService.get(IEnvironmentVariableService); manager = store.add(instantiationService.createInstance(TerminalProcessManager, 1, undefined, undefined, undefined)); }); + test('does not accumulate environment variable collection listeners when relaunching', async () => { + const changeCollectionsEmitter = (environmentVariableService as unknown as { _onDidChangeCollections: Emitter })._onDidChangeCollections; + const initialListenerCount = listenerCount(changeCollectionsEmitter); + + await manager.createProcess({}, 80, 24, false); + strictEqual(listenerCount(changeCollectionsEmitter), initialListenerCount + 1); + + for (let i = 0; i < 3; i++) { + await manager.relaunch({}, 80, 24, false); + strictEqual(listenerCount(changeCollectionsEmitter), initialListenerCount + 1); + } + }); + suite('process persistence', () => { suite('local', () => { test('regular terminal should persist', async () => { From dd33efd4fea10c9995f6cb66b50ba05473855883 Mon Sep 17 00:00:00 2001 From: SteVen Batten Date: Wed, 22 Jul 2026 11:40:53 -0700 Subject: [PATCH 25/34] remove unnecessary condition (#326699) --- .../node/protocol/resourceOperations.integrationTest.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/protocol/resourceOperations.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/resourceOperations.integrationTest.ts index 8ad66e71106bab..390139917a8505 100644 --- a/src/vs/platform/agentHost/test/node/protocol/resourceOperations.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/resourceOperations.integrationTest.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from '../../../../../base/common/path.js'; -import { isCI, isLinux, isMacintosh, isWindows } from '../../../../../base/common/platform.js'; +import { isLinux, isMacintosh, isWindows } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; import { ContentEncoding, ResourceType, ResourceWriteMode, type ResourceListResult, type ResourceReadResult, type ResourceResolveResult } from '../../../common/state/protocol/common/commands.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; @@ -365,8 +365,8 @@ suite('Protocol WebSocket - Resource Operations', function () { }), /resource not found/i); }); - // File watcher delivery is unreliable in the Linux and Windows Electron environments, and in macOS Electron CI. - (isLinux || isWindows || (isMacintosh && isCI) ? test.skip : test)('non-recursive resource watch emits a change action', async function () { + // File watcher delivery is unreliable in all environments. + (isLinux || isWindows || isMacintosh ? test.skip : test)('non-recursive resource watch emits a change action', async function () { const watch = await client.call<{ channel: string }>('createResourceWatch', { channel: ROOT_STATE_URI, uri: URI.file(testDirectory).toString(), From 0db54e43fd88d72f88fc38171506568e17ace027 Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:49:14 -0700 Subject: [PATCH 26/34] added classification comments for response events (#327017) * added classification comments for response events * Classify forwarded Copilot identifiers as pseudonymous Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../copilotGitHubTelemetryForwarder.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts index 6dd7ef78c9e68d..24e6d69724b85a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotGitHubTelemetryForwarder.ts @@ -6,6 +6,79 @@ import type { GitHubTelemetryNotification } from '@github/copilot-sdk'; import { ITelemetryData, ITelemetryService } from '../../../telemetry/common/telemetry.js'; +/* __GDPR__FRAGMENT__ + "CopilotCliForwardedTelemetry": { + "created_at": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Timestamp when the SDK created the event." }, + "model_call_id": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "SDK identifier for the model call." }, + "exp_assignment_context": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Experiment assignment context from the Copilot CLI runtime." }, + "session_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier for the Copilot CLI session." }, + "sdk_session_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier for the SDK session that forwarded the event." }, + "copilot_tracking_id": { "classification": "EndUserPseudonymizedInformation", "purpose": "BusinessInsight", "comment": "Pseudonymous Copilot user identifier supplied by the runtime." }, + "kind": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Kind of SDK telemetry event." }, + "cli_version": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Version of the Copilot CLI runtime." }, + "os_platform": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Operating system platform of the Copilot CLI runtime." }, + "os_version": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Operating system version of the Copilot CLI runtime." }, + "os_arch": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Operating system architecture of the Copilot CLI runtime." }, + "node_version": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Node.js version of the Copilot CLI runtime." }, + "copilot_plan": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Copilot subscription plan reported by the runtime." }, + "client_type": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Type of client that produced the event." }, + "client_name": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Name of the client that produced the event." }, + "dev_device_id": { "classification": "EndUserPseudonymizedInformation", "purpose": "BusinessInsight", "comment": "Pseudonymous device identifier supplied by the runtime." }, + "is_staff": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the user is a GitHub or Microsoft staff member.", "isMeasurement": true }, + "restricted": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the SDK marked the event as restricted telemetry.", "isMeasurement": true }, + "${wildcard}": [{ + "${prefix}": "feature.", + "${classification}": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Feature flag value supplied by the Copilot CLI runtime." } + }] + } +*/ + +/* __GDPR__ + "copilotCli/response.success": { + "owner": "amunger", + "comment": "Reports performance and usage details for successful Copilot CLI model responses forwarded by the Copilot SDK.", + "${include}": [ "${CopilotCliForwardedTelemetry}" ], + "reason": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reason the response completed." }, + "model": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Model selected for the response." }, + "apiType": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "API type used for the response." }, + "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Identifier for the request." }, + "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub identifier for the request." }, + "modelCallId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Identifier for the model call." }, + "reasoningEffort": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reasoning effort used for the response." }, + "initiatorType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the response was initiated by a user or an agent." }, + "copilot_pid": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Process identifier for the Copilot CLI runtime." }, + "interaction_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier that correlates events in an interaction." }, + "engagement_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier that correlates events in an engagement." }, + "promptTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of input prompt tokens.", "isMeasurement": true }, + "promptCacheTokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of input prompt tokens read from cache.", "isMeasurement": true }, + "cacheWriteTokens": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of input prompt tokens written to cache.", "isMeasurement": true }, + "completionTokens": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of generated completion tokens.", "isMeasurement": true }, + "reasoningTokens": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of generated reasoning tokens.", "isMeasurement": true }, + "tokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Total number of tokens used by the response.", "isMeasurement": true }, + "turn": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Turn number within the session.", "isMeasurement": true }, + "timeToFirstToken": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Time until the first response token.", "isMeasurement": true }, + "timeToComplete": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Time until the response completed.", "isMeasurement": true } + } +*/ + +/* __GDPR__ + "copilotCli/response.error": { + "owner": "amunger", + "comment": "Reports performance and usage details for failed Copilot CLI model responses forwarded by the Copilot SDK.", + "${include}": [ "${CopilotCliForwardedTelemetry}" ], + "type": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Type of response failure." }, + "model": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Model selected for the response." }, + "apiType": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "API type used for the response." }, + "requestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Identifier for the request." }, + "gitHubRequestId": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "GitHub identifier for the request." }, + "reasoningEffort": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Reasoning effort used for the response." }, + "copilot_pid": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Process identifier for the Copilot CLI runtime." }, + "interaction_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier that correlates events in an interaction." }, + "engagement_id": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Identifier that correlates events in an engagement." }, + "transport": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Transport used for the request." } + } +*/ + /** * Re-emits GitHub-shaped telemetry events forwarded by the Copilot CLI runtime * (via the SDK's `onGitHubTelemetry` connection-global callback) through VS From de7b8e2811af44a1f2683c21de870a5059536d5f Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:57:29 -0700 Subject: [PATCH 27/34] Fix: apply chat.defaultModel on every new local session (#327011) * Fix: apply chat.defaultModel on every new local session * Tests and comment update --- .../chatInputModelSelectionController.ts | 7 +- .../input/chatModelSelectionModel.test.ts | 110 +++++++++++++++++- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts index 91c3e0c7d11809..51e50087a1d037 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputModelSelectionController.ts @@ -231,8 +231,13 @@ export class ChatInputModelSelectionController extends Disposable { } applyConfiguredDefault(): boolean { + // `chat.defaultModel` is the default for every new (empty) conversation. Only a genuine + // in-conversation choice blocks it: an explicit user pick or a mode-forced programmatic + // selection. `SessionRestore` on an empty session is just spillover from the previous + // session and must yield (a real reopened conversation is non-empty → `!isEmpty()`). if (!this._runtime.isEmpty() - || isAuthoritativeModelSelectionReason(this._selectionReason) + || this._selectionReason === ModelSelectionReason.UserSelection + || this._selectionReason === ModelSelectionReason.ProgrammaticSelection || (this._intent && this._intent.kind !== 'remembered')) { return false; } diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts index 37e1c511f5240b..5487a385d8f5e5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionModel.test.ts @@ -40,6 +40,8 @@ interface IRuntimeState { resolved: boolean; readonly sessionType: string; configuredModel?: string; + /** Defaults to `true` (a new/empty session). Set to `false` to model a reopened conversation with history. */ + isEmpty?: boolean; } function createRuntime( @@ -51,7 +53,7 @@ function createRuntime( location: ChatAgentLocation.Chat, getCurrentModeKind: () => ChatModeKind.Ask, getCurrentSessionType: () => state.sessionType, - isEmpty: () => true, + isEmpty: () => state.isEmpty ?? true, getModels: () => state.models, getAllModels: () => state.models, requiresCustomModels: () => false, @@ -554,6 +556,9 @@ suite('ChatInputModelSelectionController', () => { }); test('late configured default does not overwrite a restored conversation model', () => { + // A genuine reopened conversation is NON-empty, so the configured default must never override + // its restored model. The empty/new-session case (where the configured default wins over a + // spilled-over restore) is covered by the empty-session tests above. const restored = model('test/restored'); const configured = model('copilot/configured'); let models = [restored]; @@ -562,7 +567,7 @@ suite('ChatInputModelSelectionController', () => { location: ChatAgentLocation.Chat, getCurrentModeKind: () => ChatModeKind.Ask, getCurrentSessionType: () => undefined, - isEmpty: () => true, + isEmpty: () => false, getModels: () => models, getAllModels: () => models, requiresCustomModels: () => false, @@ -704,6 +709,103 @@ suite('ChatInputModelSelectionController', () => { }); }); + test('re-applies the configured default over a spilled-over session-restore on an empty session', () => { + // Regression for the local "+ new session" / back-to-list cases: a new empty session that + // inherits the previous session's model as a session-restore must still reset to the + // configured `chat.defaultModel`. See the SessionRestore-is-not-a-blocker rule in + // `applyConfiguredDefault`. + const gpt = model('test/gpt'); + const opus = model('test/opus'); + const modelChanges = disposables.add(new Emitter()); + const applied: string[] = []; + const controller = disposables.add(new ChatInputModelSelectionController( + createRuntime({ models: [gpt, opus], resolved: true, sessionType: 'test', configuredModel: gpt.metadata.id }, modelChanges, applied))); + + controller.beginSessionSwitch(true, false, false); + controller.syncFromConversationState(opus, undefined, 'test', 'chat:one'); + const afterSpillover = controller.currentModel.get()?.identifier; + const configuredApplied = controller.applyConfiguredDefault(); + + assert.deepStrictEqual({ afterSpillover, configuredApplied, applied, current: controller.currentModel.get()?.identifier }, { + afterSpillover: opus.identifier, + configuredApplied: true, + applied: [opus.identifier, gpt.identifier], + current: gpt.identifier, + }); + }); + + test('preserves an explicit user pick on an empty session over the configured default', () => { + const gpt = model('test/gpt'); + const opus = model('test/opus'); + const modelChanges = disposables.add(new Emitter()); + const applied: string[] = []; + const controller = disposables.add(new ChatInputModelSelectionController( + createRuntime({ models: [gpt, opus], resolved: true, sessionType: 'test', configuredModel: gpt.metadata.id }, modelChanges, applied))); + + controller.beginSessionSwitch(true, false, false); + controller.applyExplicitSelection(opus, () => applied.push(opus.identifier), false); + const configuredApplied = controller.applyConfiguredDefault(); + + assert.deepStrictEqual({ configuredApplied, applied, current: controller.currentModel.get()?.identifier, userPicked: controller.userExplicitlySelectedModel }, { + configuredApplied: false, + applied: [opus.identifier], + current: opus.identifier, + userPicked: true, + }); + }); + + test('keeps the restored model on a reopened non-empty conversation even when a default is configured', () => { + const gpt = model('test/gpt'); + const opus = model('test/opus'); + const applied: string[] = []; + const runtime: IChatInputModelSelectionRuntime = { + location: ChatAgentLocation.Chat, + getCurrentModeKind: () => ChatModeKind.Ask, + getCurrentSessionType: () => undefined, + isEmpty: () => false, + getModels: () => [gpt, opus], + getAllModels: () => [gpt, opus], + requiresCustomModels: () => false, + getConfiguredModelValue: () => gpt.metadata.id, + resolveModelIdentifier: identifier => resolveModelIdentifier([gpt, opus], identifier, true), + subscribeToModelChanges: () => toDisposable(() => { }), + getBoundConversationKey: () => 'chat:one', + getVisibleConversationKey: () => 'chat:one', + restoreModelConfiguration: () => { }, + applyModel: selected => applied.push(selected.identifier), + }; + const controller = disposables.add(new ChatInputModelSelectionController(runtime)); + + controller.syncFromConversationState(opus, undefined, undefined, 'chat:one'); + const configuredApplied = controller.applyConfiguredDefault(); + + assert.deepStrictEqual({ configuredApplied, applied, current: controller.currentModel.get()?.identifier }, { + configuredApplied: false, + applied: [opus.identifier], + current: opus.identifier, + }); + }); + + test('leaves the spilled-over model sticky when no default model is configured', () => { + // The fix must be inert when `chat.defaultModel` is unset: sticky "last-used" behavior wins. + const gpt = model('test/gpt'); + const opus = model('test/opus'); + const modelChanges = disposables.add(new Emitter()); + const applied: string[] = []; + const controller = disposables.add(new ChatInputModelSelectionController( + createRuntime({ models: [gpt, opus], resolved: true, sessionType: 'test' }, modelChanges, applied))); + + controller.beginSessionSwitch(true, false, false); + controller.syncFromConversationState(opus, undefined, 'test', 'chat:one'); + const configuredApplied = controller.applyConfiguredDefault(); + + assert.deepStrictEqual({ configuredApplied, applied, current: controller.currentModel.get()?.identifier }, { + configuredApplied: false, + applied: [opus.identifier], + current: opus.identifier, + }); + }); + test('replaces a BYOK first-available model when the Copilot default loads later', () => { const modelChanges = disposables.add(new Emitter()); const byok = model('openai/byok'); @@ -924,7 +1026,9 @@ suite('ChatInputModelSelectionController', () => { const matchBase = targetedModel('test/match', sessionType); const match = { ...matchBase, metadata: { ...matchBase.metadata, id: desired.metadata.id } }; const configured = targetedModel('test/configured', sessionType); - const state: IRuntimeState = { models: [], resolved: false, sessionType, configuredModel: configured.metadata.id }; + // A genuine reopened conversation is NON-empty, so its best-match restore stays authoritative and + // the configured default must not override it. The empty-session behavior is covered above. + const state: IRuntimeState = { models: [], resolved: false, sessionType, configuredModel: configured.metadata.id, isEmpty: false }; const applied: string[] = []; const controller = disposables.add(new ChatInputModelSelectionController(createRuntime(state, modelChanges, applied))); From 42859562ed8d1ee1b8936407bda043592d75166c Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Jul 2026 14:57:53 -0400 Subject: [PATCH 28/34] Dictation/voice mic UX: context menu, mutual hide, spacing, hands-free default (#327007) --- .../contrib/chat/browser/newChatInput.ts | 25 +++- .../contrib/chat/browser/newChatVoice.ts | 20 ++- .../browser/sessionsChatAccessibilityHelp.ts | 1 + .../browser/agentsVoice.contribution.ts | 11 +- .../contrib/agentsVoice/common/agentsVoice.ts | 10 ++ .../browser/actions/chatAccessibilityHelp.ts | 1 + .../actions/chatSpeechToTextActions.ts | 8 +- .../speechToText/dictationActionViewItem.ts | 51 ++++++++ .../dictationDownloadActionViewItem.ts | 17 +++ .../speechToText/micButtonMenuActions.ts | 114 ++++++++++++++++++ .../voiceClient/voiceModeActionViewItem.ts | 58 +++++++++ .../voiceClient/voiceSessionController.ts | 6 +- .../browser/widget/input/chatInputPart.ts | 14 ++- .../chat/browser/widget/media/chat.css | 3 + 14 files changed, 323 insertions(+), 16 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/speechToText/dictationActionViewItem.ts create mode 100644 src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts create mode 100644 src/vs/workbench/contrib/chat/browser/voiceClient/voiceModeActionViewItem.ts diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 4debaf235cca83..a5fe2893435a46 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -60,6 +60,7 @@ import { INotificationService } from '../../../../platform/notification/common/n import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { addMicButtonContextMenuListener, getDictationContextMenuActions } from '../../../../workbench/contrib/chat/browser/speechToText/micButtonMenuActions.js'; import { SlashCommandHandler } from './slashCommands.js'; import { VariableCompletionHandler } from './variableCompletions.js'; import { SessionReferenceCompletionHandler } from './sessionReferenceCompletions.js'; @@ -86,6 +87,7 @@ import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../../../wor import { runDictationShortcut } from '../../../../workbench/contrib/chat/browser/actions/chatSpeechToTextActions.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { DictationDownloadRing } from '../../../../workbench/contrib/chat/browser/speechToText/dictationDownloadRing.js'; +import { IVoiceSessionController } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; const OPEN_OTEL_SETTINGS_COMMAND = 'github.copilot.chat.otel.openSettings'; @@ -359,6 +361,9 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IChatSpeechToTextService private readonly chatSpeechToTextService: IChatSpeechToTextService, @IChatSubmitRequestHandlerService private readonly chatSubmitRequestHandlerService: IChatSubmitRequestHandlerService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @ICommandService private readonly commandService: ICommandService, + @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, ) { super(); this._sessionModelSelectionModel = this._register(this.instantiationService.createInstance(SessionModelSelectionModel, this.options.session)); @@ -830,9 +835,18 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this._register(sttService.onDidChangePreparingModel(renderState)); const updateVisibility = () => { - button.classList.toggle('hidden', !sttService.isConfigured); + // Mirror the `MenuId.ChatExecute` dictation gate: hide while + // unconfigured, and while Voice Mode is connected so the dictation and + // voice mic affordances never compete on this composer. + const voiceActive = this.voiceSessionController.isConnected.get() || this.voiceSessionController.isConnecting.get(); + button.classList.toggle('hidden', !sttService.isConfigured || voiceActive); }; updateVisibility(); + this._register(autorun(reader => { + this.voiceSessionController.isConnected.read(reader); + this.voiceSessionController.isConnecting.read(reader); + updateVisibility(); + })); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('chat.speechToText.enabled')) { updateVisibility(); @@ -856,6 +870,15 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation void toggle(); } })); + + // Right-click shows dictation-specific entries ("Configure Keybinding", + // "Select Microphone", "Disable Dictation") mirroring the chat-input mic + // button, since this custom button isn't a `MenuEntryActionViewItem`. + this._register(addMicButtonContextMenuListener( + button, + () => getDictationContextMenuActions(this.commandService, this.configurationService, this.keybindingService, TOGGLE_DICTATION_COMMAND_ID), + this.contextMenuService, + )); } /** diff --git a/src/vs/sessions/contrib/chat/browser/newChatVoice.ts b/src/vs/sessions/contrib/chat/browser/newChatVoice.ts index 87088d25c13919..784eeb8732bf8e 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatVoice.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatVoice.ts @@ -9,7 +9,7 @@ import { Disposable, IDisposable, toDisposable } from '../../../../base/common/l import { IObservable, autorun, derived, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; -import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { MenuId, MenuItemAction, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; @@ -20,6 +20,7 @@ import { IKeybindingService } from '../../../../platform/keybinding/common/keybi import { IMicCaptureService } from '../../../../workbench/contrib/chat/browser/voiceClient/micCaptureService.js'; import { ITtsPlaybackService } from '../../../../workbench/contrib/chat/browser/voiceClient/ttsPlaybackService.js'; import { IVoiceSessionController } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; +import { VoiceModeActionViewItem } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceModeActionViewItem.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { setupVoiceInputDecorations } from './voiceInputDecorations.js'; @@ -102,6 +103,12 @@ const WHEN_LISTENING = ContextKeyExpr.equals('agentsVoiceListening', true); const WHEN_CONNECTED = ContextKeyExpr.equals('agentsVoiceConnected', true); const WHEN_INITIATED_HERE = ContextKeyExpr.equals('agentsVoiceInitiatedHere', true); const WHEN_VOICE_SURFACE = ContextKeyExpr.equals('newChatVoiceSurface', true); +// Hide Voice Mode while dictation is active (recording or the model is loading) +// so the two mic affordances never compete, mirroring `MenuId.ChatExecute`. +const WHEN_NOT_DICTATING = ContextKeyExpr.and( + ContextKeyExpr.has('chatSpeechToTextRecording').negate(), + ContextKeyExpr.has('chatSpeechToTextPreparing').negate(), +); MenuRegistry.appendMenuItem(SessionsNewChatVoiceMenu, { command: { id: 'agentsVoice.connecting', title: localize('agentsVoice.connecting', "Connecting..."), icon: Codicon.loading }, @@ -112,7 +119,7 @@ MenuRegistry.appendMenuItem(SessionsNewChatVoiceMenu, { MenuRegistry.appendMenuItem(SessionsNewChatVoiceMenu, { command: { id: 'agentsVoice.startVoiceInChat', title: localize('agentsVoice.startVoiceInChat', "Voice Mode"), icon: Codicon.voiceMode }, - when: ContextKeyExpr.and(WHEN_VOICE_ENABLED, WHEN_VOICE_SURFACE, WHEN_LISTENING.negate(), WHEN_CONNECTING.negate()), + when: ContextKeyExpr.and(WHEN_VOICE_ENABLED, WHEN_VOICE_SURFACE, WHEN_LISTENING.negate(), WHEN_CONNECTING.negate(), WHEN_NOT_DICTATING), group: 'navigation', order: -10, }); @@ -180,6 +187,15 @@ export class NewChatVoiceController extends Disposable { this._register(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, options.toolbarContainer, SessionsNewChatVoiceMenu, { hiddenItemStrategy: HiddenItemStrategy.NoHide, + actionViewItemProvider: (action, itemOptions) => { + // While listening the menu swaps the start action for the + // push-to-talk stop action; cover both so the context menu + // (Select Microphone / Disable Voice Mode) stays available. + if ((action.id === 'agentsVoice.startVoiceInChat' || action.id === 'agentsVoice.pttStopInChat') && action instanceof MenuItemAction) { + return scopedInstantiationService.createInstance(VoiceModeActionViewItem, action, itemOptions); + } + return undefined; + }, })); // Target the active composer before a session exists, or when it opts in diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index de5854cd433325..40d1c53d826398 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -33,6 +33,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.history', "Use up and down arrows to navigate your request history in the input box.")); content.push(localize('sessionsChat.dictation', "When speech to text is configured, dictate your message into the input using on-device speech to text{0}. Tap to start and stop, or hold to dictate only while pressed.", '')); content.push(localize('sessionsChat.voiceMode', "Start or stop Voice Mode to interact with the agent using your microphone{0}.", '')); + content.push(localize('sessionsChat.micContextMenu', "To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu (for example Shift+F10).")); content.push(localize('sessionsChat.contextReferences', "Type # in the chat input to attach context. Use #file to reference a file or folder, or #session to reference another agent session. Referencing a session together with the /troubleshoot command analyzes that session's logs instead of the current one. Accept a suggestion with Tab or Enter; the reference appears as a pill above the input that you can remove.")); content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach status pills above it, then press Enter or Space to activate a pill. A pill with multiple background activities opens a picker; use the up and down arrows to navigate, Enter to open an activity, and Escape to dismiss the picker and return focus to the pill.")); content.push(localize('sessionsChat.conversations', "When a session supports multiple chats, a New Chat button is always shown: as a labeled button in the session header while the session has a single open chat, and as a compact button at the end of the chat tab strip once the session has more than one open chat. Activate it to start a new chat. Once the session has more than one committed chat, a Conversations menu is also shown: in the session header while the tab strip is hidden, and at the end of the chat tab strip once it is shown. Open it to reopen a closed chat: each chat is listed with a checkbox, where checked chats are shown as tabs and unchecked chats are closed (hidden).")); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index df14e51aa10fcc..731e2bfdefb22b 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -32,7 +32,7 @@ import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 import { ConfigurationKeyValuePairs, IConfigurationMigrationRegistry, Extensions as WorkbenchConfigurationExtensions } from '../../../common/configuration.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { AgentsVoiceStorageKeys } from '../common/agentsVoice.js'; +import { AgentsVoiceStorageKeys, AGENTS_VOICE_CONNECTED, AGENTS_VOICE_CONNECTING, AGENTS_VOICE_LISTENING } from '../common/agentsVoice.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -50,9 +50,6 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j // --- Context Keys --- export const AGENTS_VOICE_WIDGET_FOCUSED = new RawContextKey('agentsVoiceWidgetFocused', false); -const AGENTS_VOICE_CONNECTED = new RawContextKey('agentsVoiceConnected', false); -const AGENTS_VOICE_CONNECTING = new RawContextKey('agentsVoiceConnecting', false); -const AGENTS_VOICE_LISTENING = new RawContextKey('agentsVoiceListening', false); // --- Context Key Binding --- @@ -161,6 +158,10 @@ registerAction2(class extends Action2 { ChatContextKeys.currentlyEditing.negate(), AGENTS_VOICE_LISTENING.negate(), AGENTS_VOICE_CONNECTING.negate(), + // Hide Voice Mode while dictation is active (recording or the + // model is loading) so the two mic affordances never compete. + ChatContextKeys.speechToTextRecording.negate(), + ChatContextKeys.speechToTextPreparing.negate(), ), group: 'navigation', order: -10 @@ -592,7 +593,7 @@ configurationRegistry.registerConfiguration({ 'agents.voice.handsFree': { type: 'boolean', markdownDescription: nls.localize('agents.voice.handsFree', "When enabled, voice mode automatically re-enters listening after the assistant finishes speaking, so you can hold a hands-free back-and-forth conversation. When disabled, you start each turn manually. This controls only the auto-listen loop; how a turn ends is controlled by {0} and {1}.", '`#agents.voice.turn.silenceMs#`', '`#agents.voice.turn.stopPhrases#`'), - default: false, + default: true, scope: ConfigurationScope.APPLICATION, }, 'agents.voice.turn.silenceMs': { diff --git a/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts b/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts index f393a1b4247be9..6cc3aad25c3509 100644 --- a/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts +++ b/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts @@ -4,10 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { Event } from '../../../../base/common/event.js'; import './agentsVoiceColors.js'; // Register custom voice theme colors +/** + * Context keys reflecting the live Voice Mode connection state. Defined here in + * the common layer so other contributions (e.g. chat-input dictation) can gate + * their UI on Voice Mode being active without importing the browser contribution. + */ +export const AGENTS_VOICE_CONNECTED = new RawContextKey('agentsVoiceConnected', false); +export const AGENTS_VOICE_CONNECTING = new RawContextKey('agentsVoiceConnecting', false); +export const AGENTS_VOICE_LISTENING = new RawContextKey('agentsVoiceListening', false); + /** * Default dimensions for the Agents Voice floating window. */ diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 3297a7f33ff3b0..d32a069541e178 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -86,6 +86,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui content.push(localize('chat.attachments.removal', 'To remove attached contexts, focus an attachment and press Delete or Backspace.')); content.push(localize('workbench.action.chat.toggleSpeechToText', 'To dictate your request into the input box using on-device speech-to-text, invoke the Dictate command{0}. Invoke it again to stop; recording start and stop are indicated by accessibility signals.', '')); content.push(localize('workbench.action.chat.cancelSpeechToText', 'While dictating, invoke the Cancel Dictation command{0} to stop and discard the dictated text.', '')); + content.push(localize('chat.speechToText.contextMenu', 'To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu{0} (for example Shift+F10).', '')); content.push(localize('chat.inspectResponse', 'In the input box, inspect the last response in the accessible view{0}. Thinking content is included in order by default.', '')); content.push(localize('chat.inspectResponseThinkingToggle', 'To include or exclude thinking content in the accessible view, run the Toggle Thinking Content in Accessible View command from the Command Palette.')); content.push(localize('workbench.action.chat.focus', 'To focus the chat request and response list, invoke the Focus Chat command{0}. This will move focus to the most recent response, which you can then navigate using the up and down arrow keys.', getChatFocusKeybindingLabel(keybindingService, type, 'last'))); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts index 47952400bb2452..13c90e2f10f960 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatSpeechToTextActions.ts @@ -18,7 +18,7 @@ import { KeybindingWeight } from '../../../../../platform/keybinding/common/keyb import { ILogService } from '../../../../../platform/log/common/log.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { AgentsVoiceStorageKeys } from '../../../agentsVoice/common/agentsVoice.js'; +import { AgentsVoiceStorageKeys, AGENTS_VOICE_CONNECTED } from '../../../agentsVoice/common/agentsVoice.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { CHAT_CATEGORY } from './chatActions.js'; import { IChatExecuteActionContext } from './chatExecuteActions.js'; @@ -119,7 +119,7 @@ export async function runDictationShortcut(context: IDictationShortcutContext, c await stopDictation(); } -class ToggleChatSpeechToTextAction extends Action2 { +export class ToggleChatSpeechToTextAction extends Action2 { static readonly ID = 'workbench.action.chat.toggleSpeechToText'; constructor() { @@ -137,7 +137,7 @@ class ToggleChatSpeechToTextAction extends Action2 { menu: [{ id: MenuId.ChatExecute, order: -11, - when: ContextKeyExpr.and(ChatSpeechToTextConfigured, ChatSpeechToTextPreparing.negate()), + when: ContextKeyExpr.and(ChatSpeechToTextConfigured, ChatSpeechToTextPreparing.negate(), AGENTS_VOICE_CONNECTED.negate()), group: 'navigation', }], keybinding: { @@ -192,7 +192,7 @@ export class ChatSpeechToTextPreparingAction extends Action2 { menu: [{ id: MenuId.ChatExecute, order: -11, - when: ContextKeyExpr.and(ChatSpeechToTextConfigured, ChatSpeechToTextPreparing), + when: ContextKeyExpr.and(ChatSpeechToTextConfigured, ChatSpeechToTextPreparing, AGENTS_VOICE_CONNECTED.negate()), group: 'navigation', }], }); diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationActionViewItem.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationActionViewItem.ts new file mode 100644 index 00000000000000..a533adc5e3a94e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationActionViewItem.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { addMicButtonContextMenuListener, getDictationContextMenuActions } from './micButtonMenuActions.js'; + +/** + * Action view item for the chat-input dictation mic button. Behaves like a + * normal toolbar mic (click to dictate) but adds a right-click context menu with + * dictation-specific entries — "Configure Keybinding" (mirroring the standard + * toolbar affordance), "Select Microphone" and "Disable Dictation" — instead of + * the generic toolbar context menu. + */ +export class DictationActionViewItem extends MenuEntryActionViewItem { + + constructor( + action: MenuItemAction, + options: IMenuEntryActionViewItemOptions | undefined, + @ICommandService private readonly _commandService: ICommandService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IKeybindingService keybindingService: IKeybindingService, + @INotificationService notificationService: INotificationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IThemeService themeService: IThemeService, + @IContextMenuService contextMenuService: IContextMenuService, + @IAccessibilityService accessibilityService: IAccessibilityService, + ) { + super(action, options, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService); + } + + override render(container: HTMLElement): void { + super.render(container); + + this._register(addMicButtonContextMenuListener( + container, + () => getDictationContextMenuActions(this._commandService, this._configurationService, this._keybindingService, this._action.id), + this._contextMenuService, + )); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadActionViewItem.ts b/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadActionViewItem.ts index 73c6d19af838d5..208760015f5fa0 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadActionViewItem.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/dictationDownloadActionViewItem.ts @@ -7,6 +7,8 @@ import { IManagedHoverContent } from '../../../../../base/browser/ui/hover/hover import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; @@ -14,6 +16,10 @@ import { INotificationService } from '../../../../../platform/notification/commo import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { IChatSpeechToTextService } from './chatSpeechToTextService.js'; import { DictationDownloadRing, getDictationDownloadHoverContent } from './dictationDownloadRing.js'; +import { addMicButtonContextMenuListener, getDictationContextMenuActions } from './micButtonMenuActions.js'; + +/** Command whose keybinding the context menu targets while the model prepares. */ +const TOGGLE_DICTATION_COMMAND_ID = 'workbench.action.chat.toggleSpeechToText'; /** * Toolbar affordance shown while the on-device dictation model downloads: a @@ -26,6 +32,8 @@ export class DictationDownloadActionViewItem extends MenuEntryActionViewItem { action: MenuItemAction, options: IMenuEntryActionViewItemOptions | undefined, @IChatSpeechToTextService private readonly _speechToTextService: IChatSpeechToTextService, + @ICommandService private readonly _commandService: ICommandService, + @IConfigurationService private readonly _configurationService: IConfigurationService, @IKeybindingService keybindingService: IKeybindingService, @INotificationService notificationService: INotificationService, @IContextKeyService contextKeyService: IContextKeyService, @@ -41,6 +49,15 @@ export class DictationDownloadActionViewItem extends MenuEntryActionViewItem { container.classList.add('dictation-download-item'); this._register(new DictationDownloadRing(container, this._speechToTextService)); + + // Keep the mic context menu available while the model prepares so the + // affordance doesn't lose Select Microphone / Disable Dictation during + // first-use download. + this._register(addMicButtonContextMenuListener( + container, + () => getDictationContextMenuActions(this._commandService, this._configurationService, this._keybindingService, TOGGLE_DICTATION_COMMAND_ID), + this._contextMenuService, + )); } protected override getHoverContents(): IManagedHoverContent { diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts new file mode 100644 index 00000000000000..c69f9c8b07002e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { addDisposableListener, getWindow } from '../../../../../base/browser/dom.js'; +import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; +import { IAction, toAction } from '../../../../../base/common/actions.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { createConfigureKeybindingAction } from '../../../../../platform/actions/common/menuService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; + +/** Command that opens the microphone picker shared by dictation and Voice Mode. */ +export const SELECT_MICROPHONE_COMMAND = 'workbench.action.chat.selectSpeechToTextMicrophone'; +/** Command that cancels the active/preparing dictation session. */ +const CANCEL_DICTATION_COMMAND = 'workbench.action.chat.cancelSpeechToText'; +/** Command that tears down an active Voice Mode session. */ +const VOICE_DISCONNECT_COMMAND = 'agentsVoice.disconnect'; +/** Setting that enables dictation; toggled off by "Disable Dictation". */ +const DICTATION_ENABLED_SETTING = 'chat.speechToText.enabled'; +/** Setting that enables Voice Mode; toggled off by "Disable Voice Mode". */ +const VOICE_ENABLED_SETTING = 'agents.voice.enabled'; + +/** + * "Select Microphone" entry shared by every dictation / Voice Mode mic button + * context menu. Opens the picker shared by both features. + */ +function createSelectMicrophoneAction(commandService: ICommandService): IAction { + return toAction({ + id: SELECT_MICROPHONE_COMMAND, + label: localize('mic.selectMicrophone', "Select Microphone"), + run: () => commandService.executeCommand(SELECT_MICROPHONE_COMMAND), + }); +} + +/** + * "Disable Dictation" entry. Cancels any active/preparing dictation first so + * disabling the setting doesn't leave the microphone capturing while the toolbar + * affordance disappears, then turns off the feature setting. + */ +function createDisableDictationAction(commandService: ICommandService, configurationService: IConfigurationService): IAction { + return toAction({ + id: 'chat.dictation.disable', + label: localize('dictation.disable', "Disable Dictation"), + run: async () => { + await commandService.executeCommand(CANCEL_DICTATION_COMMAND); + await configurationService.updateValue(DICTATION_ENABLED_SETTING, false); + }, + }); +} + +/** + * "Disable Voice Mode" entry. Tears down any active session first so disabling + * the setting doesn't leave the microphone capturing while the toolbar + * affordance disappears, then turns off the feature setting. + */ +function createDisableVoiceModeAction(commandService: ICommandService, configurationService: IConfigurationService): IAction { + return toAction({ + id: 'chat.voiceMode.disable', + label: localize('voiceMode.disable', "Disable Voice Mode"), + run: async () => { + await commandService.executeCommand(VOICE_DISCONNECT_COMMAND); + await configurationService.updateValue(VOICE_ENABLED_SETTING, false); + }, + }); +} + +/** + * Actions for the dictation mic button context menu: "Configure Keybinding" + * (always enabled so a removed binding can be restored), "Select Microphone" + * and "Disable Dictation". `keybindingCommandId` is the stable command the + * keybinding entry targets. + */ +export function getDictationContextMenuActions(commandService: ICommandService, configurationService: IConfigurationService, keybindingService: IKeybindingService, keybindingCommandId: string): IAction[] { + return [ + createConfigureKeybindingAction(commandService, keybindingService, keybindingCommandId), + createSelectMicrophoneAction(commandService), + createDisableDictationAction(commandService, configurationService), + ]; +} + +/** + * Actions for the Voice Mode mic button context menu, mirroring + * {@link getDictationContextMenuActions} but with "Disable Voice Mode". + */ +export function getVoiceModeContextMenuActions(commandService: ICommandService, configurationService: IConfigurationService, keybindingService: IKeybindingService, keybindingCommandId: string): IAction[] { + return [ + createConfigureKeybindingAction(commandService, keybindingService, keybindingCommandId), + createSelectMicrophoneAction(commandService), + createDisableVoiceModeAction(commandService, configurationService), + ]; +} + +/** + * Wire a right-click context menu onto a mic button. Stops the event before it + * reaches the toolbar's generic context-menu handler so the mic-specific menu is + * shown instead. Works for both `MenuEntryActionViewItem` containers and the + * Agents-window custom mic `
`s. + */ +export function addMicButtonContextMenuListener(container: HTMLElement, getActions: () => IAction[], contextMenuService: IContextMenuService): IDisposable { + return addDisposableListener(container, 'contextmenu', e => { + e.preventDefault(); + e.stopPropagation(); + const event = new StandardMouseEvent(getWindow(container), e); + contextMenuService.showContextMenu({ + getAnchor: () => event, + getActions, + }); + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceModeActionViewItem.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceModeActionViewItem.ts new file mode 100644 index 00000000000000..99ff7e47e898ca --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceModeActionViewItem.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IMenuEntryActionViewItemOptions, MenuEntryActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { addMicButtonContextMenuListener, getVoiceModeContextMenuActions } from '../speechToText/micButtonMenuActions.js'; + +/** + * Stable command the "Configure Keybinding" entry targets. Voice Mode swaps the + * rendered action between start and push-to-talk-stop while listening, but the + * keybinding lives on the start command, so target it in both states. + */ +const VOICE_START_COMMAND = 'agentsVoice.startVoiceInChat'; + +/** + * Action view item for the chat-input Voice Mode button. Behaves like the normal + * toolbar voice-mode toggle (click to start/stop) but adds a right-click context + * menu with voice-specific entries — "Configure Keybinding" (mirroring the + * standard toolbar affordance), "Select Microphone" and "Disable Voice Mode" — + * instead of the generic toolbar context menu. Mirrors {@link DictationActionViewItem}. + */ +export class VoiceModeActionViewItem extends MenuEntryActionViewItem { + + constructor( + action: MenuItemAction, + options: IMenuEntryActionViewItemOptions | undefined, + @ICommandService private readonly _commandService: ICommandService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IKeybindingService keybindingService: IKeybindingService, + @INotificationService notificationService: INotificationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IThemeService themeService: IThemeService, + @IContextMenuService contextMenuService: IContextMenuService, + @IAccessibilityService accessibilityService: IAccessibilityService, + ) { + super(action, options, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, accessibilityService); + } + + override render(container: HTMLElement): void { + super.render(container); + + this._register(addMicButtonContextMenuListener( + container, + () => getVoiceModeContextMenuActions(this._commandService, this._configurationService, this._keybindingService, VOICE_START_COMMAND), + this._contextMenuService, + )); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 35c5212f09cda5..cc60f8b411ecee 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -2528,9 +2528,9 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } private _isHandsFreeEnabled(): boolean { - // Default-off: hands-free auto-listen is opt-in, so only an explicit - // `true` enables it. An unresolved/undefined value resolves to the - // `handsFree` default (`false`) and stays disabled. + // Hands-free auto-listen is on by default; an unresolved/undefined value + // resolves to the `handsFree` default (`true`). Only an explicit `false` + // disables it. return this.configurationService.getValue('agents.voice.handsFree') === true; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index ea7e10eedf3f9e..d8d18522ee0f50 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -107,8 +107,10 @@ import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { ChatHistoryNavigator } from '../../../common/widget/chatWidgetHistoryService.js'; import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenDelegationPickerAction, OpenModelPickerAction, OpenModePickerAction, OpenPermissionPickerAction, OpenSessionTargetPickerAction, OpenWorkspacePickerAction } from '../../actions/chatExecuteActions.js'; -import { ChatSpeechToTextPreparingAction } from '../../actions/chatSpeechToTextActions.js'; +import { ChatSpeechToTextPreparingAction, ToggleChatSpeechToTextAction } from '../../actions/chatSpeechToTextActions.js'; +import { DictationActionViewItem } from '../../speechToText/dictationActionViewItem.js'; import { DictationDownloadActionViewItem } from '../../speechToText/dictationDownloadActionViewItem.js'; +import { VoiceModeActionViewItem } from '../../voiceClient/voiceModeActionViewItem.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; @@ -3224,6 +3226,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (action.id === ChatSpeechToTextPreparingAction.ID && action instanceof MenuItemAction) { return this.instantiationService.createInstance(DictationDownloadActionViewItem, action, options); } + if (action.id === ToggleChatSpeechToTextAction.ID && action instanceof MenuItemAction) { + return this.instantiationService.createInstance(DictationActionViewItem, action, options); + } + // Voice Mode mic button: add a right-click context menu (Select + // Microphone / Disable Voice Mode) mirroring dictation. While + // listening the toolbar swaps the start action for the + // push-to-talk stop action, so cover both so the menu stays put. + if ((action.id === 'agentsVoice.startVoiceInChat' || action.id === 'agentsVoice.pttStopInChat') && action instanceof MenuItemAction) { + return this.instantiationService.createInstance(VoiceModeActionViewItem, action, options); + } return undefined; }, })); diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 16f5e1c46d3c2b..f3a62e94509dcd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1186,6 +1186,9 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) { position: relative; border-radius: var(--vscode-cornerRadius-circle); + /* Separate the round primary send button from the preceding mic / voice-mode + toggle icons so it doesn't visually crowd them (see #326932). */ + margin-left: 4px; } .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:has(> .action-label.codicon-arrow-up) > .action-label.codicon-arrow-up { From a1c0f92c4a43ff1652be0aefdbde193c7180867b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:58:40 +0000 Subject: [PATCH 29/34] Add delete action to Migrate Prompts items (#325785) * Initial plan * feat: add delete action to prompt migration items Co-authored-by: aeschli <6461412+aeschli@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aeschli <6461412+aeschli@users.noreply.github.com> --- .../aiCustomizationManagementEditor.ts | 97 ++++++++++++------- .../media/aiCustomizationManagement.css | 24 +++++ 2 files changed, 86 insertions(+), 35 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 8315bc308435fb..bca350f393cd2b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -85,7 +85,7 @@ import { getSimpleEditorOptions } from '../../../codeEditor/browser/simpleEditor import { IWorkingCopyService } from '../../../../services/workingCopy/common/workingCopyService.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; +import { FileSystemProviderCapabilities, IFileService } from '../../../../../platform/files/common/files.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; @@ -1277,6 +1277,38 @@ export class AICustomizationManagementEditor extends EditorPane { return checkbox; }; + const renderItem = (container: HTMLElement, promptFile: IPromptPath): void => { + const row = DOM.append(container, $('div.ai-customization-list-item.prompt-migration-item')); + const checkbox = renderSelectionCheckbox(row, promptFile); + this.migrationPageDisposables.add(DOM.addDisposableListener(row, 'click', event => { + if (event.target instanceof Node && checkbox.domNode.contains(event.target)) { + return; + } + openPromptFileInEmbeddedEditor(promptFile); + })); + + const itemLeft = DOM.append(row, $('span.item-left')); + const itemText = DOM.append(itemLeft, $('span.item-text')); + const nameRow = DOM.append(itemText, $('span.item-name-row')); + const nameLabel = DOM.append(nameRow, $('span.item-name.prompt-migration-item-name')); + nameLabel.textContent = promptFile.name ?? basename(promptFile.uri); + + const pathLabel = DOM.append(itemText, $('span.item-description.is-filename.prompt-migration-item-path')); + pathLabel.textContent = this.labelService.getUriLabel(promptFile.uri, { relative: true }); + + const itemRight = DOM.append(row, $('span.item-right')); + const deleteButton = DOM.append(itemRight, $('button.icon-button', { + type: 'button', + 'aria-label': localize('deletePromptFile', "Delete {0}", promptFile.name ?? basename(promptFile.uri)), + })) as HTMLButtonElement; + deleteButton.classList.add(...ThemeIcon.asClassNameArray(Codicon.trash)); + this.migrationPageDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), deleteButton, localize('deletePromptFileTooltip', "Delete"))); + this.migrationPageDisposables.add(DOM.addDisposableListener(deleteButton, 'click', event => { + event.stopPropagation(); + void this.deletePromptFile(promptFile); + })); + }; + const renderGroup = (groupLabel: string, promptFiles: readonly IPromptPath[]): void => { if (promptFiles.length === 0) { return; @@ -1306,23 +1338,7 @@ export class AICustomizationManagementEditor extends EditorPane { count.textContent = String(promptFiles.length); for (const promptFile of promptFiles) { - const row = DOM.append(group, $('div.ai-customization-list-item.prompt-migration-item')); - const checkbox = renderSelectionCheckbox(row, promptFile); - this.migrationPageDisposables.add(DOM.addDisposableListener(row, 'click', event => { - if (event.target instanceof Node && checkbox.domNode.contains(event.target)) { - return; - } - openPromptFileInEmbeddedEditor(promptFile); - })); - - const itemLeft = DOM.append(row, $('span.item-left')); - const itemText = DOM.append(itemLeft, $('span.item-text')); - const nameRow = DOM.append(itemText, $('span.item-name-row')); - const nameLabel = DOM.append(nameRow, $('span.item-name.prompt-migration-item-name')); - nameLabel.textContent = promptFile.name ?? basename(promptFile.uri); - - const pathLabel = DOM.append(itemText, $('span.item-description.is-filename.prompt-migration-item-path')); - pathLabel.textContent = this.labelService.getUriLabel(promptFile.uri, { relative: true }); + renderItem(group, promptFile); } }; @@ -1330,23 +1346,7 @@ export class AICustomizationManagementEditor extends EditorPane { renderGroup(localize('promptMigrationUserGroup', "User"), userPromptFiles); for (const promptFile of filteredPromptFiles.filter(file => file.storage !== PromptsStorage.local && file.storage !== PromptsStorage.user)) { - const row = DOM.append(this.migrationListContainer, $('div.ai-customization-list-item.prompt-migration-item')); - const checkbox = renderSelectionCheckbox(row, promptFile); - this.migrationPageDisposables.add(DOM.addDisposableListener(row, 'click', event => { - if (event.target instanceof Node && checkbox.domNode.contains(event.target)) { - return; - } - openPromptFileInEmbeddedEditor(promptFile); - })); - - const itemLeft = DOM.append(row, $('span.item-left')); - const itemText = DOM.append(itemLeft, $('span.item-text')); - const nameRow = DOM.append(itemText, $('span.item-name-row')); - const nameLabel = DOM.append(nameRow, $('span.item-name.prompt-migration-item-name')); - nameLabel.textContent = promptFile.name ?? basename(promptFile.uri); - - const pathLabel = DOM.append(itemText, $('span.item-description.is-filename.prompt-migration-item-path')); - pathLabel.textContent = this.labelService.getUriLabel(promptFile.uri, { relative: true }); + renderItem(this.migrationListContainer, promptFile); } this.updatePromptMigrationActionState(); @@ -1407,6 +1407,33 @@ export class AICustomizationManagementEditor extends EditorPane { : localize('promptMigrationPageButton', "Migrate"); } + private async deletePromptFile(promptFile: IPromptPath): Promise { + const fileName = promptFile.name ?? basename(promptFile.uri); + const confirmation = await this.dialogService.confirm({ + message: localize('confirmDeletePromptFile', "Are you sure you want to delete '{0}'?", fileName), + detail: localize('confirmDeleteDetail', "This action cannot be undone."), + primaryButton: localize('delete', "Delete"), + type: 'warning', + }); + + if (!confirmation.confirmed) { + return; + } + + const useTrash = this.fileService.hasCapability(promptFile.uri, FileSystemProviderCapabilities.Trash); + await this.fileService.del(promptFile.uri, { useTrash }); + if (promptFile.storage === PromptsStorage.local) { + const projectRoot = this.workspaceService.getActiveProjectRoot(); + if (projectRoot) { + await this.workspaceService.deleteFiles(projectRoot, [promptFile.uri]); + } + } + + // Remove the deleted file from the list and re-render immediately + const updatedFiles = this.promptFilesToMigrate.filter(f => !isEqual(f.uri, promptFile.uri)); + this.setPromptFilesToMigrate(updatedFiles); + } + private isPromptMigrationEnabled(): boolean { return this.configurationService.getValue(ChatConfiguration.ChatCustomizationsPromptMigrationEnabled) === true; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 143667159b7be3..f1220b6b9b1a69 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -922,6 +922,30 @@ gap: 8px; } +.ai-customization-management-editor .prompt-migration-item .icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: var(--vscode-cornerRadius-small); + background: transparent; + color: var(--vscode-icon-foreground); + cursor: pointer; + font-size: 16px; +} + +.ai-customization-management-editor .prompt-migration-item .icon-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.ai-customization-management-editor .prompt-migration-item .icon-button:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + .ai-customization-management-editor .prompt-migration-empty { margin: 0; padding: 8px 12px; From 43e452321c40f7df357a0a50ada335f050cc0197 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 22 Jul 2026 12:11:40 -0700 Subject: [PATCH 30/34] Hide chat tab strip unless multiple tabs, move Chats action to meta row (#327022) In the Agents window session editor, only show the chat tab strip when the session has more than one chat actually visible as a tab. A single visible tab (including a lone real chat with unopened subagents) now always hides the strip. Also move the Chats (Conversations) menu out of the tab-strip/header split and always render it in the session header meta row, at the end of the pills, once the session has more than one committed chat or the active chat has subagents. The now-dead chat-tab-bar action menu, its menu id, and CSS are removed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 2 +- src/vs/sessions/SESSIONS.md | 2 +- src/vs/sessions/browser/menus.ts | 1 - .../browser/parts/chatCompositeBar.ts | 22 ++-------- .../browser/parts/media/chatCompositeBar.css | 31 -------------- src/vs/sessions/common/contextkeys.ts | 2 +- .../browser/sessionsChatAccessibilityHelp.ts | 4 +- .../sessions/browser/sessionsActions.ts | 40 ++++++------------- .../sessions/browser/visibleSessions.ts | 28 +++---------- .../test/browser/visibleSessions.test.ts | 23 +++++++---- 10 files changed, 42 insertions(+), 113 deletions(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 8d73a14e1f1a80..a05d52eb919cb2 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -141,7 +141,7 @@ The Sessions Part (`SessionsPart` in [browser/parts/sessionsPart.ts](src/vs/sess A `SessionView` ([browser/parts/sessionView.ts](src/vs/sessions/browser/parts/sessionView.ts)) is a single leaf in the Sessions Part's internal grid. It hosts: - A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (the contributed workspace folder / changes / pull request buttons), and the session toolbars (Run, Open in VS Code, New Chat). The status icon ([browser/sessionStatusIcon.ts](src/vs/sessions/browser/sessionStatusIcon.ts)) shows the live spinner/status glyph for in-progress / needs-input / error states; in terminal/default states the title shows the read/unread **dot indicator** (filled link-colored dot when unread, small muted dot when read) — neither the session type icon nor the PR icon is shown in the title, since the pull request is surfaced in the meta row instead. (The status icon's `completedStateIcon` argument is generic: the header passes nothing so it falls back to the dot indicator, while the sessions list still passes the PR icon.) The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; by default each contributed action renders as a consistent compact secondary `Button` with an inline `icon title` label via `SessionHeaderMetaActionViewItem` ([browser/parts/sessionHeaderMetaActionViewItem.ts](src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts)) unless it registers its own action view item (spacing between the pills comes from the meta row's `gap`, no separator dot). The files view contributes the workspace folder pill (order -10, so it leads the row, gated by the per-view `SessionHasWorkspaceContext` key which `SessionView` sets when the session has a workspace label, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the workspace icon — cloud / folder / worktree per workspace kind — plus the workspace label, and a hover showing the working-directory path and git branch, registered from `contrib/files/browser/workspaceFolderActions.ts`) that, when activated, opens the Files view. The changes view contributes the diff stats as a clickable menu item (order 0, gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's **Branch Changes** changeset, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the diff-multiple icon, a `{n} files` label, and the live `+insertions -deletions` counts, registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The pill always reflects the **Branch Changes** changeset (the branch-vs-base diff) — located in `IActiveSession.changesets` by the shared `BRANCH_CHANGES_CHANGESET_ID` (`services/sessions/common/session.ts`), falling back to `IActiveSession.changes` when absent — so it is independent of whichever changeset the Changes view currently has selected. The GitHub contribution similarly contributes a pull request button (order 1, so it follows the changes button) showing the PR icon + `#` (gated by the per-view `SessionHasPullRequestContext` key, which `SessionView` sets from the session's GitHub info, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the live `#` as its label, registered from `contrib/github/browser/pullRequestActions.ts`) that, when activated, opens the pull request on GitHub; its leading icon reads `gitHubInfo.pullRequest.icon` and renders its themed color (set as an inline `color` with `!important` priority) so the glyph reflects the live PR state; its hover is owned by the GitHub contribution and shows the repository link/date, PR title, up to three lines of description, and target/source branch pills. Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. -- A **chat composite bar** below the header ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) — the chat tab strip. Visibility tracks the number of **chats** in the session (**including in-composer draft chats**, and **counting closed chats**): it is shown as soon as the session has more than one chat and stays shown when chats are **closed** back down to a single open chat, hiding only when there is exactly one chat overall whose title matches the session title. It is **also** shown for a single remaining chat whose **title diverged** from the session title (so that chat's title stays visible somewhere). This rule is a single shared observable `IActiveSession.shouldShowChatTabs` ([services/sessions/browser/visibleSessions.ts](src/vs/sessions/services/sessions/browser/visibleSessions.ts)), read by both the composite bar and the `SessionShouldShowChatTabsContext` context key. The strip's own trailing **New Chat** action follows this visibility. The header's **New Chat** action is shown while the tab strip is hidden (a single chat with no diverged title); once the strip is shown the strip's trailing **New Chat** action offers it instead. The **New Chat** and **Conversations** controls are therefore split across the header and the tab strip on the same `SessionShouldShowChatTabsContext` boundary: the **Conversations** menu appears once the session has more than one **committed (non-draft)** chat — in the session header while the tab strip is hidden, and in the **chat tab bar action menu** at the end of the tab strip (`Menus.SessionChatTabBar`, rendered by the chat composite bar) once the strip is shown. While the tab strip is shown the chat tabs are keyboard-navigable from the active session: `Ctrl/Cmd+Shift+]` / `Ctrl/Cmd+Shift+[` go to the next / previous chat (wrapping), `Ctrl/Cmd+W` closes the active chat tab (deleting an in-composer draft, hiding a committed chat) instead of the session — the same command (`sessions.chatCompositeBar.closeChat`) is contributed to the per-tab `Menus.SessionChatTab`, which the chat tab strip renders as each non-main tab's close button (forwarding the tab's chat as the action argument), and `Ctrl+Tab` / `Ctrl+Shift+Tab` open a **chat switcher** — a no-input, editor-switcher (MRU) quick pick over the session's **open** chats (skipping in-composer drafts), each shown with a chat icon (hold the modifier, press `Tab` to cycle, release to select), winning over the session-history secondary on that chord while the session has multiple open chats and falling back to session navigation otherwise (and to the editor's own `Ctrl+Tab` switcher while a quick pick is already open, since the open chords are gated on `inQuickOpen` negated); the **Go to Chat in Session** palette command (`sessions.showChatsPicker`, `Ctrl/Cmd+Shift+O`, gated on more than one committed chat) opens a **searchable** variant that additionally lists **Closed** chats in a separate group (selecting one reopens it) — these commands (`sessions.chatCompositeBar.navigateNextChat` / `navigatePreviousChat` / `closeChat` and `sessions.showChatsPicker` in `contrib/sessions/browser/sessionsActions.ts`) outrank the session-level navigation/close chords via a higher keybinding weight. Chat-to-chat navigation (next/previous chat and the `Ctrl+Tab` switcher) is gated on `SessionHasMultipleOpenChatsContext` (more than one **open** tab) — distinct from the broader `SessionShouldShowChatTabsContext` that drives strip visibility — so it stays a no-op when only a single open chat remains (e.g. a diverged-title single chat, or one open + one closed chat); `closeChat` is gated on `SessionActiveChatIsClosableContext`, and the searchable palette command on `SessionHasMultipleCommittedChatsContext`. +- A **chat composite bar** below the header ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) — the chat tab strip. Visibility tracks the number of **visible tabs** (`IActiveSession.visibleChatTabs`): it is shown only when the session has **more than one chat actually showing as a tab**, and always hidden when there is just one visible tab — even if other chats are **closed**, the single chat's **title diverged** from the session title, or the session has unopened subagents. This rule is a single shared observable `IActiveSession.shouldShowChatTabs` ([services/sessions/browser/visibleSessions.ts](src/vs/sessions/services/sessions/browser/visibleSessions.ts)), read by both the composite bar and the `SessionShouldShowChatTabsContext` context key. The strip's own trailing **New Chat** action follows this visibility. The header's **New Chat** action is shown while the tab strip is hidden (a single visible tab); once the strip is shown the strip's trailing **New Chat** action offers it instead. The **Chats** (Conversations) menu is always rendered in the session header **meta row**, at the end of the pills (`Menus.SessionHeaderMeta`, order 100), independent of the tab strip's visibility — it appears once the session has more than one **committed (non-draft)** chat, or when the active chat has subagents. It renders as the meta toolbar's default submenu **icon** (the comment-discussion glyph), and clicking it opens the submenu as a dropdown. While the tab strip is shown the chat tabs are keyboard-navigable from the active session: `Ctrl/Cmd+Shift+]` / `Ctrl/Cmd+Shift+[` go to the next / previous chat (wrapping), `Ctrl/Cmd+W` closes the active chat tab (deleting an in-composer draft, hiding a committed chat) instead of the session — the same command (`sessions.chatCompositeBar.closeChat`) is contributed to the per-tab `Menus.SessionChatTab`, which the chat tab strip renders as each non-main tab's close button (forwarding the tab's chat as the action argument), and `Ctrl+Tab` / `Ctrl+Shift+Tab` open a **chat switcher** — a no-input, editor-switcher (MRU) quick pick over the session's **open** chats (skipping in-composer drafts), each shown with a chat icon (hold the modifier, press `Tab` to cycle, release to select), winning over the session-history secondary on that chord while the session has multiple open chats and falling back to session navigation otherwise (and to the editor's own `Ctrl+Tab` switcher while a quick pick is already open, since the open chords are gated on `inQuickOpen` negated); the **Go to Chat in Session** palette command (`sessions.showChatsPicker`, `Ctrl/Cmd+Shift+O`, gated on more than one committed chat) opens a **searchable** variant that additionally lists **Closed** chats in a separate group (selecting one reopens it) — these commands (`sessions.chatCompositeBar.navigateNextChat` / `navigatePreviousChat` / `closeChat` and `sessions.showChatsPicker` in `contrib/sessions/browser/sessionsActions.ts`) outrank the session-level navigation/close chords via a higher keybinding weight. Chat-to-chat navigation (next/previous chat and the `Ctrl+Tab` switcher) is gated on `SessionHasMultipleOpenChatsContext` (more than one **open** tab) — distinct from the broader `SessionShouldShowChatTabsContext` that drives strip visibility — so it stays a no-op when only a single open chat remains (e.g. one open + one closed chat); `closeChat` is gated on `SessionActiveChatIsClosableContext`, and the searchable palette command on `SessionHasMultipleCommittedChatsContext`. - A **chat view** below the bars, swapped in/out based on session state. - A floating toolbar overlay ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts), `SessionViewFloatingToolbar`) shown for not-yet-created sessions in place of the header. diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index a6d635e2763074..b339c5518fe44e 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -165,7 +165,7 @@ Subagent chats **persist** in the session catalog after the subagent completes ( **Restoring subagent chats.** Subagent chats are in-memory only; on restart the agent host restores them as separate sessions but no longer re-adds them to the parent catalog. `AgentService._registerRestoredSubagent` mirrors the live `_handleSubagentStarted` flow on restore — it re-adds the subagent to the parent session's catalog (same `ahp-chat://subagent/...` chat URI, `origin: Tool`, `interactivity: ReadOnly`, restored turns) so it reappears as a read-only tab. -**Subagents in the Conversations menu.** Subagents spawned by the **currently-active** chat are shown as a separate group (`2_subagents`) at the bottom of the **Conversations** submenu, below the session's regular chats (`1_chats`); a separator divides the two groups. Per-chat association uses `IChatOrigin.parentChat` — the sessions-layer origin carries the spawning chat's resource (mapped from the protocol `ChatOrigin.chat` by the agent host provider's `_resolveParentChatResource`) — so the group changes as the active chat changes. Selecting a subagent entry toggles its read-only tab open/closed like any other chat entry. The entries are populated per session by `SessionConversationsMenuContribution` (only when the active chat has subagents). The chat tab strip is shown as soon as the session has any subagent (`IActiveSession.shouldShowChatTabs`), so the Conversations menu surfaces in the tab bar; `SessionActiveChatHasSubagentsContext` also keeps the menu available even when the parent is the only committed chat. +**Subagents in the Chats menu.** Subagents spawned by the **currently-active** chat are shown as a separate group (`2_subagents`) at the bottom of the **Chats** (Conversations) submenu, below the session's regular chats (`1_chats`); a separator divides the two groups. Per-chat association uses `IChatOrigin.parentChat` — the sessions-layer origin carries the spawning chat's resource (mapped from the protocol `ChatOrigin.chat` by the agent host provider's `_resolveParentChatResource`) — so the group changes as the active chat changes. Selecting a subagent entry toggles its read-only tab open/closed like any other chat entry. The entries are populated per session by `SessionConversationsMenuContribution` (only when the active chat has subagents). Subagents on their own do **not** show the chat tab strip: `IActiveSession.shouldShowChatTabs` is shown only when there is more than one visible tab (e.g. a subagent explicitly opened as a tab alongside the main chat) — a subagent that has not been opened as a tab is ignored. The **Chats** menu is always surfaced in the **session header meta row** (at the end of the pills), independent of the strip's visibility, kept available by `SessionActiveChatHasSubagentsContext` even when the parent is the only committed chat. **Background activities above the chat input.** `SessionChatInputToolbar` combines live integrated browsers and running subagents into one background-activities pill. Browsers come from `IBrowserViewWorkbenchService.getKnownBrowserViews()` and belong to the viewed chat when their `IBrowserViewOwner.sessionId` matches that chat or one of its direct tool-origin subagents; subagents come from the owning session's in-progress tool-origin chats whose `origin.parentChat` is the viewed chat. A single activity shows its kind icon and label (browser page title, falling back to "Browser"; subagent title truncated after 30 characters with `...`). Multiple activities of one kind show **N Active Browsers/Subagents**; mixed kinds show **N Background Activities** with the session-in-progress icon. Any multi-item pill opens `IActionWidgetService` with categorized **Browsers** and **Subagents** sections (browser section first), where every selectable row has its kind icon and label. Opening a browser activity prefers a contextual browser page already **Sharing with Agent** for the same destination (exact URL first, then the browser tools' same-host rule), so the user sees the page the agent is driving; when no shared match exists, it opens the activity's normal browser input. The boolean `chat.turnStatusPills` setting gates the entire status-pills surface; for compatibility, any `true` member in the former per-pill object form enables the whole surface. When enabled, completed-turn pills replace the older checkpoint file-changes summary. `ChatView` mounts the toolbar in `ChatInputPart.persistentContentContainerElement`, which remains in layout when `ChatWidget.setReadOnly(true)` hides the rest of the composer, so these pills also remain available on read-only chats. diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 445fa701700ce2..896dc61248ca9b 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -40,7 +40,6 @@ export const Menus = { SessionBarToolbar: new MenuId('SessionsSessionBarToolbar'), SessionConversations: new MenuId('SessionsSessionConversations'), SessionChatTab: new MenuId('SessionsSessionChatTab'), - SessionChatTabBar: new MenuId('SessionsSessionChatTabBar'), SessionsEditorHeaderPrimary: new MenuId('SessionsEditorHeaderPrimary'), SessionsEditorHeaderSecondary: new MenuId('SessionsEditorHeaderSecondary'), SessionsEditorTitle: new MenuId('SessionsEditorTitle'), diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index 3d92f21b97c481..6c7938fc3be2b6 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -64,7 +64,6 @@ export class ChatCompositeBar extends Disposable { private _session: IActiveSession | undefined; private readonly _newChatAction: Action; private readonly _newChatContainer: HTMLElement; - private readonly _actionMenuToolbar: MenuWorkbenchToolBar; private readonly _onDidChangeVisibility = this._register(new Emitter()); readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; @@ -115,22 +114,9 @@ export class ChatCompositeBar extends Disposable { })); this._tabsRow.appendChild(this._tabsScrollbar.getDomNode()); - // Chat tab bar action menu (e.g. the Conversations dropdown) grouped with - // the New Chat button at the end of the strip; items are contributed into - // Menus.SessionChatTabBar. - const actionMenuContainer = $('.chat-composite-bar-action-menu'); - this._tabsRow.appendChild(actionMenuContainer); - this._actionMenuToolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, actionMenuContainer, Menus.SessionChatTabBar, { - hiddenItemStrategy: HiddenItemStrategy.Ignore, - menuOptions: { shouldForwardArgs: true }, - highlightToggledItems: true, - toolbarOptions: { primaryGroup: () => true, useSeparatorsInPrimaryActions: true }, - })); - - // "New Chat" button pinned at the end of the tab strip, next to the - // Conversations menu. Starting a new chat is offered here while the tabs - // are shown; when the session has a single chat the session header toolbar - // offers it instead. + // "New Chat" button pinned at the end of the tab strip. Starting a new chat + // is offered here while the tabs are shown; when the session has a single + // chat the session header toolbar offers it instead. const newChatAction = this._newChatAction = this._register(new Action( 'chatCompositeBar.addChat', localize('chatCompositeBar.addChat', "New Chat"), @@ -189,8 +175,6 @@ export class ChatCompositeBar extends Disposable { } this._session = session; - this._actionMenuToolbar.context = session; - const store = new DisposableStore(); this._sessionDisposables.value = store; diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 49a13d4ae84aa5..f855414486fcb5 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -298,37 +298,6 @@ outline-offset: -1px; } -/* Chat tab bar action menu (e.g. the Conversations dropdown) grouped with the - New Chat button right after the tab strip. */ -.chat-composite-bar-tabs-row > .chat-composite-bar-action-menu { - flex-shrink: 0; - display: flex; - align-items: center; - margin-left: 4px; -} - -.chat-composite-bar-action-menu .action-item .action-label { - box-sizing: border-box; - width: 26px; - height: 26px; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--vscode-cornerRadius-small); - color: var(--chat-tab-inactive-foreground, currentColor); -} - -.chat-composite-bar-action-menu .action-item .action-label:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--chat-tab-active-foreground); -} - -.chat-composite-bar-action-menu .action-item .action-label:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - .chat-composite-bar-toolbar { display: flex; align-items: center; diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index e2592e7307b7f6..02a5e5878f4348 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -30,7 +30,7 @@ export const SessionSupportsMultipleChatsContext = new RawContextKey('s export const SessionSupportsForkContext = new RawContextKey('sessionSupportsFork', false, localize('sessionSupportsFork', "Whether the session view's session supports forking a chat from a turn into a new peer chat")); export const SessionHasMultipleCommittedChatsContext = new RawContextKey('sessionHasMultipleCommittedChats', false, localize('sessionHasMultipleCommittedChats', "Whether the session view's session has more than one committed (non-draft) chat, which drives the Conversations menu visibility")); export const SessionActiveChatHasSubagentsContext = new RawContextKey('sessionActiveChatHasSubagents', false, localize('sessionActiveChatHasSubagents', "Whether the session view's currently-active chat has spawned subagent (tool-origin) chats, which are listed as a separate group in the Conversations menu")); -export const SessionShouldShowChatTabsContext = new RawContextKey('sessionShouldShowChatTabs', false, localize('sessionShouldShowChatTabs', "Whether the session view's chat tab strip is shown, i.e. the session has more than one chat (counting closed chats) or its single remaining chat's title diverged from the session title. Used to hide the header New Chat button, which the tab strip then offers instead")); +export const SessionShouldShowChatTabsContext = new RawContextKey('sessionShouldShowChatTabs', false, localize('sessionShouldShowChatTabs', "Whether the session view's chat tab strip is shown, i.e. the session has more than one chat actually showing as a tab. A single visible tab always hides the strip. Used to hide the header New Chat button, which the tab strip then offers instead")); export const SessionHasMultipleOpenChatsContext = new RawContextKey('sessionHasMultipleOpenChats', false, localize('sessionHasMultipleOpenChats', "Whether the session view's session has more than one open chat (the tabs shown in the strip, including in-composer drafts). Used to scope chat-to-chat navigation (next/previous chat, the Ctrl+Tab chat switcher)")); export const SessionActiveChatIsClosableContext = new RawContextKey('sessionActiveChatIsClosable', false, localize('sessionActiveChatIsClosable', "Whether the session's active chat can be closed (hidden) from the tab strip, i.e. it is not the main chat. Includes read-only subagent chats. Used to scope the close-chat keybinding so it closes the tab instead of the session")); export const SessionActiveChatIsDeletableContext = new RawContextKey('sessionActiveChatIsDeletable', false, localize('sessionActiveChatIsDeletable', "Whether the session's active chat can be permanently deleted from the tab strip, i.e. it is a real, user-created non-main chat (not the main chat and not a tool-spawned subagent chat, which are transient children). Used to scope the delete-chat keybinding")); diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 40d1c53d826398..bf1d85b19f02b3 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -36,8 +36,8 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.micContextMenu', "To choose a microphone or turn off dictation or Voice Mode, focus the microphone button in the input toolbar and open its context menu (for example Shift+F10).")); content.push(localize('sessionsChat.contextReferences', "Type # in the chat input to attach context. Use #file to reference a file or folder, or #session to reference another agent session. Referencing a session together with the /troubleshoot command analyzes that session's logs instead of the current one. Accept a suggestion with Tab or Enter; the reference appears as a pill above the input that you can remove.")); content.push(localize('sessionsChat.backgroundActivities', "Press Shift+Tab from the chat input to reach status pills above it, then press Enter or Space to activate a pill. A pill with multiple background activities opens a picker; use the up and down arrows to navigate, Enter to open an activity, and Escape to dismiss the picker and return focus to the pill.")); - content.push(localize('sessionsChat.conversations', "When a session supports multiple chats, a New Chat button is always shown: as a labeled button in the session header while the session has a single open chat, and as a compact button at the end of the chat tab strip once the session has more than one open chat. Activate it to start a new chat. Once the session has more than one committed chat, a Conversations menu is also shown: in the session header while the tab strip is hidden, and at the end of the chat tab strip once it is shown. Open it to reopen a closed chat: each chat is listed with a checkbox, where checked chats are shown as tabs and unchecked chats are closed (hidden).")); - content.push(localize('sessionsChat.closeChat', "Activate a chat tab's close button to close (hide) that chat from the tab strip without deleting it; reopen it later from the Conversations menu. The session's main chat cannot be closed.")); + content.push(localize('sessionsChat.conversations', "When a session supports multiple chats, a New Chat button is always shown: as a labeled button in the session header while the session has a single visible chat tab, and as a compact button at the end of the chat tab strip once the session has more than one visible chat tab. Activate it to start a new chat. A Chats menu is also shown in the session header meta row, at the end of the pills, once the session has more than one committed chat or the active chat has subagents. Open it to reopen a closed chat or open a subagent: each chat is listed with a checkbox, where checked chats are shown as tabs and unchecked chats are closed (hidden).")); + content.push(localize('sessionsChat.closeChat', "Activate a chat tab's close button to close (hide) that chat from the tab strip without deleting it; reopen it later from the Chats menu. The session's main chat cannot be closed.")); content.push(localize('sessionsChat.deleteChat', "To permanently delete a chat, open the chat tab's context menu and choose Delete Chat. This is destructive and cannot be undone.")); content.push(localize('sessionsChat.navigatePreviousSession', "Navigate to the previous session in the list{0}.", '')); content.push(localize('sessionsChat.navigateNextSession', "Navigate to the next session in the list{0}.", '')); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index 76f0236cf85580..f63e6ed9bf4982 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -1183,39 +1183,25 @@ export class SessionNewChatActionViewItemContribution extends Disposable impleme } } -// The "Conversations" toolbar entry is a submenu (rendered as a dropdown): it -// lists every chat in the session with a checkbox. Checked chats are shown as -// tabs; unchecked chats are closed (hidden from the tab strip). Toggling an entry -// closes or reopens the corresponding chat. The main chat is always shown and -// cannot be closed, so its entry is checked and disabled. +// The "Chats" toolbar entry is a submenu: it lists every chat in the session +// with a checkbox. Checked chats are shown as tabs; unchecked chats are closed +// (hidden from the tab strip). Toggling an entry closes or reopens the +// corresponding chat. The main chat is always shown and cannot be closed, so its +// entry is checked and disabled. // -// It surfaces in one of two places depending on whether the chat tab strip is -// shown: when the strip is hidden it lives in the session header toolbar; once the -// session has more than one open chat (the tab strip is shown) it moves to the -// chat tab bar action menu at the end of the strip instead (see -// Menus.SessionChatTabBar below). It also surfaces when the active chat has -// subagents (a separate group at the bottom lists them), even if that is the only +// It is always rendered in the session header meta row, after the pills +// (workspace folder / changes / pull request) as the meta toolbar's default +// submenu icon, independent of whether the chat tab strip is shown. It surfaces +// once the session has more than one committed chat, or when the active chat has +// subagents (a separate group at the bottom lists them) even if that is the only // committed chat. -MenuRegistry.appendMenuItem(Menus.SessionBarToolbar, { +MenuRegistry.appendMenuItem(Menus.SessionHeaderMeta, { submenu: Menus.SessionConversations, title: localize2('chatCompositeBar.conversations', "Chats"), icon: Codicon.commentDiscussion, group: 'navigation', - order: 10, - when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), ContextKeyExpr.or(SessionHasMultipleCommittedChatsContext, SessionActiveChatHasSubagentsContext), SessionShouldShowChatTabsContext.negate()), -}); - -// Mirror of the header Conversations submenu, rendered at the end of the chat tab -// bar action menu while the tab strip is shown (more than one open chat). The two -// `when` clauses are mutually exclusive on SessionShouldShowChatTabsContext so -// the Conversations menu only ever appears in one place at a time. -MenuRegistry.appendMenuItem(Menus.SessionChatTabBar, { - submenu: Menus.SessionConversations, - title: localize2('chatCompositeBar.conversations', "Chats"), - icon: Codicon.commentDiscussion, - group: 'navigation', - order: 10, - when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), ContextKeyExpr.or(SessionHasMultipleCommittedChatsContext, SessionActiveChatHasSubagentsContext), SessionShouldShowChatTabsContext), + order: 100, + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionIsArchivedContext.negate(), ContextKeyExpr.or(ContextKeyExpr.and(SessionSupportsMultipleChatsContext, SessionHasMultipleCommittedChatsContext), SessionActiveChatHasSubagentsContext)), }); /** diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index c8f9f4488134c6..fb7e6ab33989ee 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -116,30 +116,12 @@ export class VisibleSession extends Disposable implements IActiveSession { c.origin?.kind !== ChatOriginKind.Tool || shownSubagents.has(c.resource.toString())); }); - // Shown for more than one real (non-tool) chat — counting closed ones — - // or a single chat whose title diverged from the session title. An opened - // subagent tab also warrants showing the strip, so any time there is more - // than one visible tab the strip is shown. The strip is also shown as soon - // as the session has any subagent (tool-origin) chat, so the Conversations - // menu (which lists subagents) surfaces in the tab bar. + // Shown only when there is more than one chat actually showing as a tab. + // A single visible tab (even if other chats are closed, or its title + // diverged from the session title, or subagents exist) always hides the + // strip; the Conversations menu surfaces in the session header instead. this.shouldShowChatTabs = derived(this, reader => { - const chats = this._session.chats.read(reader); - if (chats.some(c => c.origin?.kind === ChatOriginKind.Tool)) { - return true; - } - const tabChats = chats.filter(c => - c.origin?.kind !== ChatOriginKind.Tool && - c.interactivity.read(reader) !== ChatInteractivity.Hidden); - if (tabChats.length > 1) { - return true; - } - if (this.visibleChatTabs.read(reader).length > 1) { - return true; - } - if (tabChats.length === 1) { - return tabChats[0].title.read(reader) !== this._session.title.read(reader); - } - return false; + return this.visibleChatTabs.read(reader).length > 1; }); } diff --git a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts index 1d32f48e2156a1..682de76ab71719 100644 --- a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts @@ -1194,9 +1194,9 @@ suite('VisibleSession - shouldShowChatTabs', () => { assert.strictEqual(visible.shouldShowChatTabs.get(), false); }); - test('shown for a single chat whose title diverged from the session title', () => { + test('hidden for a single chat even when its title diverged from the session title', () => { const visible = createSession('Session Title', [makeChat('main', 'Chat Title')]); - assert.strictEqual(visible.shouldShowChatTabs.get(), true); + assert.strictEqual(visible.shouldShowChatTabs.get(), false); }); test('shown for more than one chat even if a chat title matches the session title', () => { @@ -1204,13 +1204,22 @@ suite('VisibleSession - shouldShowChatTabs', () => { assert.strictEqual(visible.shouldShowChatTabs.get(), true); }); - test('shown for a single non-tool chat matching the session title when it has a subagent', () => { + test('hidden for a single non-tool chat matching the session title even when it has a subagent', () => { const visible = createSession('Title', [ makeChat('main', 'Title'), makeChat('tool', 'tool', ChatOriginKind.Tool), ]); - // The strip is shown as soon as the session has any subagent, so the - // Conversations menu (which lists subagents) surfaces in the tab bar. + // Subagents on their own do not show the strip; the Conversations menu + // (which lists subagents) surfaces in the session header instead. + assert.strictEqual(visible.shouldShowChatTabs.get(), false); + }); + + test('shown once a subagent tab is explicitly opened (multiple visible tabs)', () => { + const chats = [makeChat('main', 'Title'), makeChat('tool', 'tool', ChatOriginKind.Tool)]; + const visible = createSession('Title', chats); + visible.openChat(chats[1]); + // Opening a subagent surfaces it as a second visible tab, so the strip is + // shown to display both tabs. assert.strictEqual(visible.shouldShowChatTabs.get(), true); }); @@ -1222,7 +1231,7 @@ suite('VisibleSession - shouldShowChatTabs', () => { assert.strictEqual(visible.shouldShowChatTabs.get(), false); }); - test('stays shown after a non-main chat is closed back down to a single open chat', () => { + test('hidden after a non-main chat is closed back down to a single visible tab', () => { const main = makeChat('main', 'Title'); const second = makeChat('second', 'second'); const visible = createSession('Title', [main, second]); @@ -1236,7 +1245,7 @@ suite('VisibleSession - shouldShowChatTabs', () => { shouldShowChatTabs: visible.shouldShowChatTabs.get(), visibleChatTabs: visible.visibleChatTabs.get().map(c => c.title.get()), }, { - shouldShowChatTabs: true, + shouldShowChatTabs: false, visibleChatTabs: ['Title'], }); }); From d8b343d0ad0b18a9eb1c6fe9d84f2fa22e77aeb5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:27:42 +0000 Subject: [PATCH 31/34] Include typed chat input in voice mode requests (#327015) * Initial plan * Include typed chat input text with voice mode request Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: meganrogge <29464607+meganrogge@users.noreply.github.com> --- .../contrib/chat/browser/newChatInput.ts | 4 +-- .../chat/browser/voiceBridge.contribution.ts | 4 ++- .../browser/voiceClient/voiceInputUtils.ts | 24 ++++++++++++++ .../widgetHosts/viewPane/chatViewPane.ts | 4 ++- .../voiceClient/voiceInputUtils.test.ts | 31 +++++++++++++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputUtils.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceInputUtils.test.ts diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index a5fe2893435a46..3206046f134c46 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -85,6 +85,7 @@ import { handleTerminalCommandPaste, isTerminalCommandInput } from '../../../../ import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { ChatSpeechToTextState, IChatSpeechToTextService } from '../../../../workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.js'; import { runDictationShortcut } from '../../../../workbench/contrib/chat/browser/actions/chatSpeechToTextActions.js'; +import { combineVoiceInput } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceInputUtils.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { DictationDownloadRing } from '../../../../workbench/contrib/chat/browser/speechToText/dictationDownloadRing.js'; import { IVoiceSessionController } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; @@ -1097,8 +1098,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation } const model = this._editor?.getModel(); if (model) { - const existing = model.getValue(); - const combined = existing && !/\s$/.test(existing) ? `${existing} ${text}` : `${existing}${text}`; + const combined = combineVoiceInput(model.getValue(), text); model.setValue(combined); this._send(); } diff --git a/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts b/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts index 8f036507209d12..ed8780f2bab329 100644 --- a/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/voiceBridge.contribution.ts @@ -12,6 +12,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IVoiceSessionController } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceSessionController.js'; +import { combineVoiceInput } from '../../../../workbench/contrib/chat/browser/voiceClient/voiceInputUtils.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { INewChatVoiceTargetService, NEW_CHAT_VOICE_SENTINEL } from './newChatVoice.js'; @@ -77,7 +78,8 @@ class SessionsVoiceBridgeContribution extends Disposable implements IWorkbenchCo // Let the user review edited input before submitting. widget.input.setValue(text, false); } else { - widget.acceptInput(text, { preserveFocus: true }); + // Preserve any text the user already typed in the input. + widget.acceptInput(combineVoiceInput(widget.getInput(), text), { preserveFocus: true }); } } })); diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputUtils.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputUtils.ts new file mode 100644 index 00000000000000..e2ac2098de5e9a --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputUtils.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Combine any text already typed into a chat input with a voice transcript so + * the typed text is not dropped when voice mode submits the request. + * + * The transcript is appended after the existing text, inserting a single space + * separator only when the existing text does not already end in whitespace. + * + * @param existing The text currently in the chat input editor. + * @param transcript The recognized voice transcript to submit. + */ +export function combineVoiceInput(existing: string, transcript: string): string { + if (!existing) { + return transcript; + } + if (!transcript) { + return existing; + } + return /\s$/.test(existing) ? `${existing}${transcript}` : `${existing} ${transcript}`; +} 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 9e05ab618a5e89..810b8893ead85c 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -76,6 +76,7 @@ import { IMicCaptureService } from '../../voiceClient/micCaptureService.js'; import { ITtsPlaybackService } from '../../voiceClient/ttsPlaybackService.js'; import { IVoiceSessionController } from '../../voiceClient/voiceSessionController.js'; import { computeVoiceGlowStyle, isGlowingVoiceState, readVoiceGlowIntensity } from '../../voiceClient/voiceGlow.js'; +import { combineVoiceInput } from '../../voiceClient/voiceInputUtils.js'; import { IAgentTitleBarStatusService } from '../../agentSessions/experiments/agentTitleBarStatusService.js'; import { IVoicePlaybackService } from '../../../common/voicePlaybackService.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; @@ -404,7 +405,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // editor so the user can review before submitting. widget.input.setValue(text, false); } else { - widget.acceptInput(text, { preserveFocus: true }); + // Preserve any text the user already typed in the input. + widget.acceptInput(combineVoiceInput(widget.getInput(), text), { preserveFocus: true }); } } })); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceInputUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceInputUtils.test.ts new file mode 100644 index 00000000000000..4bec99a1cb0a63 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceInputUtils.test.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { combineVoiceInput } from '../../../browser/voiceClient/voiceInputUtils.js'; + +suite('combineVoiceInput', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps typed input and appends the transcript', () => { + assert.deepStrictEqual( + [ + combineVoiceInput('', 'hello world'), + combineVoiceInput('please', 'run the tests'), + combineVoiceInput('please ', 'run the tests'), + combineVoiceInput('please\n', 'run the tests'), + combineVoiceInput('draft', ''), + ], + [ + 'hello world', + 'please run the tests', + 'please run the tests', + 'please\nrun the tests', + 'draft', + ] + ); + }); +}); From de4891b7a50fa03306f197c89d55d70b4dc1f811 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Jul 2026 16:28:14 -0400 Subject: [PATCH 32/34] Fix Voice Mode panel dictation misrouting to background chat editor (#327025) * Fix voice request misrouting to background chat editor When dictating with voice mode in the panel chat input, the request could be sent to a background chat editor that had been focused earlier instead of the panel. _chat.voice.acceptInput routed to IChatWidgetService.lastFocusedWidget, which is only updated on a chat widget's onDidFocus and is never cleared on blur (only on dispose). After a chat editor was opened/focused it stayed the lastFocusedWidget, and because dictating in the panel voice input does not DOM-focus its chat input, the request was dispatched to that stale editor even though _chat.voice.getCurrentSession reported the panel. Only trust lastFocusedWidget while it still holds input focus (an intentionally focused chat editor); otherwise fall back to the panel view pane's own widget, keeping the send target consistent with the reported current session. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e000e96c-2f7e-4721-9405-001e9e0ff1fd * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 810b8893ead85c..2aef0c0aaf6461 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -398,7 +398,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Voice command bridge — lets the VoiceSessionController reach into the chat widget this._voiceBarDisposables.add(CommandsRegistry.registerCommand('_chat.voice.acceptInput', (accessor, text: string) => { const chatWidgetService = accessor.get(IChatWidgetService); - const widget = chatWidgetService.lastFocusedWidget ?? this._widget; + // Ignore lastFocusedWidget when its input no longer has focus because blur does not clear it. + const focusedWidget = chatWidgetService.lastFocusedWidget; + const widget = focusedWidget?.hasInputFocus() ? focusedWidget : this._widget; if (text && widget?.viewModel) { if (widget.viewModel.editing) { // When editing an old message, populate the active input From 1aee297d4c11024d273dfbd91abb77797d9308cd Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 22 Jul 2026 13:55:23 -0700 Subject: [PATCH 33/34] sessions: open single-file diff directly from single-pane Changes view (#327030) * sessions: open single-file diff directly from single-pane Changes view Fixes #327012. SinglePaneChangesViewPane forced every file click in the Changes view to open the aggregate multi-file diff editor, ignoring the shared single/ multi-file decision logic and the Alt invert. Remove that override so single-pane falls through to the same logic as the standard layout: honor sessions.changes.openSingleFileDiff (default off, so default behavior is unchanged) with Alt inverting the choice, reusing the existing _openSingleFileDiffEditor()/_openMultiFileDiffEditor() paths. Also show the whole file by default when a single-file diff is opened from Changes, instead of folding unchanged regions - that diff is opened to review one specific file, so nothing should be hidden. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: address review feedback on single-file diff opening - Restore hideUnchangedRegions to the configured value once the pane's active editor switches away from the opened single-file diff. The diff editor control is reused across different inputs in the same pane, and AbstractTextEditor.updateEditorConfiguration only applies configuration deltas, so the earlier fix left folding disabled for whatever opened next in that pane. - Extend SinglePaneResponsiveSidebarStrategy's real-content check to also treat a DiffEditorInput whose original/modified side is a FileEditorInput as real content, so opening a single-file diff on a small window auto-hides the sessions list like a plain file open already does. - Update LAYOUT.md, which still documented the old always-multi-diff behavior this PR replaced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 2 +- .../contrib/changes/browser/changesView.ts | 50 ++++++++++++------- .../singlePaneResponsiveSidebarStrategy.ts | 27 +++++++--- .../desktopSessionLayoutController.test.ts | 17 +++++++ 4 files changed, 71 insertions(+), 25 deletions(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index a05d52eb919cb2..7d2b5fd32e5779 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -230,7 +230,7 @@ The entire third-pane redesign is gated behind the experimental setting `session - Side-pane sizes are **workbench-level, not per session**: the editor grid node width is owned by the workbench grid and persisted globally (`workbench.sessions.partSizes`), so switching between sessions keeps the same side-pane width the user last set — the layout controller does not track or restore a per-session width. The workbench persists the docked side-pane geometry across reloads via `_savePartSizes` on `onWillSaveState`, restored by `createDesktopGridDescriptor`. Because the docked detail (auxiliary bar) lives **inside** the editor grid node, the persisted editor value is the pure editor-content width: `_persistedEditorWidth` subtracts the docked detail width **only when the detail is visible**, mirroring the descriptor, which adds it back only when the detail is visible. Subtracting it unconditionally (the earlier bug) shrank an **Editor-only** session's side pane by the detail width on every reload, compounding toward zero. - `_dockedEditorSizeBeforeHide` is captured on hide **only for "Hide Editor"** (detail/auxiliary bar still visible, so the editor node stays visible at a real user-chosen width). When the **whole** side pane closes (auxiliary bar also hidden — e.g. **Toggle Side Panel** or the last-tab close, where `setEditorHidden(true)` runs with `partVisibility.auxiliaryBar === false`), the editor grid node collapses to `0px`, so capturing it would restore a bogus/cramped width; instead `_dockedEditorSizeBeforeHide` is cleared and the stale sidebar-collapse grow snapshots (`_editorSizeGrownForSidebarHide` / `_detailWidthGrownForSidebarHide`) are dropped, so reopening falls through to the 60%-of-window split. Because the split is computed from the full window width (not the remaining main area), reopening the side pane is a generous width rather than the cramped node a captured `0px` (or a stale pre-collapse snapshot) would restore. - The shared editor title's inline layout cluster orders the Hide Editor chevron before maximize/restore, followed by the detail-panel toggle. The detail-panel toggle is conditional (shown only when the active tab is Changes or Files, i.e. hidden for Browser/Search tabs, which have no detail). No chevron is shown while the editor is hidden; opening a file or diff from the detail panel reveals the editor again. If the detail-panel toggle hides the detail while editor content is hidden, it reveals the editor content instead of leaving the pane empty; **Toggle Side Panel** remains the separate action that can hide both. -- Changes opens as a **custom `SessionChangesEditor`** (the multi-diff editor; in single-pane its *Branch Changes* dropdown + diff-stats + primary actions render in the full-width header part above, so the editor itself is header-less and the diff fills the pane). Each file header shows the live `+insertions -deletions` counts from the selected changeset alongside the file label. Clicking a Branch Changes file always reveals that file in this multi-diff editor (ignoring `sessions.changes.openSingleFileDiff` and the Alt inversion used by the standard layout). The auxiliary bar's composite tab strip + title are hidden, and `SinglePaneDetailPanelStrategy` maps the active editor tab to the detail container (Changes → files + Checks, File → Explorer, Browser → hidden). Created sessions default to **editor-only** (the Changes/file editor visible, the detail closed); activating a Changes/file editor switches the detail container to match but does **not** force-reveal a hidden detail — except when the empty Files placeholder becomes active (which reveals the Files detail) or when the detail was transiently hidden by a Browser tab (switching back to File or Changes re-reveals it). +- Changes opens as a **custom `SessionChangesEditor`** (the multi-diff editor; in single-pane its *Branch Changes* dropdown + diff-stats + primary actions render in the full-width header part above, so the editor itself is header-less and the diff fills the pane). Each file header shows the live `+insertions -deletions` counts from the selected changeset alongside the file label. Clicking a Branch Changes file honors the same `sessions.changes.openSingleFileDiff` setting and Alt inversion as the standard layout, opening either a docked single-file diff or revealing the file in this multi-diff editor. The auxiliary bar's composite tab strip + title are hidden, and `SinglePaneDetailPanelStrategy` maps the active editor tab to the detail container (Changes → files + Checks, File → Explorer, Browser → hidden). Created sessions default to **editor-only** (the Changes/file editor visible, the detail closed); activating a Changes/file editor switches the detail container to match but does **not** force-reveal a hidden detail — except when the empty Files placeholder becomes active (which reveals the Files detail) or when the detail was transiently hidden by a Browser tab (switching back to File or Changes re-reveals it). - Closing the last editor tab hides both the editor content and the docked detail panel, leaving the Agents window chat-only. Opening any tab reveals the editor part again, and `DetailPanelController` restores the matching detail content for File/Changes tabs. - **Editor-area tab collapse:** when the editor area is hidden (detail-only), the single-pane controller closes **every non-docked** editor tab (anything not `instanceof DockedEditorInput`) so only the docked Changes and Files tabs remain, capturing each closable one's untyped input **and tab index** (`editor.toUntyped()`); when the editor area is shown again the captured ones are reopened **at their original positions** (`SinglePaneEditorAreaCollapseStrategy._collapseNonManagedTabs` / `_restoreCollapsedTabs`, registered by `SinglePaneLayoutController._registerAuxiliaryControllers`). It is serialized on the shared docked-tab `Sequencer`, skipped during a layout-driven restore (`ISinglePaneLayoutContext.isRestoringSessionLayout`), and the capture is dropped on a session change. Non-restorable tabs (e.g. an **untitled Search editor**, whose `toUntyped()` returns `undefined`) are still closed but not restored; dirty editors are closed too (the workbench save/confirm flow applies), so they don't linger in a "closed" editor area. - While a new (uncreated) workspace session view is active, the editor content is kept hidden **continuously** so the Files detail panel and editor tab bar remain visible without showing editor content by default. The rule is **level-triggered** on the active editor + editor-part visibility (in `singlePaneLayoutController.ts`): it hides the editor whenever the active editor is **not real content** (a `FileEditorInput` for a real file or a `BrowserEditorInput`), treating the managed empty landing tab (`EmptyFileEditorInput`) and "no active editor" as not real content. Because it re-reads visibility + active editor, any spurious reveal (a session-switch working-set restore, a layout race, the 60%-of-window split) is **re-hidden** — so reopening a new session after visiting a created session keeps the editor closed. While there is no real content the width-based reveal-sync is also suppressed (`setSuppressDockedEditorRevealSync(true)`), so sidebar-collapse, grid relayout, and grid-sash drags never re-reveal the editor there. Once a real file/diff is the active editor the hide **short-circuits** (and the suppress flag clears), so a real open (via `onWillOpenEditor` → `setEditorHidden(false)`) or the detail-panel toggle reveals it and sticks. On submit, a Changes tab is added and the Changes detail is shown, but the editor content stays closed. The auto-managed Changes and File tabs never reveal the editor content. diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 5ac7f1eb591d29..be4c0e49f2c7ad 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -57,6 +57,7 @@ import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/ import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { isDiffEditor } from '../../../../editor/browser/editorBrowser.js'; import { getChangesEditorLabels } from './changesEditorLabels.js'; import { ISessionChangesService } from './sessionChangesService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; @@ -926,14 +927,9 @@ export class ChangesViewPane extends ViewPane { return; } - if (this.shouldRevealFileInMultiDiffEditor()) { - void this._openMultiFileDiffEditor(e.element.uri); - return; - } - // Holding Alt inverts the configured single/multi file diff behavior. const altKey = !!(e.browserEvent as MouseEvent | KeyboardEvent | undefined)?.altKey; - const openSingleFileDiff = this.configurationService.getValue(SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING) !== altKey; + const openSingleFileDiff = this.shouldOpenSingleFileDiffByDefault() !== altKey; if (openSingleFileDiff) { // Alt here only switches the diff mode, not the target group. const sideBySide = e.sideBySide && !altKey; @@ -1479,16 +1475,19 @@ export class ChangesViewPane extends ViewPane { } /** - * Whether clicking a file opens the modal single-file diff (vs the multi-file - * diff editor). Standard layout honors the `workbench.editor.useModal` setting; - * {@link SinglePaneChangesViewPane} always opens the multi-file diff. + * Whether clicking a file opens the modal single-file diff. {@link SinglePaneChangesViewPane} + * never uses the modal editor. */ protected shouldOpenModalDiff(): boolean { return this.configurationService.getValue('workbench.editor.useModal') === 'all'; } - protected shouldRevealFileInMultiDiffEditor(): boolean { - return false; + /** + * Whether clicking a file opens a single-file diff by default (vs the + * multi-file diff editor). Alt inverts this. + */ + protected shouldOpenSingleFileDiffByDefault(): boolean { + return this.configurationService.getValue(SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING); } /** @@ -1562,12 +1561,32 @@ export class ChangesViewPane extends ViewPane { // (no modified) are shown as a diff against an empty side, matching the // "Open Changes" action. const modifiedUri = isDeletion ? undefined : uri; - await this.editorService.openEditor({ + const pane = await this.editorService.openEditor({ original: { resource: originalUri }, modified: { resource: modifiedUri }, ...labels, options: { preserveFocus, pinned } }, group); + + // Show the whole file rather than folding unchanged regions, since this + // diff is opened to review one specific file. No open-call option exists + // for this, so apply it via updateOptions() once the pane resolves - but + // the pane's diff editor control is reused across different inputs, so + // restore the configured value once this input is no longer active, + // rather than leaving the override stuck for whatever opens next. + const control = pane?.getControl(); + if (pane && isDiffEditor(control)) { + const openedInput = pane.input; + control.updateOptions({ hideUnchangedRegions: { enabled: false } }); + const listener = pane.group.onDidActiveEditorChange(() => { + if (pane.group.activeEditor === openedInput) { + return; + } + listener.dispose(); + control.updateOptions({ hideUnchangedRegions: { enabled: this.configurationService.getValue('diffEditor.hideUnchangedRegions.enabled') } }); + }); + this._register(listener); + } } private async _openMultiFileDiffEditor(reveal?: URI): Promise { @@ -1619,7 +1638,7 @@ export class ChangesViewPane extends ViewPane { * Changes view for the single-pane layout: the files list lives in the docked * detail panel while the Branch Changes header, Create-PR actions, and diffs are * shown in the custom Changes editor. Overrides the standard hooks to omit the - * in-panel header/actions and always open the multi-file diff. + * in-panel header/actions. */ export class SinglePaneChangesViewPane extends ChangesViewPane { @@ -1636,12 +1655,9 @@ export class SinglePaneChangesViewPane extends ChangesViewPane { } protected override shouldOpenModalDiff(): boolean { + // Single-pane never uses the modal editor. return false; } - - protected override shouldRevealFileInMultiDiffEditor(): boolean { - return true; - } } export class ChangesViewPaneContainer extends ViewPaneContainer { diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneResponsiveSidebarStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneResponsiveSidebarStrategy.ts index 7407af3aab98d0..012d6097757f3a 100644 --- a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneResponsiveSidebarStrategy.ts +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneResponsiveSidebarStrategy.ts @@ -13,6 +13,8 @@ import { Action2, MenuId, registerAction2 } from '../../../../../platform/action import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext } from '../../../../../workbench/common/contextkeys.js'; +import { DiffEditorInput } from '../../../../../workbench/common/editor/diffEditorInput.js'; +import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; import { FileEditorInput } from '../../../../../workbench/contrib/files/browser/editors/fileEditorInput.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; @@ -32,6 +34,17 @@ const singlePaneLayoutToggleDetailsOrder = 30; /** Below this main-container width the sessions list is auto-hidden to free room for the side pane; wider windows have room to keep it open. */ const SMALL_WINDOW_MAX_WIDTH = 1800; +/** Whether `editor` is real file/diff content that needs editor-area room (vs. a managed tab like the Changes multi-diff or empty Files placeholder). */ +function isRealEditorContent(editor: EditorInput): boolean { + if (editor instanceof FileEditorInput || editor instanceof BrowserEditorInput) { + return true; + } + if (editor instanceof DiffEditorInput) { + return editor.original instanceof FileEditorInput || editor.modified instanceof FileEditorInput; + } + return false; +} + /** * [D7 single-pane] Auto-hide the sessions list when the user needs more room for * the side pane: opening the details pane via the Toggle Details action, or @@ -63,12 +76,12 @@ export class SinglePaneResponsiveSidebarStrategy extends SinglePaneLayoutStrateg // toggle command. this._register(this._registerToggleDetailsAction()); - // [Scenario 8] Opening a real file/browser editor from the Files or Changes - // view needs editor-area room, so auto-hide the sessions list — but only on - // a small window, in an existing (created) session, and only when the editor - // area is currently closed (this open will reveal it). Managed tabs (the - // Changes multi-diff and the empty Files placeholder) are not - // FileEditorInput/BrowserEditorInput so they never trigger this; a + // [Scenario 8] Opening a real file/browser editor or a single-file diff + // from the Files or Changes view needs editor-area room, so auto-hide the + // sessions list — but only on a small window, in an existing (created) + // session, and only when the editor area is currently closed (this open + // will reveal it). Managed tabs (the Changes multi-diff and the empty + // Files placeholder) are not real content so they never trigger this; a // session-switch restore is excluded too. this._register(this._editorService.onWillOpenEditor(e => { if (this._ctx.isRestoringSessionLayout || this._ctx.multipleSessionsVisibleObs.get() || this._layoutService.isEditorMaximized()) { @@ -81,7 +94,7 @@ export class SinglePaneResponsiveSidebarStrategy extends SinglePaneLayoutStrateg if (!activeSession?.isCreated.get() || this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { return; } - if (!(e.editor instanceof FileEditorInput || e.editor instanceof BrowserEditorInput)) { + if (!isRealEditorContent(e.editor)) { return; } if (this._setSidebarAutoHidden(true)) { diff --git a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts index 6683e304bcfd45..912b9b2f1a5ec3 100644 --- a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts @@ -22,6 +22,7 @@ import { ISessionFileChange, SessionStatus } from '../../../../services/sessions import { SinglePaneChangesTabMissingContext, HasDockedDetailsContext, SinglePaneFilesTabMissingContext } from '../../../../common/contextkeys.js'; import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; import { FileEditorInput } from '../../../../../workbench/contrib/files/browser/editors/fileEditorInput.js'; +import { DiffEditorInput } from '../../../../../workbench/common/editor/diffEditorInput.js'; import { EmptyFileEditorInput } from '../../../editor/browser/emptyFileEditorInput.js'; import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; import { IEditorWillOpenEvent, isResourceEditorInput } from '../../../../../workbench/common/editor.js'; @@ -2247,6 +2248,22 @@ suite('LayoutController (desktop)', () => { assert.deepStrictEqual(sidebarHiddenCalls(), [true]); }); + test('[Scenario 8] hides the sessions list when a single-file diff is opened in a created session with the editor closed', async () => { + createSinglePaneController({ mainContainerWidth: 800 }); + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, false); + await timeout(0); + harness.setPartHiddenCalls = []; + + const diffEditor = Object.create(DiffEditorInput.prototype) as DiffEditorInput; + Object.defineProperty(diffEditor, 'original', { value: Object.create(FileEditorInput.prototype) }); + Object.defineProperty(diffEditor, 'modified', { value: Object.create(FileEditorInput.prototype) }); + openEditor(diffEditor); + + assert.deepStrictEqual(sidebarHiddenCalls(), [true]); + }); + test('[Scenario 8] does not hide the sessions list on a big window when a file is opened', async () => { createSinglePaneController({ mainContainerWidth: 2400 }); harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); From 87efe6c5bfeb296ccf701d330cf6e55a46e5d61c Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Jul 2026 17:29:27 -0400 Subject: [PATCH 34/34] Voice Mode: replace settings gear with 'Configure' context-menu action (#327042) --- .../browser/agentsVoice.contribution.ts | 15 +--------- .../agentsVoice/browser/agentsVoiceWidget.ts | 29 +++++++++---------- .../browser/agentsVoiceWindowService.ts | 13 ++++++++- .../browser/components/headerComponent.ts | 20 ++++--------- .../agentsVoice/browser/components/tokens.ts | 11 +++++++ .../speechToText/micButtonMenuActions.ts | 21 +++++++++++++- 6 files changed, 64 insertions(+), 45 deletions(-) diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index 731e2bfdefb22b..a6f153a7afb07b 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -347,28 +347,15 @@ registerAction2(class extends Action2 { } }); -// --- Open Voice Mode Settings (gear button, shown left of Disconnect when connected) --- +// --- Open Voice Mode Settings (surfaced via the mic button context menu, no toolbar gear) --- registerAction2(class extends Action2 { constructor() { super({ id: 'agentsVoice.openSettings', title: nls.localize2('agentsVoice.openSettings', "Voice Mode Settings"), - icon: Codicon.settingsGear, f1: true, precondition: ContextKeyExpr.equals('config.agents.voice.enabled', true), - menu: { - id: MenuId.ChatExecute, - when: ContextKeyExpr.and( - ContextKeyExpr.equals('config.agents.voice.enabled', true), - ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), - ChatContextKeys.currentlyEditing.negate(), - AGENTS_VOICE_CONNECTED.isEqualTo(true), - ), - group: 'navigation', - // Just before the Disconnect button (order -9) and after the mic/stop button (order -10). - order: -9.5 - }, }); } async run(accessor: ServicesAccessor): Promise { diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts index c13cf50c87068f..fc5f2dd052b03b 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts @@ -17,7 +17,7 @@ import { createSessionList, type SessionRowData, type SessionGroupData } from '. import { createFeedbackDialog, type FeedbackDialogState } from './components/feedbackDialog.js'; import { createOnboarding } from './components/onboardingComponent.js'; import { createVoiceBar } from './components/voiceBarComponent.js'; -import { FONT_SIZE, addKeyboardActivation } from './components/tokens.js'; +import { FONT_SIZE, addKeyboardActivation, isSecondaryPointerGesture } from './components/tokens.js'; import type { VoiceState, IPendingToolConfirmation, ITranscriptTurn } from '../../chat/browser/voiceClient/voiceSessionController.js'; import { computeVoiceGlowStyle } from '../../chat/browser/voiceClient/voiceGlow.js'; @@ -40,6 +40,12 @@ export interface VoiceWidgetCallbacks { getAnalyserNode(): AnalyserNode | null; onResize(): void; openPttKeySettings(): void; + /** + * Show the Voice Mode context menu (Configure, Select Microphone, Disable + * Voice Mode) anchored at the triggering event. Wired to a right-click / + * context-menu gesture on the voice mode mic icon. + */ + showVoiceContextMenu(e: MouseEvent): void; /** Optional — when provided, header renders a "popout" button. */ openPopout?(): void; /** Submit user feedback. Returns success/failure. */ @@ -181,7 +187,6 @@ export class AgentsVoiceWidget extends Disposable { private readonly _inputBoxPlaceholder: HTMLElement | undefined; private readonly _inputBoxToolbar: HTMLElement | undefined; private readonly _inputBoxMicBtn: HTMLElement | undefined; - private readonly _inputBoxGearBtn: HTMLElement | undefined; private readonly _inputBoxConnIndicator: HTMLElement | undefined; private readonly _inputBoxFeedbackBtn: HTMLElement | undefined; private readonly _inputBoxSessionsBtn: HTMLElement | undefined; @@ -335,17 +340,16 @@ export class AgentsVoiceWidget extends Disposable { this._inputBoxMicBtn.ariaLabel = localize('agentsVoice.pushToTalkSpace', "Push to talk (Space)"); this._inputBoxMicBtn.title = localize('agentsVoice.pushToTalkSpace', "Push to talk (Space)"); this._inputBoxMicBtn.style.cssText = `font-size:${FONT_SIZE.iconMd};cursor:pointer;-webkit-app-region:no-drag;border-radius:4px;padding:2px;`; + this._register(dom.addDisposableListener(this._inputBoxMicBtn, 'contextmenu', (e: MouseEvent) => { + e.preventDefault(); e.stopPropagation(); + this.callbacks.showVoiceContextMenu(e); + })); // Connection indicator this._inputBoxConnIndicator = toolbarBtn('codicon-debug-connected', localize('agentsVoice.disconnect', "Disconnect"), localize('agentsVoice.disconnect', "Disconnect")); - // Gear button - this._inputBoxGearBtn = toolbarBtn('codicon-gear', - localize('agentsVoice.configureKeybinding', "Configure keybinding"), - localize('agentsVoice.configureKeybinding', "Configure keybinding")); - // Feedback button this._inputBoxFeedbackBtn = toolbarBtn('codicon-feedback', localize('agentsVoice.sendFeedback', "Send feedback"), @@ -371,7 +375,6 @@ export class AgentsVoiceWidget extends Disposable { this._inputBoxToolbar.append( this._inputBoxMicBtn, this._inputBoxConnIndicator, - this._inputBoxGearBtn, toolbarSpacer, this._inputBoxFeedbackBtn, this._inputBoxSessionsBtn, @@ -753,17 +756,13 @@ export class AgentsVoiceWidget extends Disposable { if (!micIsActive) { this._inputBoxMicBtn!.style.boxShadow = 'none'; } - this._inputBoxMicBtn!.onmousedown = (e: MouseEvent) => { e.preventDefault(); this.callbacks.pttDown(); }; - this._inputBoxMicBtn!.onmouseup = () => { this.callbacks.pttUp(); }; + this._inputBoxMicBtn!.onmousedown = (e: MouseEvent) => { if (isSecondaryPointerGesture(e)) { return; } e.preventDefault(); this.callbacks.pttDown(); }; + this._inputBoxMicBtn!.onmouseup = (e: MouseEvent) => { if (isSecondaryPointerGesture(e)) { return; } this.callbacks.pttUp(); }; // Connection indicator — visible when connected this._inputBoxConnIndicator!.style.display = showConnected ? '' : 'none'; this._inputBoxConnIndicator!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.disconnect(); }; - // Gear button — always visible - this._inputBoxGearBtn!.style.display = ''; - this._inputBoxGearBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.openPttKeySettings(); }; - // Feedback button — always visible this._inputBoxFeedbackBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this._toggleFeedbackDialog(); }; @@ -841,7 +840,7 @@ export class AgentsVoiceWidget extends Disposable { onDisconnectClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.disconnect(); }, onCloseClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.closeWindow(); }, onToggleClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this._expanded.set(!this._expanded.get(), undefined); }, - onPttKeyClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.openPttKeySettings(); }, + onMicContextMenu: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.showVoiceContextMenu(e); }, onPopoutClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.openPopout?.(); }, onFeedbackClick: (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this._toggleFeedbackDialog(); }, pttKeyLabel: this._pttKeyLabel.read(reader), diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts index 2c591d793a36cf..4534922c334486 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts @@ -6,7 +6,7 @@ import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { mainWindow } from '../../../../base/browser/window.js'; -import { disposableWindowInterval } from '../../../../base/browser/dom.js'; +import { disposableWindowInterval, getWindow } from '../../../../base/browser/dom.js'; import { FileAccess } from '../../../../base/common/network.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -33,6 +33,9 @@ import { AgentsVoiceWidget } from './agentsVoiceWidget.js'; import { bindWidgetToController } from './agentsVoiceWidgetBinding.js'; import { AgentsVoiceSessionsPicker } from './agentsVoiceSessionsPicker.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; +import { getVoiceModeContextMenuActions } from '../../chat/browser/speechToText/micButtonMenuActions.js'; export class AgentsVoiceWindowService extends Disposable implements IAgentsVoiceWindowService { @@ -73,6 +76,7 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice @IThemeService private readonly themeService: IThemeService, @IKeybindingService private readonly keybindingService: IKeybindingService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { super(); @@ -206,6 +210,13 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice }, onResize: () => this._resizeWindow(auxiliaryWindow), openPttKeySettings: () => this.commandService.executeCommand('workbench.action.openGlobalKeybindings', 'agentsVoice.pushToTalk'), + showVoiceContextMenu: (e: MouseEvent) => { + const anchor = new StandardMouseEvent(getWindow(e.target as Node ?? auxiliaryWindow.container), e); + this.contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => getVoiceModeContextMenuActions(this.commandService, this.configurationService, this.keybindingService, 'agentsVoice.pushToTalk'), + }); + }, submitFeedback: (text) => this.voiceSessionController.submitFeedback(text), showSessionsPicker: () => { const picker = this.instantiationService.createInstance( diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts index 926551d6569719..55c9c745ef7d94 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts @@ -6,7 +6,7 @@ import * as dom from '../../../../../base/browser/dom.js'; import { localize } from '../../../../../nls.js'; import type { VoiceState } from '../../../chat/browser/voiceClient/voiceSessionController.js'; -import { FONT_SIZE, addKeyboardActivation } from './tokens.js'; +import { FONT_SIZE, addKeyboardActivation, isSecondaryPointerGesture } from './tokens.js'; export interface HeaderProps { readonly copilotIconSrc: string; @@ -27,7 +27,7 @@ export interface HeaderProps { readonly onDisconnectClick: (e: MouseEvent) => void; readonly onCloseClick: (e: MouseEvent) => void; readonly onToggleClick: (e: MouseEvent) => void; - readonly onPttKeyClick: (e: MouseEvent) => void; + readonly onMicContextMenu: (e: MouseEvent) => void; readonly onPopoutClick: (e: MouseEvent) => void; readonly onFeedbackClick: (e: MouseEvent) => void; readonly expanded: boolean; @@ -67,11 +67,6 @@ export function createHeader(): HeaderComponent { micBtn.title = localize('agentsVoice.pushToTalkSpace', "Push to talk (Space)"); micBtn.style.cssText = `font-size:${FONT_SIZE.iconMd};cursor:pointer;-webkit-app-region:no-drag;border-radius:4px;padding:2px;`; - // PTT key / gear button - const gearBtn = hoverButton('codicon-gear', - localize('agentsVoice.configureKeybinding', "Configure keybinding"), - localize('agentsVoice.configureKeybinding', "Configure keybinding")); - // Connection indicator const connIndicator = dom.$('span.voice-conn-indicator'); connIndicator.role = 'button'; @@ -133,7 +128,7 @@ export function createHeader(): HeaderComponent { } `; - container.append(copilotIcon, micBtn, placeholderText, gearBtn, connIndicator, spacer, popoutBtn, closeBtn, connStyle); + container.append(copilotIcon, micBtn, placeholderText, connIndicator, spacer, popoutBtn, closeBtn, connStyle); return { element: container, @@ -162,8 +157,9 @@ export function createHeader(): HeaderComponent { } micBtn.onmouseenter = () => { micBtn.style.color = 'var(--vscode-foreground)'; }; micBtn.onmouseleave = () => { micBtn.style.color = micColor; }; - micBtn.onmousedown = props.onMicDown; - micBtn.onmouseup = () => props.onMicUp(); + micBtn.onmousedown = (e: MouseEvent) => { if (isSecondaryPointerGesture(e)) { return; } props.onMicDown(e); }; + micBtn.onmouseup = (e: MouseEvent) => { if (isSecondaryPointerGesture(e)) { return; } props.onMicUp(); }; + micBtn.oncontextmenu = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); props.onMicContextMenu(e); }; // Placeholder text — shown when not connected, displays PTT keybinding placeholderText.style.display = showConnected ? 'none' : ''; @@ -175,10 +171,6 @@ export function createHeader(): HeaderComponent { placeholderText.ariaLabel = holdText; placeholderText.onclick = props.onConnectClick; - // Gear - gearBtn.style.display = props.isConnected ? '' : 'none'; - gearBtn.onclick = props.onPttKeyClick; - // Connection indicator connIndicator.style.display = showConnected && !props.hideDisconnect ? 'inline-flex' : 'none'; connIndicator.onclick = props.onDisconnectClick; diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/tokens.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/tokens.ts index 6d6171b6601579..d3366f66d1cb1e 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/tokens.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/tokens.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { isMacintosh } from '../../../../../base/common/platform.js'; + /** * Visual design tokens for the agentsVoice floating pane. * Centralize sizes and colors so every component pulls from the same palette. @@ -41,3 +43,12 @@ export function addKeyboardActivation(el: HTMLElement): void { } }); } + +/** + * Whether a mouse event is a secondary-click / context-menu gesture (right-click, + * or Ctrl-click on macOS) rather than a primary press. Used to keep such gestures + * from starting/stopping push-to-talk when the mic also opens a context menu. + */ +export function isSecondaryPointerGesture(e: MouseEvent): boolean { + return e.button !== 0 || (isMacintosh && e.ctrlKey); +} diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts index c69f9c8b07002e..1d143a27dfac18 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/micButtonMenuActions.ts @@ -20,6 +20,8 @@ export const SELECT_MICROPHONE_COMMAND = 'workbench.action.chat.selectSpeechToTe const CANCEL_DICTATION_COMMAND = 'workbench.action.chat.cancelSpeechToText'; /** Command that tears down an active Voice Mode session. */ const VOICE_DISCONNECT_COMMAND = 'agentsVoice.disconnect'; +/** Command that opens the Voice Mode settings; the affordance that used to live behind the toolbar gear. */ +const VOICE_OPEN_SETTINGS_COMMAND = 'agentsVoice.openSettings'; /** Setting that enables dictation; toggled off by "Disable Dictation". */ const DICTATION_ENABLED_SETTING = 'chat.speechToText.enabled'; /** Setting that enables Voice Mode; toggled off by "Disable Voice Mode". */ @@ -83,13 +85,30 @@ export function getDictationContextMenuActions(commandService: ICommandService, ]; } +/** + * "Voice Mode Settings" entry. Opens the Voice Mode settings — the affordance + * that used to live behind the toolbar gear button. + */ +function createVoiceModeSettingsAction(commandService: ICommandService): IAction { + return toAction({ + id: VOICE_OPEN_SETTINGS_COMMAND, + label: localize('voiceMode.openSettings', "Open Settings"), + run: () => commandService.executeCommand(VOICE_OPEN_SETTINGS_COMMAND), + }); +} + /** * Actions for the Voice Mode mic button context menu, mirroring - * {@link getDictationContextMenuActions} but with "Disable Voice Mode". + * {@link getDictationContextMenuActions} but with "Disable Voice Mode". The + * "Configure Keybinding" entry opens the keybindings editor scoped to the Voice + * Mode keybinding and "Voice Mode Settings" opens the Voice Mode settings — the + * affordances that used to live behind the toolbar gear button. + * `keybindingCommandId` is the stable command the keybinding entry targets. */ export function getVoiceModeContextMenuActions(commandService: ICommandService, configurationService: IConfigurationService, keybindingService: IKeybindingService, keybindingCommandId: string): IAction[] { return [ createConfigureKeybindingAction(commandService, keybindingService, keybindingCommandId), + createVoiceModeSettingsAction(commandService), createSelectMicrophoneAction(commandService), createDisableVoiceModeAction(commandService, configurationService), ];