From 2e390be6675495942acc8762cb2d58529279f742 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 18 Aug 2026 08:55:50 -0700 Subject: [PATCH 01/14] agentHost: remove BYOK environment override Use synchronized root configuration as the sole source of BYOK enablement and simplify the related logging and tests.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHostStarter.config.contribution.ts | 2 +- .../platform/agentHost/common/agentService.ts | 11 --------- .../agentHost/node/copilot/copilotAgent.ts | 6 ++--- .../node/copilot/copilotSessionLauncher.ts | 6 ++--- .../test/common/agentService.test.ts | 23 +------------------ .../agentHost/test/node/copilotAgent.test.ts | 10 +------- .../test/node/copilotSessionLauncher.test.ts | 8 ------- 7 files changed, 7 insertions(+), 59 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index b4df6ee7c1b18b..84bc7ed97c0eb4 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -244,7 +244,7 @@ configurationRegistry.registerConfiguration({ }, [AgentHostByokModelsEnabledSettingId]: { type: 'boolean', - description: nls.localize('chat.agentHost.byokModels.enabled', "When enabled, the agent host wires up the BYOK ('bring your own key') language-model bridge so extension-provided BYOK models can run in agent-host sessions. Changes take effect immediately unless overridden by the agent host environment."), + description: nls.localize('chat.agentHost.byokModels.enabled', "When enabled, the agent host wires up the BYOK ('bring your own key') language-model bridge so extension-provided BYOK models can run in agent-host sessions. Changes take effect immediately."), default: false, tags: ['experimental', 'advanced'], experiment: { mode: 'startup' }, diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index deefb0ed8c0f8c..336a6b053fdfde 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -184,13 +184,6 @@ export const AgentHostClaudeAgentEnabledEnvVar = 'VSCODE_AGENT_HOST_CLAUDE_AGENT */ export const AgentHostCodexAgentEnabledEnvVar = 'VSCODE_AGENT_HOST_CODEX_AGENT_ENABLED'; -/** - * Explicit environment override for {@link AgentHostByokModelsEnabledSettingId}. - * Accepts `'true'` / `'false'`; when absent or invalid, the synchronized agent - * host root configuration determines whether BYOK models are enabled. - */ -export const AgentHostByokModelsEnabledEnvVar = 'VSCODE_AGENT_HOST_BYOK_MODELS_ENABLED'; - /** * Overrides the grace period (in milliseconds) before an idle, fully * unsubscribed session is released from memory. Defaults to 30_000. Primarily a @@ -223,10 +216,6 @@ export function isAgentEnabled(envValue: string | undefined, defaultEnabled: boo return defaultEnabled; } -export function isAgentHostByokModelsEnabled(envValue: string | undefined, rootConfigValue: boolean | undefined): boolean { - return isAgentEnabled(envValue, rootConfigValue ?? false); -} - /** * Configuration key that controls the sandbox mode for the Copilot SDK's built-in * shell tool (the path taken when `AgentHostCustomTerminalToolEnabledSettingId` diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 85aa9ed153c06a..8bfaec76de0a4e 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -38,7 +38,6 @@ import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTel import { IAgentHostReviewService } from '../../common/agentHostReviewService.js'; import { createPricingMetaFromBilling, hasLongContextSurcharge, normalizeCAPIBilling, type ICAPIModelBilling } from '../../common/agentModelPricing.js'; import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; -import { AgentHostByokModelsEnabledEnvVar, isAgentHostByokModelsEnabled } from '../../common/agentService.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, DEFAULT_SESSION_CUSTOMIZATION_DISCOVERY_MODE, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliConfigSchema, DEFAULT_COPILOT_RUBBER_DUCK_ENABLED, type CopilotSdkLogLevelSetting } from '../../common/copilotCliConfig.js'; import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; @@ -1733,10 +1732,9 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._shutdownPromise) { return; } - const envValue = process.env[AgentHostByokModelsEnabledEnvVar]; const rootConfigValue = this._configurationService.getRootValue(platformRootSchema, AgentHostByokModelsEnabledConfigKey); - const enabled = isAgentHostByokModelsEnabled(envValue, rootConfigValue); - this._logService.trace(`[Copilot] BYOK model publication enabled: ${enabled} (environment: ${envValue ?? 'unset'}, root config: ${rootConfigValue ?? 'unset'})`); + const enabled = rootConfigValue === true; + this._logService.trace(`[Copilot] BYOK model publication enabled: ${enabled} (root config: ${rootConfigValue ?? 'unset'})`); if (!enabled) { this._byokModels = []; this._publishModels(); diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index ffe80f701b3472..21386fd29c265d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -15,7 +15,6 @@ import { ILogService, LogLevel } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agent.js'; import { getByokLmSelectionModelId, type IByokLmModelInfo } from '../../common/agentHostByokLm.js'; import { AgentHostByokModelsEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; -import { AgentHostByokModelsEnabledEnvVar, isAgentHostByokModelsEnabled } from '../../common/agentService.js'; import { CopilotCliConfigKey, copilotCliConfigSchema, normalizeModelFamilyAlias, normalizeToolSearchDeferThreshold, resolveModelCapabilityOverrideField } from '../../common/copilotCliConfig.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { reasoningEffortLevels, type ReasoningEffortLevel } from '../../common/reasoningEffort.js'; @@ -691,10 +690,9 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { * shared proxy handle for this launcher (started lazily on first use). */ private _resolveByokSessionConfig(sessionId: string): Promise<{ providers?: NamedProviderConfig[]; models?: ProviderModelConfig[] }> { - const envValue = process.env[AgentHostByokModelsEnabledEnvVar]; const rootConfigValue = this._configurationService.getRootValue(platformRootSchema, AgentHostByokModelsEnabledConfigKey); - const enabled = isAgentHostByokModelsEnabled(envValue, rootConfigValue); - this._logService.trace(`[Copilot:${sessionId}] BYOK session configuration enabled: ${enabled} (environment: ${envValue ?? 'unset'}, root config: ${rootConfigValue ?? 'unset'})`); + const enabled = rootConfigValue === true; + this._logService.trace(`[Copilot:${sessionId}] BYOK session configuration enabled: ${enabled} (root config: ${rootConfigValue ?? 'unset'})`); if (!enabled) { return Promise.resolve({}); } diff --git a/src/vs/platform/agentHost/test/common/agentService.test.ts b/src/vs/platform/agentHost/test/common/agentService.test.ts index bcad6698343986..ec541d729daff6 100644 --- a/src/vs/platform/agentHost/test/common/agentService.test.ts +++ b/src/vs/platform/agentHost/test/common/agentService.test.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IConfigurationService } from '../../../configuration/common/configuration.js'; import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, protectedResourcesRequireGitHubCopilotSignIn } from '../../common/agent.js'; -import { AgentHostCodexAgentEnabledSettingId, AgentHostOTelEnvVars, buildAgentHostOTelEnv, CodexPreferAgentHostEditorSettingId, isAgentEnabled, isAgentHostByokModelsEnabled, readAgentHostOTelPolicySettings, sanitizeAgentHostOTelPolicySettings, shouldSurfaceLocalAgentHostProvider } from '../../common/agentService.js'; +import { AgentHostCodexAgentEnabledSettingId, AgentHostOTelEnvVars, buildAgentHostOTelEnv, CodexPreferAgentHostEditorSettingId, isAgentEnabled, readAgentHostOTelPolicySettings, sanitizeAgentHostOTelPolicySettings, shouldSurfaceLocalAgentHostProvider } from '../../common/agentService.js'; import type { ProtectedResourceMetadata } from '../../common/state/protocol/state.js'; import { buildChatUri, buildDefaultChatUri, resolveChatUri } from '../../common/state/sessionState.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; @@ -303,27 +303,6 @@ suite('resolveChatUri', () => { }); }); -suite('isAgentHostByokModelsEnabled', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('uses an explicit environment override before synchronized root config', () => { - assert.deepStrictEqual({ - envFalseRootTrue: isAgentHostByokModelsEnabled('false', true), - envTrueRootFalse: isAgentHostByokModelsEnabled('true', false), - noEnvRootFalse: isAgentHostByokModelsEnabled(undefined, false), - noEnvRootTrue: isAgentHostByokModelsEnabled(undefined, true), - noSources: isAgentHostByokModelsEnabled(undefined, undefined), - }, { - envFalseRootTrue: false, - envTrueRootFalse: true, - noEnvRootFalse: false, - noEnvRootTrue: true, - noSources: false, - }); - }); -}); - suite('protectedResourcesRequireGitHubCopilotSignIn', () => { ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 26f475cb9784b8..5e540bebd894b0 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -37,7 +37,6 @@ import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService. import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey } from '../../common/copilotCliConfig.js'; import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey } from '../../common/agentHostSchema.js'; -import { AgentHostByokModelsEnabledEnvVar } from '../../common/agentService.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentChatContext, type IAgentChatMetadata, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentDiscoveredChat, type IAgentMaterializeChatEvent, type IAgentSpawnChatEvent } from '../../common/agent.js'; @@ -4202,9 +4201,7 @@ suite('CopilotAgent', () => { } }); - test('BYOK models follow synchronized root configuration when there is no environment override', async () => { - const previousEnvValue = process.env[AgentHostByokModelsEnabledEnvVar]; - delete process.env[AgentHostByokModelsEnabledEnvVar]; + test('BYOK models follow synchronized root configuration', async () => { const byokBridgeRegistry = new ByokLmBridgeRegistry(); const { agent, configurationService } = createTestAgentContext(disposables, { byokBridgeRegistry, @@ -4234,11 +4231,6 @@ suite('CopilotAgent', () => { disabledAgain: [], }); } finally { - if (previousEnvValue === undefined) { - delete process.env[AgentHostByokModelsEnabledEnvVar]; - } else { - process.env[AgentHostByokModelsEnabledEnvVar] = previousEnvValue; - } await disposeAgent(agent); } }); diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index 667cb230233cb4..e6305ac745cd38 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -17,7 +17,6 @@ import { ILogService, NullLogService } from '../../../log/common/log.js'; import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js'; import type { IByokLmBridgeConnection, IByokLmChatRequest, IByokLmChatResult, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; import { AgentHostByokModelsEnabledConfigKey, type SchemaValues } from '../../common/agentHostSchema.js'; -import { AgentHostByokModelsEnabledEnvVar } from '../../common/agentService.js'; import type { IAgentHostManagedSettingsPermissions } from '../../common/agentHostManagedSettings.js'; import { CopilotCliConfigKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; import type { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; @@ -331,8 +330,6 @@ suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { }); test('does not synthesize BYOK session config while root configuration disables it', async () => { - const previousEnvValue = process.env[AgentHostByokModelsEnabledEnvVar]; - delete process.env[AgentHostByokModelsEnabledEnvVar]; const store = new DisposableStore(); const proxy = fakeProxyService(); const registry = new ByokLmBridgeRegistry(); @@ -344,11 +341,6 @@ suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { assert.deepStrictEqual({ config, proxyStarts: proxy.starts }, { config: {}, proxyStarts: 0 }); } finally { store.dispose(); - if (previousEnvValue === undefined) { - delete process.env[AgentHostByokModelsEnabledEnvVar]; - } else { - process.env[AgentHostByokModelsEnabledEnvVar] = previousEnvValue; - } } }); }); From d6e9c56d98828b0a6527b5252ed5d855727d3c75 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 18 Aug 2026 09:24:29 -0700 Subject: [PATCH 02/14] agentHost: centralize BYOK enablement resolution Reuse one root-configuration gate and trace format for model publication and session setup. Clarify that setting changes apply after synchronization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/common/agentHostByokLm.ts | 9 +++++++++ .../common/agentHostStarter.config.contribution.ts | 2 +- src/vs/platform/agentHost/node/copilot/copilotAgent.ts | 6 +++--- .../agentHost/node/copilot/copilotSessionLauncher.ts | 6 +++--- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostByokLm.ts b/src/vs/platform/agentHost/common/agentHostByokLm.ts index 09d5a6b3d806df..0f9f33dac3ff7b 100644 --- a/src/vs/platform/agentHost/common/agentHostByokLm.ts +++ b/src/vs/platform/agentHost/common/agentHostByokLm.ts @@ -176,6 +176,15 @@ export function getByokLmAgentModelId(model: IByokLmModelInfo): string { return `${model.vendor}/${getByokLmSelectionModelId(model)}`; } +/** Resolves BYOK enablement and trace context from synchronized root configuration. */ +export function resolveByokLmEnablement(rootConfigValue: boolean | undefined): { readonly enabled: boolean; readonly trace: string } { + const enabled = rootConfigValue === true; + return { + enabled, + trace: `enabled: ${enabled} (root config: ${rootConfigValue ?? 'unset'})`, + }; +} + export const IAgentHostByokLmHandler = createDecorator('agentHostByokLmHandler'); /** diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 84bc7ed97c0eb4..e77d2d89942117 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -244,7 +244,7 @@ configurationRegistry.registerConfiguration({ }, [AgentHostByokModelsEnabledSettingId]: { type: 'boolean', - description: nls.localize('chat.agentHost.byokModels.enabled', "When enabled, the agent host wires up the BYOK ('bring your own key') language-model bridge so extension-provided BYOK models can run in agent-host sessions. Changes take effect immediately."), + description: nls.localize('chat.agentHost.byokModels.enabled', "When enabled, the agent host wires up the BYOK ('bring your own key') language-model bridge so extension-provided BYOK models can run in agent-host sessions. Changes take effect after the setting is synchronized to the agent host."), default: false, tags: ['experimental', 'advanced'], experiment: { mode: 'startup' }, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 8bfaec76de0a4e..c459cf1067145a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -60,7 +60,7 @@ import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomization import { ActionType, AuthRequiredReason, type AuthRequiredParams, type SessionAction } from '../../common/state/sessionActions.js'; import { areAdditionalWorkingDirectoriesEqual } from '../../common/state/sessionWorkingDirectories.js'; import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_READ_DB_KEY, isDefaultChatUri, withSessionEhcliAdoptable, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; -import { getByokLmAgentModelId } from '../../common/agentHostByokLm.js'; +import { getByokLmAgentModelId, resolveByokLmEnablement } from '../../common/agentHostByokLm.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; import { ActiveClientToolSet, structuralToolsEqual } from '../activeClientState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; @@ -1733,8 +1733,8 @@ export class CopilotAgent extends Disposable implements IAgent { return; } const rootConfigValue = this._configurationService.getRootValue(platformRootSchema, AgentHostByokModelsEnabledConfigKey); - const enabled = rootConfigValue === true; - this._logService.trace(`[Copilot] BYOK model publication enabled: ${enabled} (root config: ${rootConfigValue ?? 'unset'})`); + const { enabled, trace } = resolveByokLmEnablement(rootConfigValue); + this._logService.trace(`[Copilot] BYOK model publication ${trace}`); if (!enabled) { this._byokModels = []; this._publishModels(); diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 21386fd29c265d..8bf3c2c62c8ab0 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -13,7 +13,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agent.js'; -import { getByokLmSelectionModelId, type IByokLmModelInfo } from '../../common/agentHostByokLm.js'; +import { getByokLmSelectionModelId, resolveByokLmEnablement, type IByokLmModelInfo } from '../../common/agentHostByokLm.js'; import { AgentHostByokModelsEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { CopilotCliConfigKey, copilotCliConfigSchema, normalizeModelFamilyAlias, normalizeToolSearchDeferThreshold, resolveModelCapabilityOverrideField } from '../../common/copilotCliConfig.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; @@ -691,8 +691,8 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { */ private _resolveByokSessionConfig(sessionId: string): Promise<{ providers?: NamedProviderConfig[]; models?: ProviderModelConfig[] }> { const rootConfigValue = this._configurationService.getRootValue(platformRootSchema, AgentHostByokModelsEnabledConfigKey); - const enabled = rootConfigValue === true; - this._logService.trace(`[Copilot:${sessionId}] BYOK session configuration enabled: ${enabled} (root config: ${rootConfigValue ?? 'unset'})`); + const { enabled, trace } = resolveByokLmEnablement(rootConfigValue); + this._logService.trace(`[Copilot:${sessionId}] BYOK session configuration ${trace}`); if (!enabled) { return Promise.resolve({}); } From 198bad9aa013c67f1251bcfc0aa64a171c4b3688 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Tue, 18 Aug 2026 11:19:27 -0700 Subject: [PATCH 03/14] Refactor agent host and relauncher components for improved clarity - Simplify agentHostStarter configuration. - Optimize agentService logic and reduce code complexity. - Clean up agentHostMain implementation. - Remove unused code in relauncher contribution and tests. --- .../common/agentHostStarter.config.contribution.ts | 2 +- src/vs/platform/agentHost/common/agentService.ts | 13 +++---------- src/vs/platform/agentHost/node/agentHostMain.ts | 7 ++----- .../relauncher/browser/relauncher.contribution.ts | 6 ------ .../relauncher/test/browser/relauncher.test.ts | 6 +++--- 5 files changed, 9 insertions(+), 25 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index e77d2d89942117..ea4342d284cd0d 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -244,7 +244,7 @@ configurationRegistry.registerConfiguration({ }, [AgentHostByokModelsEnabledSettingId]: { type: 'boolean', - description: nls.localize('chat.agentHost.byokModels.enabled', "When enabled, the agent host wires up the BYOK ('bring your own key') language-model bridge so extension-provided BYOK models can run in agent-host sessions. Changes take effect after the setting is synchronized to the agent host."), + description: nls.localize('chat.agentHost.byokModels.enabled', "When enabled, extension-provided BYOK ('bring your own key') models can run in agent-host sessions. Changes are synchronized to the running agent host and do not require a restart."), default: false, tags: ['experimental', 'advanced'], experiment: { mode: 'startup' }, diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 336a6b053fdfde..2082ba828df939 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -146,16 +146,9 @@ export const AgentHostClaudeAgentEnabledSettingId = 'chat.agentHost.claudeAgent. export const AgentHostCodexAgentEnabledSettingId = 'chat.agentHost.codexAgent.enabled'; /** - * Configuration key controlling whether the agent host *wires up* the BYOK - * ("bring your own key") language-model bridge: the renderer LM handler, the - * reverse-RPC channel, and the per-connection link to the node-side OpenAI - * proxy + bridge registry. When `true` (the default), the renderer's BYOK - * server channel and the per-connection bridge are wired so extension-provided - * BYOK models are reachable from agent-host sessions. When `false`, the proxy - * and registry are still constructed but stay inert — the BYOK server channel - * and the per-connection bridge are not wired, so the registry stays empty and - * extension-provided BYOK models are never reachable from agent-host sessions. - * The agent host process must be restarted for changes to take effect. + * Configuration key controlling whether extension-provided BYOK ("bring your + * own key") models are published and included in new agent-host sessions. + * Changes are synchronized to the running agent host. */ export const AgentHostByokModelsEnabledSettingId = 'chat.agentHost.byokModels.enabled'; diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 7d05020ec33bd5..35064c1126561b 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -197,11 +197,8 @@ async function startAgentHost(): Promise { sdkDownloadProgress = agentSdkDownloader.onDidDownloadProgress; const claudeAgentSdkService = instantiationService.createInstance(ClaudeAgentSdkService); diServices.set(IClaudeAgentSdkService, claudeAgentSdkService); - // BYOK language-model proxy + bridge registry. Always registered so the - // session launcher can inject them, but BYOK *use* is gated: the - // per-connection bridge below (and the renderer's server channel) are only - // wired when `chat.agentHost.byokModels.enabled` is on, so the registry - // stays empty and the proxy never binds when the feature is off. + // BYOK infrastructure is always wired; synchronized root config gates model + // publication and per-session provider configuration. byokLmBridgeRegistry = new ByokLmBridgeRegistry(); diServices.set(IByokLmBridgeRegistry, byokLmBridgeRegistry); const byokLmProxyService = disposables.add(instantiationService.createInstance(ByokLmProxyService)); diff --git a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts index 684807144e9205..54af76c3378504 100644 --- a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts +++ b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts @@ -36,7 +36,6 @@ interface IConfiguration extends IWindowsConfiguration { enabled?: boolean; claudeAgent?: { enabled?: boolean }; codexAgent?: { enabled?: boolean }; - byokModels?: { enabled?: boolean }; otel?: { enabled?: boolean; exporterType?: string; @@ -70,7 +69,6 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo 'telemetry.feedback.enabled', 'chat.extensionUnification.enabled', 'chat.agentHost.claudeAgent.enabled', - 'chat.agentHost.byokModels.enabled', 'chat.editor.codex.preferAgentHost', 'chat.agentHost.otel.enabled', 'chat.agentHost.otel.exporterType', @@ -95,7 +93,6 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo private readonly telemetryFeedbackEnabled = new ChangeObserver('boolean'); private readonly extensionUnificationEnabled = new ChangeObserver('boolean'); private readonly agentHostClaudeAgentEnabled = new ChangeObserver('boolean'); - private readonly agentHostByokModelsEnabled = new ChangeObserver('boolean'); private readonly editorCodexPreferAgentHost = new ChangeObserver('boolean'); private readonly agentHostOTelEnabled = new ChangeObserver('boolean'); private readonly agentHostOTelExporterType = new ChangeObserver('string'); @@ -195,9 +192,6 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo // Extension Unification (only when turning on) processChanged(this.extensionUnificationEnabled.handleChange(config.chat?.extensionUnification?.enabled) && config.chat?.extensionUnification?.enabled === true); - // Agent Host - processChanged(this.agentHostByokModelsEnabled.handleChange(config.chat?.agentHost?.byokModels?.enabled)); - // Agent provider registration and implementation preferences are read at spawn. processChanged(this.agentHostClaudeAgentEnabled.handleChange(config.chat?.agentHost?.claudeAgent?.enabled)); processChanged(this.editorCodexPreferAgentHost.handleChange(config.chat?.editor?.codex?.preferAgentHost)); diff --git a/src/vs/workbench/contrib/relauncher/test/browser/relauncher.test.ts b/src/vs/workbench/contrib/relauncher/test/browser/relauncher.test.ts index 1d641f0410c7e6..07bb28f52286cd 100644 --- a/src/vs/workbench/contrib/relauncher/test/browser/relauncher.test.ts +++ b/src/vs/workbench/contrib/relauncher/test/browser/relauncher.test.ts @@ -92,15 +92,15 @@ suite('SettingsChangeRelauncher', () => { assert.strictEqual(restartCount, 0, 'should not restart'); }); - test('prompts to restart when chat.agentHost.byokModels.enabled changes', async () => { + test('does not prompt to restart when chat.agentHost.byokModels.enabled changes', async () => { confirmResult = true; await changeSetting( 'chat.agentHost.byokModels.enabled', () => ({ chat: { agentHost: { byokModels: { enabled: true } } } }), c => c.chat.agentHost.byokModels.enabled = false); - assert.strictEqual(confirmCount, 1, 'should prompt to restart'); - assert.strictEqual(restartCount, 1, 'should restart when confirmed'); + assert.strictEqual(confirmCount, 0, 'should not prompt to restart'); + assert.strictEqual(restartCount, 0, 'should not restart'); }); test('prompts to restart when chat.editor.codex.preferAgentHost changes', async () => { From b292bcff4012d6ad1565e4e92f082ed4f35942f4 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 18 Aug 2026 18:36:03 -0400 Subject: [PATCH 04/14] Add agent session orchestration fan-in (#331518) --- src/vs/platform/agentHost/AGENTS.md | 26 +++ .../agentHost/common/state/sessionState.ts | 56 ++++++ .../agentHost/node/agentHostStateManager.ts | 5 + .../platform/agentHost/node/agentService.ts | 29 +++- .../agentHost/node/sessionCoordination.ts | 159 ++++++++++++++++++ .../node/shared/sessionServerTools.ts | 113 +++++++++++-- .../test/node/agentHostStateManager.test.ts | 42 ++++- .../agentHost/test/node/agentService.test.ts | 86 +++++++++- ...Copilot_prompts_claude-haiku-4_5.prompt.md | 30 +++- ..._Copilot_prompts_claude-opus-4_5.prompt.md | 30 +++- ..._Copilot_prompts_claude-opus-4_6.prompt.md | 30 +++- ..._Copilot_prompts_claude-opus-4_7.prompt.md | 30 +++- ..._Copilot_prompts_claude-opus-4_8.prompt.md | 30 +++- ...___Copilot_prompts_claude-opus-5.prompt.md | 30 +++- ...opilot_prompts_claude-sonnet-4_5.prompt.md | 30 +++- ...opilot_prompts_claude-sonnet-4_6.prompt.md | 30 +++- ..._Copilot_prompts_claude-sonnet-5.prompt.md | 30 +++- ...Copilot_prompts_gemini-2_0-flash.prompt.md | 30 +++- ...2E___Copilot_prompts_gpt-5-codex.prompt.md | 30 +++- ...E2E___Copilot_prompts_gpt-5-mini.prompt.md | 30 +++- ...Host_E2E___Copilot_prompts_gpt-5.prompt.md | 30 +++- ...pilot_prompts_gpt-5_1-codex-mini.prompt.md | 30 +++- ...___Copilot_prompts_gpt-5_1-codex.prompt.md | 30 +++- ...st_E2E___Copilot_prompts_gpt-5_1.prompt.md | 30 +++- ...E___Copilot_prompts_gpt-5_6-luna.prompt.md | 30 +++- ...2E___Copilot_prompts_gpt-5_6-sol.prompt.md | 30 +++- ...___Copilot_prompts_gpt-5_6-terra.prompt.md | 30 +++- .../test/node/e2e/suites/serverToolsSuite.ts | 9 + .../test/node/sessionCoordination.test.ts | 70 ++++++++ .../test/node/sessionServerTools.test.ts | 130 +++++++++++++- 30 files changed, 1217 insertions(+), 78 deletions(-) create mode 100644 src/vs/platform/agentHost/node/sessionCoordination.ts create mode 100644 src/vs/platform/agentHost/test/node/sessionCoordination.test.ts diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index 052186b1b35f3f..e74a0fc658f77f 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -227,6 +227,32 @@ Provider-private discovery helpers name their concrete source: Claude uses `_lis For every provider, migration and discovery partition the same native catalog: migration returns known entries as plain metadata, while discovery emits unknown entries with provider-classified provenance (external for Claude and Codex, and for Copilot everything except an unknown legacy extension-host chat, which is emitted as internal and adoptable). The partition is not quite exhaustive for Copilot: a chat whose session database exists but holds none of the metadata keys `listChatsToMigrate` requires is rejected by both halves. That is deliberate — an empty database is how Agent Host records a chat it already touched — and is asserted by `copilotAgent.test.ts`'s "does not discover an extension-host chat with an empty Agent Host database". Central `agent-host.db` remains the durable provenance authority. +### Server-tool orchestration relationships + +Treat a session as the user-visible unit of work. The `create_chat` tool is the +default for parallel subtasks that should share one workspace, lifecycle, and +aggregate diff. Use `create_session` only when a delegated task needs an +independent workspace, worktree or branch, provider, or lifecycle. + +Sessions created by the `create_session` server tool record provider-neutral +orchestration metadata in the session summary `_meta` bag. The metadata names +the creating session separately from the hierarchy parent, plus an optional +label, whether the child may coordinate with its creator, and an optional +idle-notification policy. Keeping creator identity separate from hierarchy +placement preserves notification routing if parent relationships evolve. +`list_sessions` projects and filters hierarchy metadata without involving +provider harnesses. + +`SessionCoordinationService` owns idle-notification status observation, +per-child sequencing, creator restoration, and delivery. Its durable +`creatorNotificationState` is `waitingForCompletion` after work starts and +`notified` after the next input-needed/idle/error transition wakes the creator. +The `always` policy returns to `waitingForCompletion` on the next work cycle. A +busy creator default chat receives a queued system notification rather than a +new active turn, so concurrent child completion cannot overwrite creator work. +The existing pending-message drain starts that queued notification when the +creator chat becomes idle. + `list_sessions` exposes a session's configured project URI separately from its primary and additional working directories. `create_session` accepts those URIs directly and can resolve a unique project display name, preferring the diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index f1df35019c4685..4d05f64cd3e665 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -1711,6 +1711,62 @@ export function withSessionSpawnDepth(meta: SessionSummaryMeta | undefined, dept return { ...meta, [SESSION_META_SPAWN_DEPTH_KEY]: depth }; } +export type SessionIdleNotification = 'once' | 'always'; +export type SessionCreatorNotificationState = 'waitingForCompletion' | 'notified'; + +export interface ISessionOrchestration { + readonly parentSession: string; + readonly creatorSession: string; + readonly label?: string; + readonly coordinateWithCreator: boolean; + readonly notifyOnIdle?: SessionIdleNotification; + /** Durable delivery state used to wait for a work outcome and deduplicate replayed statuses. */ + readonly creatorNotificationState?: SessionCreatorNotificationState; +} + +export const SESSION_META_ORCHESTRATION_KEY = 'agentHost/orchestration'; +export const AH_META_ORCHESTRATION_DB_KEY = 'agentHost.orchestration'; + +export function readSessionOrchestration(meta: SessionSummaryMeta | undefined): ISessionOrchestration | undefined { + const value = meta?.[SESSION_META_ORCHESTRATION_KEY]; + if (!value || typeof value !== 'object') { + return undefined; + } + const candidate = value as { [key: string]: unknown }; + if (typeof candidate.parentSession !== 'string' || typeof candidate.coordinateWithCreator !== 'boolean') { + return undefined; + } + const creatorSession = typeof candidate.creatorSession === 'string' ? candidate.creatorSession : candidate.parentSession; + const label = typeof candidate.label === 'string' ? candidate.label : undefined; + const notifyOnIdle = candidate.notifyOnIdle === 'once' || candidate.notifyOnIdle === 'always' ? candidate.notifyOnIdle : undefined; + const creatorNotificationState = candidate.creatorNotificationState === 'waitingForCompletion' || candidate.creatorNotificationState === 'notified' + ? candidate.creatorNotificationState + : undefined; + return { + parentSession: candidate.parentSession, + creatorSession, + coordinateWithCreator: candidate.coordinateWithCreator, + ...(label !== undefined ? { label } : {}), + ...(notifyOnIdle !== undefined ? { notifyOnIdle } : {}), + ...(creatorNotificationState !== undefined ? { creatorNotificationState } : {}), + }; +} + +export function parseSessionOrchestration(value: string | undefined): ISessionOrchestration | undefined { + if (value === undefined) { + return undefined; + } + try { + return readSessionOrchestration({ [SESSION_META_ORCHESTRATION_KEY]: JSON.parse(value) }); + } catch { + return undefined; + } +} + +export function withSessionOrchestration(meta: SessionSummaryMeta | undefined, orchestration: ISessionOrchestration): SessionSummaryMeta { + return { ...meta, [SESSION_META_ORCHESTRATION_KEY]: orchestration }; +} + /** * Reserved key under {@link SessionSummaryMeta} marking a session as * workspace-less: a session with no workspace/folder binding (surfaced in the diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 5cdcaa9cd30712..2015c077fa2e46 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -269,6 +269,8 @@ export class AgentHostStateManager extends Disposable { readonly onDidEmitNotification: Event = this._onDidEmitNotification.event; private readonly _onDidChangeSessionActiveTurn = this._register(new Emitter<{ session: string; active: boolean }>()); readonly onDidChangeSessionActiveTurn: Event<{ session: string; active: boolean }> = this._onDidChangeSessionActiveTurn.event; + private readonly _onDidChangeSessionStatus = this._register(new Emitter<{ session: string; status: SessionStatus }>()); + readonly onDidChangeSessionStatus: Event<{ session: string; status: SessionStatus }> = this._onDidChangeSessionStatus.event; private readonly _onDidRemoveSession = this._register(new Emitter()); readonly onDidRemoveSession: Event = this._onDidRemoveSession.event; @@ -1706,6 +1708,9 @@ export class AgentHostStateManager extends Disposable { ...(statusChanged ? { status: newStatus } : undefined), ...(activityChanged ? { activity: aggregate.activity } : undefined), }; + if (statusChanged) { + this._onDidChangeSessionStatus.fire({ session: sessionKey, status: newStatus }); + } // Roll the aggregated `modifiedAt` into the catalog-only timestamp. const newModifiedAt = aggregate.modifiedAt !== undefined ? new Date(aggregate.modifiedAt).toISOString() : undefined; diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 11f8d6b7e91d8f..69e87edfc579ab 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -37,7 +37,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, readSessionSpawnDepth, withSessionSpawnDepth, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { IProductService } from '../../product/common/productService.js'; import { buildBoundedSideChatSourceContext, getSideChatPartialResponse } from './agentPeerChats.js'; @@ -95,6 +95,7 @@ import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetrySer import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; import { AgentHostStorageService, IAgentHostStorageService } from './agentHostStorageService.js'; +import { SessionCoordinationService } from './sessionCoordination.js'; import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; import { GitHubService, IGitHubService } from '../../github/common/githubService.js'; import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js'; @@ -332,6 +333,7 @@ export class AgentService extends Disposable implements IAgentService { /** Authoritative state manager for the sessions process protocol. */ private readonly _stateManager: AgentHostStateManager; + private readonly _sessionCoordination: SessionCoordinationService; private readonly _managedSettingsService = this._register(new AgentHostManagedSettingsService()); /** @@ -589,7 +591,6 @@ export class AgentService extends Disposable implements IAgentService { this._queueSessionListReconciliation(); } })); - // Build a local instantiation scope so downstream components can // consume {@link IAgentConfigurationService} (and later {@link ILogService}) // via DI rather than being plumbed plain-class references. @@ -781,6 +782,16 @@ export class AgentService extends Disposable implements IAgentService { void this._gitStateService.attachSessionGitHubReferences(session.toString(), text); }, })); + this._sessionCoordination = this._register(new SessionCoordinationService( + this._stateManager, + this._sessionDataService, + this._logService, + { + getSessionMetadata: session => this._getSessionMetadata(session), + restoreSession: session => this.restoreSession(session), + handleAction: (chat, action) => this._sideEffects.handleAction(chat, action), + }, + )); // Server-side tools, executed in-process against each session's own // state. The set of groups (and their display) is the single source of @@ -1091,6 +1102,7 @@ export class AgentService extends Disposable implements IAgentService { type: ActionType.SessionMetaChanged, _meta: withSessionSpawnDepth(this._stateManager.getSessionSummary(session.toString())?._meta, depth), }), + setSessionOrchestration: (session, orchestration) => this._sessionCoordination.setOrchestration(session.toString(), orchestration), }; } @@ -1688,8 +1700,8 @@ export class AgentService extends Disposable implements IAgentService { const sessionStr = s.session.toString(); const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr); const metadataKeys: Record = changesetKeys - ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } - : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; + ? { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } + : { customTitle: true, [AH_META_IS_READ_DB_KEY]: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_ORCHESTRATION_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, [CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS }; const m = await ref.object.getMetadataObject(metadataKeys); // This session is an internal peer-chat backing (e.g. a // Claude peer chat's SDK session, enumerated by the agent's @@ -1711,6 +1723,10 @@ export class AgentService extends Disposable implements IAgentService { if (persistedArchived !== undefined) { updated = { ...updated, status: withSessionStatusFlag(updated.status ?? SessionStatus.Idle, SessionStatus.IsArchived, persistedArchived === 'true') }; } + const orchestration = parseSessionOrchestration(m[AH_META_ORCHESTRATION_DB_KEY]); + if (orchestration) { + updated = { ...updated, _meta: withSessionOrchestration(updated._meta, orchestration) }; + } if (m[META_GIT_STATE]) { try { const gitState = JSON.parse(m[META_GIT_STATE]) as ISessionGitState; @@ -4559,6 +4575,7 @@ export class AgentService extends Disposable implements IAgentService { [AH_META_IS_DONE_DB_KEY]: true, configValues: true, [AH_META_WORKSPACELESS_DB_KEY]: true, + [AH_META_ORCHESTRATION_DB_KEY]: true, [SESSION_META_MULTI_ROOT_KEY]: true, [SESSION_META_FOLDER_PICKER_KEY]: true, ...GIT_DB_METADATA_KEYS, @@ -4618,6 +4635,10 @@ export class AgentService extends Disposable implements IAgentService { if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { sessionMetadata = withSessionWorkspaceless(sessionMetadata, m[AH_META_WORKSPACELESS_DB_KEY] === 'true'); } + const orchestration = parseSessionOrchestration(m[AH_META_ORCHESTRATION_DB_KEY]); + if (orchestration) { + sessionMetadata = withSessionOrchestration(sessionMetadata, orchestration); + } sessionMetadata = withSessionMultiRootMetadata(sessionMetadata, parseSessionMultiRootMetadata(m[SESSION_META_MULTI_ROOT_KEY])); sessionMetadata = withSessionFolderPickerDecision(sessionMetadata, parseSessionFolderPickerDecision(m[SESSION_META_FOLDER_PICKER_KEY])); diff --git a/src/vs/platform/agentHost/node/sessionCoordination.ts b/src/vs/platform/agentHost/node/sessionCoordination.ts new file mode 100644 index 00000000000000..eb12cf10a3da63 --- /dev/null +++ b/src/vs/platform/agentHost/node/sessionCoordination.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toErrorMessage } from '../../../base/common/errorMessage.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { ILogService } from '../../log/common/log.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { ActionType, type ChatTurnStartedAction } from '../common/state/sessionActions.js'; +import { MessageKind, PendingMessageKind, AH_META_ORCHESTRATION_DB_KEY, buildDefaultChatUri, readSessionOrchestration, type ISessionOrchestration, SessionStatus, withSessionOrchestration } from '../common/state/sessionState.js'; +import { type Message } from '../common/state/protocol/state.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; +import { persistSessionMetadataValues } from './shared/persistSessionMetadata.js'; + +export interface ISessionCoordinationTransition { + readonly orchestration?: ISessionOrchestration; + readonly notify: boolean; +} + +export function transitionSessionCoordination(status: SessionStatus, orchestration: ISessionOrchestration): ISessionCoordinationTransition { + if (!orchestration.notifyOnIdle) { + return { notify: false }; + } + + const inputNeeded = (status & SessionStatus.InputNeeded) === SessionStatus.InputNeeded; + const inProgress = !inputNeeded && (status & SessionStatus.InProgress) === SessionStatus.InProgress + && (status & SessionStatus.Error) !== SessionStatus.Error; + if (inProgress) { + if (orchestration.creatorNotificationState !== 'waitingForCompletion' + && !(orchestration.notifyOnIdle === 'once' && orchestration.creatorNotificationState === 'notified')) { + return { orchestration: { ...orchestration, creatorNotificationState: 'waitingForCompletion' }, notify: false }; + } + return { notify: false }; + } + + const completed = inputNeeded + || (status & SessionStatus.Idle) === SessionStatus.Idle + || (status & SessionStatus.Error) === SessionStatus.Error; + if (!completed || orchestration.creatorNotificationState !== 'waitingForCompletion') { + return { notify: false }; + } + + return { + orchestration: { + ...orchestration, + creatorNotificationState: 'notified', + }, + notify: true, + }; +} + +export interface ISessionCoordinationDelegate { + readonly getSessionMetadata: (session: URI) => Promise<{ readonly status?: SessionStatus } | undefined>; + readonly restoreSession: (session: URI) => Promise; + readonly handleAction: (chat: string, action: ChatTurnStartedAction) => void; +} + +export class SessionCoordinationService extends Disposable { + + private readonly _queues = new Map>(); + + constructor( + private readonly _stateManager: AgentHostStateManager, + private readonly _sessionDataService: ISessionDataService, + private readonly _logService: ILogService, + private readonly _delegate: ISessionCoordinationDelegate, + ) { + super(); + this._register(this._stateManager.onDidChangeSessionStatus(({ session, status }) => this._queueStatusChange(session, status))); + } + + async setOrchestration(session: string, orchestration: ISessionOrchestration): Promise { + await persistSessionMetadataValues(this._sessionDataService, session, { + [AH_META_ORCHESTRATION_DB_KEY]: JSON.stringify(orchestration), + }); + this._stateManager.setSessionMeta(session, withSessionOrchestration(this._stateManager.getSessionSummary(session)?._meta, orchestration)); + } + + async handleStatusChange(session: string, status: SessionStatus): Promise { + const summary = this._stateManager.getSessionSummary(session); + const orchestration = readSessionOrchestration(summary?._meta); + if (!summary || !orchestration?.notifyOnIdle) { + return; + } + + const transition = transitionSessionCoordination(status, orchestration); + if (!transition.notify) { + if (transition.orchestration) { + await this.setOrchestration(session, transition.orchestration); + } + return; + } + + const creator = URI.parse(orchestration.creatorSession); + const creatorMetadata = await this._delegate.getSessionMetadata(creator); + if (!creatorMetadata || (creatorMetadata.status !== undefined && (creatorMetadata.status & SessionStatus.IsArchived) === SessionStatus.IsArchived)) { + return; + } + if (!this._stateManager.getSessionState(creator.toString())) { + try { + await this._delegate.restoreSession(creator); + } catch (error) { + this._logService.error(`[SessionCoordinationService] Failed to restore creator session ${creator.toString()} for child notification: ${toErrorMessage(error)}`); + return; + } + } + const creatorSummary = this._stateManager.getSessionSummary(creator.toString()); + if (!creatorSummary || (creatorSummary.status & SessionStatus.IsArchived) === SessionStatus.IsArchived) { + return; + } + + const outcome = (status & SessionStatus.InputNeeded) === SessionStatus.InputNeeded + ? 'needs input' + : (status & SessionStatus.Error) === SessionStatus.Error ? 'encountered an error' : 'became idle'; + const childName = orchestration.label ? `${orchestration.label} (${session})` : session; + this._startPrompt(creator, `Child session ${childName} ${outcome}. Use get_session_context with session "${session}" to inspect its result.`); + if (transition.orchestration) { + await this.setOrchestration(session, transition.orchestration); + } + } + + private _queueStatusChange(session: string, status: SessionStatus): void { + const previous = this._queues.get(session) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(() => this.handleStatusChange(session, status)); + this._queues.set(session, next); + void next.catch(error => { + this._logService.error(`[SessionCoordinationService] Failed to coordinate child session ${session}: ${toErrorMessage(error)}`); + }).finally(() => { + if (this._queues.get(session) === next) { + this._queues.delete(session); + } + }); + } + + private _startPrompt(creator: URI, prompt: string): void { + const chat = buildDefaultChatUri(creator); + const message: Message = { text: prompt, origin: { kind: MessageKind.SystemNotification } }; + if (this._stateManager.getActiveTurnId(chat)) { + this._stateManager.dispatchServerAction(chat, { + type: ActionType.ChatPendingMessageSet, + kind: PendingMessageKind.Queued, + id: generateUuid(), + message, + }); + return; + } + const action: ChatTurnStartedAction = { + type: ActionType.ChatTurnStarted, + turnId: generateUuid(), + startedAt: new Date().toISOString(), + message, + }; + this._stateManager.dispatchServerAction(chat, action); + this._delegate.handleAction(chat, action); + } +} diff --git a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts index ccb8f56aca0f19..a435fc24175a97 100644 --- a/src/vs/platform/agentHost/node/shared/sessionServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/sessionServerTools.ts @@ -9,7 +9,7 @@ import { isEqual } from '../../../../base/common/resources.js'; import { localize } from '../../../../nls.js'; import { AgentSession, type AgentProvider, type IAgentCreateSessionConfig, type IAgentModelInfo, type IAgentSessionMetadata } from '../../common/agent.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; -import { buildChatUri, buildDefaultChatUri, getInlineToolInput, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionGitState, readSessionGitHubState, ResponsePartKind, ToolCallStatus, TurnState, type Message, type ModelSelection, type ResponsePart, type ToolCallState, type ToolDefinition, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, getInlineToolInput, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionGitState, readSessionGitHubState, readSessionOrchestration, ResponsePartKind, ToolCallStatus, TurnState, type ISessionOrchestration, type Message, type ModelSelection, type ResponsePart, type SessionIdleNotification, type ToolCallState, type ToolDefinition, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; import { buildOpenSessionLinkUri, parseOpenSessionLinkChatId, parseOpenSessionLinkUri } from '../../common/openSessionLink.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; import { generateUuid } from '../../../../base/common/uuid.js'; @@ -57,15 +57,20 @@ const listSessionsInputSchema: ToolDefinition['inputSchema'] = { includeArchived: { type: 'boolean', description: 'Whether to include archived sessions. Defaults to false; set true to also return archived sessions.' }, createdAfter: { type: 'string', description: 'Only return sessions created at or after this time (ISO-8601 timestamp, e.g. `2025-01-31T00:00:00Z`).' }, createdBefore: { type: 'string', description: 'Only return sessions created at or before this time (ISO-8601 timestamp).' }, + parentSession: { type: 'string', description: 'Only return sessions created by this parent session URI or open-session link.' }, + label: { type: 'string', description: 'Only return sessions with this orchestration label.' }, }, }; const createSessionInputSchema: ToolDefinition['inputSchema'] = { type: 'object', properties: { - workspace: { type: 'string', description: 'Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session.' }, + workspace: { type: 'string', description: 'Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session\'s workspace and changes.' }, prompt: { type: 'string', description: 'Initial prompt to send to the new session.' }, model: { type: 'string', description: 'Optional model ID or display name. Defaults to the current chat\'s model.' }, + coordinateWithCreator: { type: 'boolean', description: 'Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true.' }, + notifyOnIdle: { type: 'string', enum: ['once', 'always'], description: 'Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle.' }, + label: { type: 'string', description: 'Optional label used to group and filter related child sessions.' }, }, required: ['workspace', 'prompt'], }; @@ -149,14 +154,14 @@ export const sessionServerToolDefinitions: ToolDefinition[] = [ { name: SessionServerToolName.CreateSession, title: 'Create Session', - description: 'Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button.', + description: 'Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button.', inputSchema: createSessionInputSchema, annotations: { readOnlyHint: false }, }, { name: SessionServerToolName.CreateChat, title: 'Create Chat', - description: 'Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat\'s model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button.', + description: 'Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session\'s workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat\'s model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button.', inputSchema: createChatInputSchema, annotations: { readOnlyHint: false }, }, @@ -200,12 +205,18 @@ interface ICreateSessionArgs { readonly workspace?: unknown; readonly prompt?: unknown; readonly model?: unknown; + readonly coordinateWithCreator?: unknown; + readonly notifyOnIdle?: unknown; + readonly label?: unknown; } export interface IResolvedCreateSessionArgs { readonly workspace: URI; readonly prompt: string; readonly model?: IAgentModelInfo; + readonly coordinateWithCreator: boolean; + readonly notifyOnIdle?: SessionIdleNotification; + readonly label?: string; } /** Minimal dependency surface needed by the session server-tool group. */ @@ -227,6 +238,7 @@ export interface ISessionServerToolAccessor { readonly getSessionSpawnDepth: (session: URI) => number; /** Records the spawn depth of a freshly-created session so its own `create_session` calls can enforce the recursion limit. */ readonly setSessionSpawnDepth: (session: URI, depth: number) => void; + readonly setSessionOrchestration: (session: URI, orchestration: ISessionOrchestration) => Promise; } export interface IRenameTitleResult { @@ -293,6 +305,10 @@ interface ISerializedSession { }[]; readonly git?: ISerializedGitState; readonly github?: ISerializedGitHubState; + readonly parentSession?: string; + readonly creator?: string; + readonly label?: string; + readonly notifyOnIdle?: SessionIdleNotification; } function getRequiredString(value: unknown, field: string, toolName: string): string { @@ -415,10 +431,22 @@ export function getCreateSessionArgs(rawArgs: unknown, sessions: readonly IAgent const workspace = getRequiredString(args.workspace, 'workspace', SessionServerToolName.CreateSession); const prompt = getRequiredString(args.prompt, 'prompt', SessionServerToolName.CreateSession); const modelName = getOptionalString(args.model, 'model', SessionServerToolName.CreateSession); + const coordinateWithCreator = getOptionalBoolean(args.coordinateWithCreator, 'coordinateWithCreator', SessionServerToolName.CreateSession) ?? true; + const label = getOptionalString(args.label, 'label', SessionServerToolName.CreateSession); + let notifyOnIdle: SessionIdleNotification | undefined; + if (args.notifyOnIdle !== undefined) { + if (args.notifyOnIdle !== 'once' && args.notifyOnIdle !== 'always') { + throw new Error(`Invalid ${SessionServerToolName.CreateSession} input: notifyOnIdle must be once or always.`); + } + notifyOnIdle = args.notifyOnIdle; + } return { workspace: resolveWorkspace(workspace, sessions), prompt, model: resolveModel(modelName, models), + coordinateWithCreator, + ...(notifyOnIdle !== undefined ? { notifyOnIdle } : {}), + ...(label !== undefined ? { label } : {}), }; } @@ -475,6 +503,8 @@ export interface IListSessionsArgs { readonly createdAfter?: number; /** Upper bound on session creation time, in epoch milliseconds. */ readonly createdBefore?: number; + readonly parentSession?: string; + readonly label?: string; } function getOptionalBoolean(value: unknown, field: string, toolName: string): boolean | undefined { @@ -503,7 +533,7 @@ function getOptionalTimestamp(value: unknown, field: string, toolName: string): /** Validates and normalizes the optional `list_sessions` filter arguments. */ export function getListSessionsArgs(rawArgs: unknown): IListSessionsArgs { - const args = (rawArgs ?? {}) as { session?: unknown; status?: unknown; workspace?: unknown; withChanges?: unknown; unread?: unknown; withPullRequest?: unknown; includeArchived?: unknown; createdAfter?: unknown; createdBefore?: unknown }; + const args = (rawArgs ?? {}) as { session?: unknown; status?: unknown; workspace?: unknown; withChanges?: unknown; unread?: unknown; withPullRequest?: unknown; includeArchived?: unknown; createdAfter?: unknown; createdBefore?: unknown; parentSession?: unknown; label?: unknown }; let status: Set | undefined; if (args.status !== undefined) { @@ -527,6 +557,8 @@ export function getListSessionsArgs(rawArgs: unknown): IListSessionsArgs { includeArchived: getOptionalBoolean(args.includeArchived, 'includeArchived', SessionServerToolName.ListSessions), createdAfter: getOptionalTimestamp(args.createdAfter, 'createdAfter', SessionServerToolName.ListSessions), createdBefore: getOptionalTimestamp(args.createdBefore, 'createdBefore', SessionServerToolName.ListSessions), + parentSession: getOptionalString(args.parentSession, 'parentSession', SessionServerToolName.ListSessions), + label: getOptionalString(args.label, 'label', SessionServerToolName.ListSessions), }; } @@ -560,14 +592,33 @@ function sessionMatchesWorkspace(session: IAgentSessionMetadata, workspace: stri } /** Applies the {@link IListSessionsArgs} filters to a set of sessions. */ -export function filterSessions(sessions: readonly IAgentSessionMetadata[], args: IListSessionsArgs): readonly IAgentSessionMetadata[] { +export function filterSessions(sessions: readonly IAgentSessionMetadata[], args: IListSessionsArgs, viewerSession?: string): readonly IAgentSessionMetadata[] { // A direct `session` lookup returns just that session, bypassing the other // filters (including the default archived exclusion). if (args.session !== undefined) { const target = parseOpenSessionLinkUri(args.session)?.toString() ?? args.session; return sessions.filter(session => session.session.toString() === target); } + const requestedParent = args.parentSession !== undefined + ? parseOpenSessionLinkUri(args.parentSession)?.toString() ?? args.parentSession + : undefined; + const viewerCanSeeRequestedParent = requestedParent === undefined || viewerSession === undefined || viewerSession === requestedParent + || sessions.some(session => { + const orchestration = readSessionOrchestration(session._meta); + return session.session.toString() === viewerSession + && orchestration?.parentSession === requestedParent + && orchestration.coordinateWithCreator; + }); return sessions.filter(session => { + const orchestration = readSessionOrchestration(session._meta); + if (requestedParent !== undefined) { + if (!viewerCanSeeRequestedParent || orchestration?.parentSession !== requestedParent) { + return false; + } + } + if (args.label !== undefined && orchestration?.label !== args.label) { + return false; + } if (args.status) { const names = describeSessionStatusNames(session); if (!names.some(name => args.status!.has(name))) { @@ -629,10 +680,17 @@ function serializeGitHubState(session: IAgentSessionMetadata): ISerializedGitHub return Object.keys(result).length > 0 ? result : undefined; } -function serializeSession(session: IAgentSessionMetadata): ISerializedSession { +function serializeSession(session: IAgentSessionMetadata, viewerSession?: string): ISerializedSession { const git = serializeGitState(session); const github = serializeGitHubState(session); const status = describeSessionStatus(session); + const orchestration = readSessionOrchestration(session._meta); + const canSeeParent = orchestration !== undefined && (viewerSession === undefined + || viewerSession === orchestration.parentSession + || (viewerSession === session.session.toString() && orchestration.coordinateWithCreator)); + const canSeeCreator = orchestration !== undefined && orchestration.coordinateWithCreator && (viewerSession === undefined + || viewerSession === orchestration.creatorSession + || viewerSession === session.session.toString()); return { session: session.session.toString(), ...(session.summary !== undefined ? { title: session.summary } : {}), @@ -658,12 +716,18 @@ function serializeSession(session: IAgentSessionMetadata): ISerializedSession { } : {}), ...(git !== undefined ? { git } : {}), ...(github !== undefined ? { github } : {}), + ...(orchestration !== undefined ? { + ...(canSeeParent ? { parentSession: orchestration.parentSession } : {}), + ...(canSeeCreator ? { creator: orchestration.creatorSession } : {}), + ...(orchestration.label !== undefined ? { label: orchestration.label } : {}), + ...(orchestration.notifyOnIdle !== undefined ? { notifyOnIdle: orchestration.notifyOnIdle } : {}), + } : {}), }; } /** Serializes session metadata into the compact tool-result JSON payload. */ -export function serializeSessions(sessions: readonly IAgentSessionMetadata[]): string { - return JSON.stringify({ sessions: sessions.map(serializeSession) }); +export function serializeSessions(sessions: readonly IAgentSessionMetadata[], viewerSession?: string): string { + return JSON.stringify({ sessions: sessions.map(session => serializeSession(session, viewerSession)) }); } export interface ICreateSessionResult { @@ -698,6 +762,15 @@ export async function applyCreateSessionTool(accessor: ISessionServerToolAccesso }; const session = await accessor.createSession(config); accessor.setSessionSpawnDepth(session, parentDepth + 1); + if (currentSession) { + await accessor.setSessionOrchestration(session, { + parentSession: currentSession.toString(), + creatorSession: currentSession.toString(), + coordinateWithCreator: args.coordinateWithCreator, + ...(args.notifyOnIdle !== undefined ? { notifyOnIdle: args.notifyOnIdle } : {}), + ...(args.label !== undefined ? { label: args.label } : {}), + }); + } const chat = URI.parse(buildDefaultChatUri(session)); await accessor.startPrompt(session, chat, args.prompt); return { session: session.toString(), chat: chat.toString(), openLink: buildOpenSessionLinkUri(session) }; @@ -769,11 +842,22 @@ export function getCreateChatArgs(rawArgs: unknown, sessions: readonly IAgentSes return { session, prompt, ...(title !== undefined ? { title } : {}), ...(model !== undefined ? { model } : {}) }; } +function assertCanCoordinateWithTarget(sessions: readonly IAgentSessionMetadata[], source: URI, target: URI, toolName: SessionServerToolName): void { + const sourceMetadata = sessions.find(candidate => candidate.session.toString() === source.toString()); + const orchestration = readSessionOrchestration(sourceMetadata?._meta); + if (orchestration && !orchestration.coordinateWithCreator && orchestration.creatorSession === target.toString()) { + throw new Error(`Invalid ${toolName} input: this session is not allowed to coordinate with its creator.`); + } +} + /** Adds a chat to a session, sends its initial prompt, and returns the created channels. */ export async function applyCreateChatTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, source?: URI): Promise { const sessions = await accessor.listSessions(); const currentSession = source ? currentSessionUri(source.toString()) : undefined; const args = getCreateChatArgs(rawArgs, sessions, accessor.getModels(), currentSession); + if (currentSession) { + assertCanCoordinateWithTarget(sessions, currentSession, args.session, SessionServerToolName.CreateChat); + } const defaults = source ? accessor.getCreationDefaults(source) : undefined; const targetProvider = AgentSession.provider(args.session); const model = args.model !== undefined ? { id: args.model.id } : targetProvider === defaults?.provider ? defaults?.model : undefined; @@ -952,6 +1036,10 @@ export function getSendMessageArgs(rawArgs: unknown, sessions: readonly IAgentSe export async function applySendMessageTool(accessor: ISessionServerToolAccessor, rawArgs: unknown, currentChannel?: ProtocolURI): Promise { const sessions = await accessor.listSessions(); const { session, chat, chatId, message } = getSendMessageArgs(rawArgs, sessions); + if (currentChannel) { + const source = currentSessionUri(currentChannel); + assertCanCoordinateWithTarget(sessions, source, session, SessionServerToolName.SendMessage); + } if (currentChannel && chat.toString() === URI.parse(currentChannel).toString()) { throw new Error(`Invalid ${SessionServerToolName.SendMessage} input: refusing to send a message to the current chat.`); } @@ -1151,7 +1239,7 @@ export function serializeCurrentSession(currentSession: URI, sessions: readonly return JSON.stringify({ session: currentSession.toString(), openLink: buildOpenSessionLinkUri(currentSession), - ...(meta ? serializeSession(meta) : {}), + ...(meta ? serializeSession(meta, currentSession.toString()) : {}), }); } @@ -1266,7 +1354,10 @@ export function createSessionServerToolGroup(accessor?: ISessionServerToolAccess const currentChannel = context.chatUri; switch (toolName) { case SessionServerToolName.ListSessions: - return serializeSessions(filterSessions(await accessor.listSessions(), getListSessionsArgs(rawArgs))); + { + const viewerSession = currentSessionUri(currentChannel).toString(); + return serializeSessions(filterSessions(await accessor.listSessions(), getListSessionsArgs(rawArgs), viewerSession), viewerSession); + } case SessionServerToolName.GetCurrentSession: return serializeCurrentSession(currentSessionUri(currentChannel), await accessor.listSessions()); case SessionServerToolName.CreateSession: { diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index aea52dd4fa4633..90da4f75c61aa6 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; -import { MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; +import { ChatInputQuestionKind, ChatInputRequestPurpose, ChatInputResponseKind, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { buildChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; @@ -1430,6 +1430,7 @@ suite('AgentHostStateManager', () => { startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); + manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', @@ -1468,6 +1469,45 @@ suite('AgentHostStateManager', () => { ); }); + test('session-status event captures every lifecycle transition without debouncing', () => { + manager.createSession(makeSessionSummary()); + const defaultChat = buildDefaultChatUri(sessionUri); + const statuses: SessionStatus[] = []; + disposables.add(manager.onDidChangeSessionStatus(e => statuses.push(e.status & ~(SessionStatus.IsRead | SessionStatus.IsArchived)))); + + manager.dispatchServerAction(defaultChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-default', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'a', origin: { kind: MessageKind.User } }, + }); + manager.dispatchServerAction(defaultChat, { + type: ActionType.ChatInputRequested, + request: { + id: 'request', + purpose: ChatInputRequestPurpose.AskUser, + questions: [{ kind: ChatInputQuestionKind.Text, id: 'question', message: 'Continue?' }], + }, + }); + manager.dispatchServerAction(defaultChat, { + type: ActionType.ChatInputCompleted, + requestId: 'request', + response: ChatInputResponseKind.Accept, + }); + manager.dispatchServerAction(defaultChat, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-default', + duration: 1000, + }); + + assert.deepStrictEqual(statuses, [ + SessionStatus.InProgress, + SessionStatus.InputNeeded, + SessionStatus.InProgress, + SessionStatus.Idle, + ]); + }); + test('removeChat clears a peer chat that is removed mid-turn', () => { manager.createSession(makeSessionSummary()); const defaultChat = buildDefaultChatUri(sessionUri); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index c425b3ed923224..e81ca4c45917a3 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -39,7 +39,7 @@ import { META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../../common/agent import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType } from '../../common/state/sessionActions.js'; -import { AH_META_IS_READ_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_IS_READ_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; @@ -6124,6 +6124,90 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(readSessionMultiRootMetadata(localService.stateManager.getSessionState(sessionResource.toString())?._meta), multiRoot); }); + test('restores persisted orchestration metadata', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + await createAgentSession(copilotAgent); + const sessionResource = (await copilotAgent.listSessions())[0].session; + copilotAgent.sessionMessages = []; + const orchestration = { + parentSession: 'copilot:/parent', + creatorSession: 'copilot:/creator', + coordinateWithCreator: true, + notifyOnIdle: 'always', + } as const; + await db.setMetadata(AH_META_ORCHESTRATION_DB_KEY, JSON.stringify(orchestration)); + + await localService.restoreSession(sessionResource); + + assert.deepStrictEqual(readSessionOrchestration(localService.stateManager.getSessionState(sessionResource.toString())?._meta), orchestration); + }); + + test('does not consume a child notification when its creator cannot be resolved', async () => { + const sessionData = createPerSessionDataService(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + const child = await localService.createSession({ provider: 'copilot' }); + const orchestration: ISessionOrchestration = { + parentSession: 'copilot:/missing', + creatorSession: 'copilot:/missing', + coordinateWithCreator: true, + notifyOnIdle: 'once', + creatorNotificationState: 'waitingForCompletion', + }; + const coordinator = localService as unknown as { + _sessionCoordination: { + setOrchestration(session: string, value: ISessionOrchestration): Promise; + handleStatusChange(session: string, status: SessionStatus): Promise; + }; + }; + await coordinator._sessionCoordination.setOrchestration(child.toString(), orchestration); + + await coordinator._sessionCoordination.handleStatusChange(child.toString(), SessionStatus.Idle); + + assert.deepStrictEqual(readSessionOrchestration(localService.stateManager.getSessionSummary(child.toString())?._meta), orchestration); + }); + + test('restores a cold creator before delivering and consuming a child notification', async () => { + const sessionData = createPerSessionDataService(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionData.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + const creator = await localService.createSession({ provider: 'copilot' }); + const child = await localService.createSession({ provider: 'copilot' }); + const orchestration: ISessionOrchestration = { + parentSession: creator.toString(), + creatorSession: creator.toString(), + coordinateWithCreator: true, + notifyOnIdle: 'once', + creatorNotificationState: 'waitingForCompletion', + }; + const coordinator = localService as unknown as { + _sessionCoordination: { + setOrchestration(session: string, value: ISessionOrchestration): Promise; + handleStatusChange(session: string, status: SessionStatus): Promise; + }; + }; + await coordinator._sessionCoordination.setOrchestration(child.toString(), orchestration); + localService.stateManager.removeSession(creator.toString()); + assert.strictEqual(localService.stateManager.getSessionState(creator.toString()), undefined); + let notificationStarted = false; + disposables.add(localService.stateManager.onDidEmitEnvelope(envelope => { + if (envelope.channel === buildDefaultChatUri(creator) && envelope.action.type === ActionType.ChatTurnStarted && envelope.action.message.origin.kind === MessageKind.SystemNotification) { + notificationStarted = true; + } + })); + + await coordinator._sessionCoordination.handleStatusChange(child.toString(), SessionStatus.Idle); + + assert.ok(localService.stateManager.getSessionState(creator.toString())); + assert.strictEqual(notificationStarted, true); + assert.deepStrictEqual(readSessionOrchestration(localService.stateManager.getSessionSummary(child.toString())?._meta), { + ...orchestration, + creatorNotificationState: 'notified', + }); + }); + test('restores persisted source-control provenance', async () => { const db = new TestSessionDatabase(); const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md index 52211d26406d52..69db2ef7db2424 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-haiku-4_5.prompt.md @@ -1214,6 +1214,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1229,14 +1237,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1245,6 +1253,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1255,7 +1279,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md index 679280c6660b2b..952d5acb71186c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_5.prompt.md @@ -1214,6 +1214,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1229,14 +1237,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1245,6 +1253,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1255,7 +1279,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md index efae17e7c1a104..273f71a0deb012 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_6.prompt.md @@ -1214,6 +1214,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1229,14 +1237,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1245,6 +1253,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1255,7 +1279,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md index 4ef9d1dc41b894..1d47c82ea74bea 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_7.prompt.md @@ -1220,6 +1220,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1235,14 +1243,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1251,6 +1259,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1261,7 +1285,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md index 43b3418fb5c629..58b0d1f85c6f6d 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-4_8.prompt.md @@ -1224,6 +1224,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1239,14 +1247,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1255,6 +1263,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1265,7 +1289,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md index b9d6ad748e8e7c..0364f2e1085d05 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-opus-5.prompt.md @@ -1224,6 +1224,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1239,14 +1247,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1255,6 +1263,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1265,7 +1289,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md index d1210fdc7553b4..a91750c2e6b2d2 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_5.prompt.md @@ -1214,6 +1214,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1229,14 +1237,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1245,6 +1253,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1255,7 +1279,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md index 555278099665e6..97e8f6f9a48171 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-4_6.prompt.md @@ -1214,6 +1214,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1229,14 +1237,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1245,6 +1253,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1255,7 +1279,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md index 08eb88a7241a70..4c2ce08571fd9c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_claude-sonnet-5.prompt.md @@ -1223,6 +1223,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1238,14 +1246,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1254,6 +1262,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1264,7 +1288,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md index 150b7d521a02d4..04ce6abcee5fc5 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gemini-2_0-flash.prompt.md @@ -1259,6 +1259,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1274,14 +1282,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1290,6 +1298,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1300,7 +1324,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md index 90c9b93db52e2a..8daf733f0d6aca 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-codex.prompt.md @@ -1168,6 +1168,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1183,14 +1191,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1199,6 +1207,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1209,7 +1233,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md index c6b673bac18543..342daf3f25d164 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5-mini.prompt.md @@ -1206,6 +1206,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1221,14 +1229,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1237,6 +1245,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1247,7 +1271,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md index 4a04bb95a4ebe2..7ac977c159f513 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5.prompt.md @@ -1220,6 +1220,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1235,14 +1243,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1251,6 +1259,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1261,7 +1285,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md index 843a75e2a68e29..2a8d1a599522c1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex-mini.prompt.md @@ -1168,6 +1168,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1183,14 +1191,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1199,6 +1207,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1209,7 +1233,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md index a500d910636bf5..f30c5d447d38fc 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1-codex.prompt.md @@ -1168,6 +1168,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1183,14 +1191,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1199,6 +1207,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1209,7 +1233,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md index aac467868c99a9..04335f842154ce 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_1.prompt.md @@ -1220,6 +1220,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1235,14 +1243,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1251,6 +1259,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1261,7 +1285,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md index 921eccacdfa04e..5422bf69dd60b8 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-luna.prompt.md @@ -1182,6 +1182,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1197,14 +1205,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1213,6 +1221,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1223,7 +1247,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md index 8d05588d224da3..608fd36c61eadf 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-sol.prompt.md @@ -1182,6 +1182,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1197,14 +1205,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1213,6 +1221,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1223,7 +1247,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md index 8fab5cdc8bd46d..d24cc8c9dcd1c1 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot_prompts_gpt-5_6-terra.prompt.md @@ -1182,6 +1182,14 @@ List sessions and their compact metadata (status, activity, working directory, p "createdBefore": { "type": "string", "description": "Only return sessions created at or before this time (ISO-8601 timestamp)." + }, + "parentSession": { + "type": "string", + "description": "Only return sessions created by this parent session URI or open-session link." + }, + "label": { + "type": "string", + "description": "Only return sessions with this orchestration label." } } } @@ -1197,14 +1205,14 @@ Get metadata and the open link for the session this conversation is running in. ``` #### create_session -Create a session in a workspace and start it with an initial prompt. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. +Create an independently scoped session and start it with an initial prompt. Use this when work needs a separate workspace, worktree or branch, provider, or lifecycle. For parallel subtasks that should share one workspace and aggregate diff, prefer `create_chat`. The UI shows a "Session Created" confirmation with a button to open it, so reply with a single short sentence confirming the session was created and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", "properties": { "workspace": { "type": "string", - "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session." + "description": "Unique project name, project/workspace URI, absolute folder path, or working directory from an existing session. Use `create_chat` instead when the work should share the current session's workspace and changes." }, "prompt": { "type": "string", @@ -1213,6 +1221,22 @@ Create a session in a workspace and start it with an initial prompt. The UI show "model": { "type": "string", "description": "Optional model ID or display name. Defaults to the current chat's model." + }, + "coordinateWithCreator": { + "type": "boolean", + "description": "Allow the child to identify and contact the session that created it. Set false for an independent child that must not send messages or create chats in its creator. Defaults to true." + }, + "notifyOnIdle": { + "type": "string", + "enum": [ + "once", + "always" + ], + "description": "Wake the creator when the child needs input, becomes idle, or errors, either once or after every work cycle." + }, + "label": { + "type": "string", + "description": "Optional label used to group and filter related child sessions." } }, "required": [ @@ -1223,7 +1247,7 @@ Create a session in a workspace and start it with an initial prompt. The UI show ``` #### create_chat -Add a new chat to an existing session and start it with an initial prompt. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. +Add a new chat to an existing session and start it with an initial prompt. Prefer this for parallel subtasks that should remain part of one user-visible unit of work, sharing the session's workspace, lifecycle, and aggregate diff. Omit `session` to add the chat to the current session; otherwise pass a session URI from `list_sessions`. Optionally pass a `model` to use for the chat (defaults to the current chat's model). The UI shows a "Chat Created" confirmation with a button to open the session, so reply with a single short sentence and do NOT print the session URL or tell the user to click a button. ```json { "type": "object", diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts index b29e8a46f2877d..f0f0a3c6196de8 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts @@ -18,6 +18,7 @@ import type { ListSessionsResult, SubscribeResult } from '../../../../common/sta import { ActionType, NotificationType, type ChatToolCallCompleteAction, type ChatToolCallStartAction, type SessionAddedParams, type StateAction } from '../../../../common/state/sessionActions.js'; import { buildDefaultChatUri, + readSessionOrchestration, ROOT_STATE_URI, type AnnotationsState, type ChatState, @@ -865,6 +866,8 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void }, 30_000); const child = (childAdded.params as SessionAddedParams).summary; createdSessions.push(child.resource); + const orchestration = readSessionOrchestration(child._meta); + assert.ok(orchestration, 'child SessionAdded summary should include orchestration metadata'); const childRequest = await retry(async () => { const requests = context.observedModelRequestBodies .map(summarizeAnthropicRequest) @@ -881,11 +884,17 @@ export function defineServerToolsTests(context: IAgentHostE2ETestContext): void provider: child.provider, messages: childState.turns.map(turn => turn.message.text), childRequestModel: childRequest.model, + orchestration, }, { sawPendingConfirmation: true, provider: model.provider, messages: [childPrompt], childRequestModel: model.id, + orchestration: { + parentSession: session.sessionUri, + creatorSession: session.sessionUri, + coordinateWithCreator: true, + }, }); }, supportsProviderModelSessionCreation); diff --git a/src/vs/platform/agentHost/test/node/sessionCoordination.test.ts b/src/vs/platform/agentHost/test/node/sessionCoordination.test.ts new file mode 100644 index 00000000000000..df649abdd846e0 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionCoordination.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { SessionStatus, type ISessionOrchestration } from '../../common/state/sessionState.js'; +import { transitionSessionCoordination } from '../../node/sessionCoordination.js'; + +suite('SessionCoordination', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const base: ISessionOrchestration = { + parentSession: 'copilot:/parent', + creatorSession: 'copilot:/creator', + coordinateWithCreator: true, + notifyOnIdle: 'once', + }; + + test('waits for completion only after work starts', () => { + assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.Idle, base), { notify: false }); + assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.InProgress, base), { + orchestration: { ...base, creatorNotificationState: 'waitingForCompletion' }, + notify: false, + }); + }); + + test('notifies once after idle or error', () => { + const waiting = { ...base, creatorNotificationState: 'waitingForCompletion' as const }; + const expected = { + orchestration: { ...waiting, creatorNotificationState: 'notified' as const }, + notify: true, + }; + assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.Idle, waiting), expected); + assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.Error, waiting), expected); + assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.InProgress, expected.orchestration), { notify: false }); + }); + + test('notifies once when input is needed and deduplicates repeated status', () => { + const waiting = { ...base, creatorNotificationState: 'waitingForCompletion' as const }; + const transition = transitionSessionCoordination(SessionStatus.InputNeeded, waiting); + assert.deepStrictEqual(transition, { + orchestration: { ...waiting, creatorNotificationState: 'notified' }, + notify: true, + }); + assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.InputNeeded, transition.orchestration!), { notify: false }); + }); + + test('always waits for later work to complete', () => { + const always: ISessionOrchestration = { ...base, notifyOnIdle: 'always', creatorNotificationState: 'notified' }; + assert.deepStrictEqual(transitionSessionCoordination(SessionStatus.InProgress, always), { + orchestration: { ...always, creatorNotificationState: 'waitingForCompletion' }, + notify: false, + }); + }); + + test('always captures back-to-back work cycles', () => { + let orchestration: ISessionOrchestration = { ...base, notifyOnIdle: 'always' }; + for (let cycle = 0; cycle < 2; cycle++) { + const started = transitionSessionCoordination(SessionStatus.InProgress, orchestration); + assert.strictEqual(started.notify, false); + orchestration = started.orchestration!; + const completed = transitionSessionCoordination(SessionStatus.Idle, orchestration); + assert.strictEqual(completed.notify, true); + orchestration = completed.orchestration!; + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts index ea1e0215a9e541..959b3098ce8fc3 100644 --- a/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionServerTools.test.ts @@ -11,7 +11,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import type { IAgentCreateSessionConfig, IAgentModelInfo, IAgentSessionMetadata } from '../../common/agent.js'; import { SessionStatus } from '../../common/state/protocol/channels-session/state.js'; -import { buildChatUri, buildDefaultChatUri, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, withSessionGitState, withSessionGitHubState, type ModelSelection, type ResponsePart, type ToolCallState, type Turn } from '../../common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, withSessionGitState, withSessionGitHubState, withSessionOrchestration, type ISessionOrchestration, type ModelSelection, type ResponsePart, type ToolCallState, type Turn } from '../../common/state/sessionState.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; import { AgentServerToolHost } from '../../node/shared/agentServerToolHost.js'; @@ -54,8 +54,9 @@ suite('SessionServerTools', () => { return { sessionUri, chatUri: buildDefaultChatUri(sessionUri) }; } - function createAccessor(overrides?: Partial & { onCreate?: (config: IAgentCreateSessionConfig) => void; onPrompt?: (session: URI, chat: URI, prompt: string) => void; onCreateChat?: (session: URI, chat: URI, options?: { title?: string; model?: ModelSelection }) => void; onRenameChat?: (session: URI, chat: URI, title: string) => void; onDelete?: (session: URI) => void; depths?: Map }): ISessionServerToolAccessor { + function createAccessor(overrides?: Partial & { onCreate?: (config: IAgentCreateSessionConfig) => void; onPrompt?: (session: URI, chat: URI, prompt: string) => void; onCreateChat?: (session: URI, chat: URI, options?: { title?: string; model?: ModelSelection }) => void; onRenameChat?: (session: URI, chat: URI, title: string) => void; onDelete?: (session: URI) => void; depths?: Map; orchestrations?: Map }): ISessionServerToolAccessor { const depths = overrides?.depths ?? new Map(); + const orchestrations = overrides?.orchestrations ?? new Map(); return { isActiveAgentTitleGenerationEnabled: overrides?.isActiveAgentTitleGenerationEnabled ?? (() => true), listSessions: overrides?.listSessions ?? (async () => [sessionMeta('s1', SessionStatus.InProgress, workspace)]), @@ -71,6 +72,7 @@ suite('SessionServerTools', () => { getChatContext: overrides?.getChatContext ?? (async () => undefined), getSessionSpawnDepth: overrides?.getSessionSpawnDepth ?? (session => depths.get(session.toString()) ?? 0), setSessionSpawnDepth: overrides?.setSessionSpawnDepth ?? ((session, depth) => { depths.set(session.toString(), depth); }), + setSessionOrchestration: overrides?.setSessionOrchestration ?? (async (session, orchestration) => { orchestrations.set(session.toString(), orchestration); }), }; } @@ -84,6 +86,7 @@ suite('SessionServerTools', () => { assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.ListSessions), false); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.GetCurrentSession), false); assert.strictEqual(sessionToolRequiresConfirmation(SessionServerToolName.GetSessionContext), false); + assert.strictEqual(sessionServerToolDefinitions.find(def => def.name === SessionServerToolName.CreateSession)?.inputSchema?.properties?.parentSession, undefined); assert.deepStrictEqual(sessionServerToolDefinitions.slice(4, 5).map(def => ({ name: def.name, required: def.inputSchema?.required })), [ { name: SessionServerToolName.RenameChat, required: ['title'] }, ]); @@ -219,6 +222,77 @@ suite('SessionServerTools', () => { }); }); + suite('orchestration metadata', () => { + test('serializeSessions and filters expose orchestration relationships', () => { + const child = { + ...sessionMeta('child', SessionStatus.Idle, workspace), + _meta: withSessionOrchestration(undefined, { + parentSession: 'copilot:/parent', + creatorSession: 'copilot:/creator', + coordinateWithCreator: true, + notifyOnIdle: 'once', + label: 'research', + }), + }; + + assert.deepStrictEqual({ + serialized: JSON.parse(serializeSessions([child])).sessions[0], + byParent: filterSessions([child], getListSessionsArgs({ parentSession: 'agent-host-session://copilot/parent' })).map(session => session.session.toString()), + byLabel: filterSessions([child], getListSessionsArgs({ label: 'research' })).map(session => session.session.toString()), + }, { + serialized: { + session: 'copilot:/child', + title: 'title-child', + status: 'idle', + workingDirectory: workspace.toString(), + parentSession: 'copilot:/parent', + creator: 'copilot:/creator', + label: 'research', + notifyOnIdle: 'once', + }, + byParent: ['copilot:/child'], + byLabel: ['copilot:/child'], + }); + }); + + test('serializeSessions hides a disabled creator relationship from the child', () => { + const child = { + ...sessionMeta('child', SessionStatus.Idle, workspace), + _meta: withSessionOrchestration(undefined, { + parentSession: 'copilot:/parent', + creatorSession: 'copilot:/parent', + coordinateWithCreator: false, + label: 'private-child', + }), + }; + + assert.deepStrictEqual({ + child: JSON.parse(serializeSessions([child], 'copilot:/child')).sessions[0], + parent: JSON.parse(serializeSessions([child], 'copilot:/parent')).sessions[0], + childFilter: filterSessions([child], getListSessionsArgs({ parentSession: 'copilot:/parent' }), 'copilot:/child'), + parentFilter: filterSessions([child], getListSessionsArgs({ parentSession: 'copilot:/parent' }), 'copilot:/parent').map(session => session.session.toString()), + }, { + child: { + session: 'copilot:/child', + title: 'title-child', + status: 'idle', + workingDirectory: workspace.toString(), + label: 'private-child', + }, + parent: { + session: 'copilot:/child', + title: 'title-child', + status: 'idle', + workingDirectory: workspace.toString(), + parentSession: 'copilot:/parent', + label: 'private-child', + }, + childFilter: [], + parentFilter: ['copilot:/child'], + }); + }); + }); + test('serializeSessions preserves remote project roots and multiple working directories', () => { const project = URI.parse('vscode-remote://ssh-remote+example/home/me/app'); const primary = URI.parse('vscode-remote://ssh-remote+example/home/me/app-worktree'); @@ -279,6 +353,7 @@ suite('SessionServerTools', () => { assert.strictEqual(byId.model?.id, 'gpt-4o'); const byName = getCreateSessionArgs({ workspace: workspace.toString(), prompt: 'hi', model: 'GPT-4o' }, sessions, [model]); assert.strictEqual(byName.model?.name, 'GPT-4o'); + assert.strictEqual(byName.coordinateWithCreator, true); }); test('getCreateSessionArgs resolves a unique project name to its configured root', () => { @@ -329,7 +404,8 @@ suite('SessionServerTools', () => { const stateManager = store.add(new AgentHostStateManager(new NullLogService())); let created: IAgentCreateSessionConfig | undefined; let prompted: { chat: URI; prompt: string } | undefined; - const accessor = createAccessor({ onCreate: c => { created = c; }, onPrompt: (_s, chat, prompt) => { prompted = { chat, prompt }; } }); + const orchestrations = new Map(); + const accessor = createAccessor({ orchestrations, onCreate: c => { created = c; }, onPrompt: (_s, chat, prompt) => { prompted = { chat, prompt }; } }); const group = createSessionServerToolGroup(accessor); const text = await group.execute(stateManager, executionContext('copilot:/caller'), SessionServerToolName.CreateSession, { workspace: workspace.toString(), prompt: 'do it', model: 'gpt-4o' }); @@ -339,9 +415,36 @@ suite('SessionServerTools', () => { assert.strictEqual(prompted?.chat.toString(), buildDefaultChatUri(URI.parse('copilot:/new'))); assert.ok(text.includes('agent-host-session://copilot/new'), 'result carries the open-session link for the pill'); assert.ok(!text.includes('copilot:/new'), 'result does not echo the raw backend session URI'); + assert.deepStrictEqual(orchestrations.get('copilot:/new'), { + parentSession: 'copilot:/caller', + creatorSession: 'copilot:/caller', + coordinateWithCreator: true, + }); store.dispose(); }); + test('create_session records explicit orchestration options', async () => { + const orchestrations = new Map(); + const sessions = [sessionMeta('caller', SessionStatus.InProgress, workspace)]; + const accessor = createAccessor({ orchestrations, listSessions: async () => sessions }); + + await applyCreateSessionTool(accessor, { + workspace: workspace.toString(), + prompt: 'do it', + coordinateWithCreator: false, + notifyOnIdle: 'always', + label: 'research', + }, URI.parse('copilot:/caller')); + + assert.deepStrictEqual(orchestrations.get('copilot:/new'), { + parentSession: 'copilot:/caller', + creatorSession: 'copilot:/caller', + coordinateWithCreator: false, + notifyOnIdle: 'always', + label: 'research', + }); + }); + test('create_session inherits the calling chat model and permission config', async () => { const source = URI.parse(buildChatUri('copilot:/caller', 'peer')); let creationSource: URI | undefined; @@ -506,7 +609,7 @@ suite('SessionServerTools', () => { }); test('getListSessionsArgs validates filter input', () => { - assert.deepStrictEqual(getListSessionsArgs({}), { session: undefined, status: undefined, workspace: undefined, withChanges: undefined, unread: undefined, withPullRequest: undefined, includeArchived: undefined, createdAfter: undefined, createdBefore: undefined }); + assert.deepStrictEqual(getListSessionsArgs({}), { session: undefined, status: undefined, workspace: undefined, withChanges: undefined, unread: undefined, withPullRequest: undefined, includeArchived: undefined, createdAfter: undefined, createdBefore: undefined, parentSession: undefined, label: undefined }); assert.throws(() => getListSessionsArgs({ status: ['bogus'] }), /status/); assert.throws(() => getListSessionsArgs({ withChanges: 'yes' }), /withChanges/); assert.throws(() => getListSessionsArgs({ includeArchived: 'no' }), /includeArchived/); @@ -841,6 +944,25 @@ suite('SessionServerTools', () => { // Refuses messaging the exact current chat channel (self-loop guard). await assert.rejects(() => applySendMessageTool(accessor, { session: 'copilot:/s1', message: 'loop' }, currentChannel), /current chat/); + const privateChild = { + ...sessionMeta('child', SessionStatus.Idle, workspace), + _meta: withSessionOrchestration(undefined, { + parentSession: 'copilot:/s2', + creatorSession: 'copilot:/s2', + coordinateWithCreator: false, + }), + }; + const privateAccessor = createAccessor({ + listSessions: async () => [privateChild, sessionMeta('s2', SessionStatus.Idle, workspace)], + }); + await assert.rejects( + () => applySendMessageTool(privateAccessor, { session: 'copilot:/s2', message: 'blocked' }, buildDefaultChatUri('copilot:/child')), + /not allowed to coordinate with its creator/, + ); + await assert.rejects( + () => applyCreateChatTool(privateAccessor, { session: 'copilot:/s2', prompt: 'blocked' }, URI.parse(buildDefaultChatUri('copilot:/child'))), + /not allowed to coordinate with its creator/, + ); // Unknown session and missing session/message are rejected. await assert.rejects(() => applySendMessageTool(accessor, { session: 'copilot:/nope', message: 'x' }, currentChannel), /known session/); assert.throws(() => getSendMessageArgs({ message: 'x' }, []), /session/); From 6b09ba3cfed396cc7b2fa29427410ea27f4b562e Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 18 Aug 2026 18:46:12 -0400 Subject: [PATCH 05/14] Remove Omni Chat (#331522) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d4f3c0a1-6a6f-4c53-9c4e-23818e388c84 --- .../lib/stylelint/vscode-known-variables.json | 4 - .../accessibility/browser/accessibleView.ts | 1 - .../browser/tabbedActionListWidget.ts | 9 +- .../browser/tabbedActionListWidget.test.ts | 33 +- src/vs/platform/native/common/native.ts | 1 - .../electron-main/nativeHostMainService.ts | 4 - .../browser/quickInputController.ts | 8 +- src/vs/sessions/SESSIONS.md | 14 - src/vs/sessions/SESSIONS_LIST.md | 2 - .../sessions/contrib/chat/browser/chatView.ts | 10 +- .../contrib/chat/browser/newChatInput.ts | 3 - .../contrib/chat/browser/newChatVoice.ts | 5 +- .../omniSessionRoutingAdapter.contribution.ts | 610 ----- .../chat/browser/sessionWorkspacePicker.ts | 105 +- .../browser/sessionWorkspacePickerModel.ts | 102 - .../chat/test/browser/newChatInput.fixture.ts | 1 - .../test/browser/newChatVoiceTarget.test.ts | 1 - .../test/browser/newChatWidget.fixture.ts | 1 - .../browser/omniSessionRoutingAdapter.test.ts | 732 ------ .../sessionWorkspacePickerModel.test.ts | 146 -- .../chat/test/browser/voiceBridge.test.ts | 1 - .../browser/blockedSessionsCIFixModel.ts | 12 +- .../browser/blockedSessionsIndicatorModel.ts | 15 +- .../browser/omniCIFailureContribution.ts | 90 - .../sessions/browser/sessions.contribution.ts | 26 - .../blockedSessionsIndicatorModel.test.ts | 1 - .../browser/omniCIFailureContribution.test.ts | 164 -- src/vs/sessions/sessions.common.main.ts | 2 - src/vs/workbench/browser/window.ts | 5 +- .../browser/agentsVoice.contribution.ts | 10 +- .../agentsVoice/browser/agentsVoiceWidget.ts | 19 +- .../browser/agentsVoiceWidgetBinding.ts | 10 +- .../components/sessionListComponent.ts | 21 - .../test/browser/sessionListComponent.test.ts | 21 - .../browser/actions/chatAccessibilityHelp.ts | 35 +- .../chat/browser/actions/chatContext.ts | 142 +- .../browser/actions/chatContextActions.ts | 7 +- .../browser/actions/chatExecuteActions.ts | 41 +- .../chat/browser/actions/chatToolActions.ts | 1 - .../agentHost/agentHostSessionHandler.ts | 2 +- .../chat/browser/chat.shared.contribution.ts | 13 +- src/vs/workbench/contrib/chat/browser/chat.ts | 25 - .../chatInputWindow.contribution.ts | 66 - .../chatInputWindow/chatInputWindowService.ts | 1697 ------------ .../chatInputWindow/media/chatInputWindow.css | 493 ---- .../contrib/chat/browser/chatSlashCommands.ts | 3 +- .../chatSessionRoutingController.ts | 1637 ------------ .../chatSessionRoutingFolderPicker.ts | 459 ---- .../chatSessionRoutingHelpers.ts | 186 -- .../chatSessionRoutingProviderService.ts | 33 - .../media/chatSessionRouting.css | 290 --- .../sessionRouter/sessionRouterService.ts | 79 - .../browser/voiceClient/voiceClientService.ts | 12 +- .../voiceClient/voiceInputDecorations.ts | 12 +- .../voiceClient/voiceSessionController.ts | 1359 ++-------- .../voiceClient/voiceToolDispatchService.ts | 5 +- .../voiceInputModeActionViewItem.ts | 25 +- .../chatQuestionCarouselPart.ts | 6 +- .../media/chatQuestionCarousel.css | 4 - .../chat/browser/widget/chatListRenderer.ts | 3 - .../contrib/chat/browser/widget/chatWidget.ts | 80 +- .../browser/widget/input/chatInputPart.ts | 83 +- .../widget/input/chatInputPickerActionItem.ts | 2 +- .../modelPicker/modelPickerActionItem.ts | 7 - .../modelPicker/modelPickerConfiguration.ts | 110 +- .../input/modelPicker/modelPickerWidget.ts | 118 +- .../chat/browser/widget/media/chat.css | 13 - .../widgetHosts/viewPane/chatViewPane.ts | 17 +- .../contrib/chat/chatCodeOrganization.md | 8 - .../chat/common/actions/chatContextKeys.ts | 3 - .../contrib/chat/common/chatInputWindow.ts | 100 - .../chat/common/chatService/chatService.ts | 3 - .../common/chatService/chatServiceImpl.ts | 6 +- .../contrib/chat/common/sessionRouter.ts | 352 --- .../common/voiceClient/voiceClientService.ts | 60 +- .../chatAccessibilityHelp.test.ts | 13 - .../test/browser/actions/chatContext.test.ts | 45 +- .../actions/chatExecuteActions.test.ts | 29 +- .../chatSessionRoutingController.test.ts | 1135 -------- .../chatSessionRoutingHelpers.test.ts | 159 -- .../voiceClient/voiceClientService.test.ts | 39 - .../voiceSessionController.test.ts | 2302 ++--------------- .../voiceToolDispatchService.test.ts | 31 +- .../test/browser/widget/chatWidget.test.ts | 56 +- .../input/chatInputNotificationWidget.test.ts | 21 - .../modelPickerConfiguration.test.ts | 69 - .../chat/test/common/chatInputWindow.test.ts | 26 - .../common/chatService/chatService.test.ts | 1 - .../chat/test/common/sessionRouter.test.ts | 121 - .../common/voiceClient/voicePendingId.test.ts | 47 +- .../browser/commandsQuickAccess.ts | 3 +- .../host/browser/browserHostService.ts | 11 +- .../workbench/services/host/browser/host.ts | 3 - .../electron-browser/nativeHostService.ts | 4 - .../chat/chatFixtureUtils.ts | 1 - .../sessionsTitleBarWidget.fixture.ts | 17 +- .../test/browser/workbenchTestServices.ts | 1 - .../electron-browser/workbenchTestServices.ts | 1 - src/vs/workbench/workbench.common.main.ts | 1 - 99 files changed, 676 insertions(+), 13246 deletions(-) delete mode 100644 src/vs/sessions/contrib/chat/browser/omniSessionRoutingAdapter.contribution.ts delete mode 100644 src/vs/sessions/contrib/chat/browser/sessionWorkspacePickerModel.ts delete mode 100644 src/vs/sessions/contrib/chat/test/browser/omniSessionRoutingAdapter.test.ts delete mode 100644 src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePickerModel.test.ts delete mode 100644 src/vs/sessions/contrib/sessions/browser/omniCIFailureContribution.ts delete mode 100644 src/vs/sessions/contrib/sessions/test/browser/omniCIFailureContribution.test.ts delete mode 100644 src/vs/workbench/contrib/agentsVoice/test/browser/sessionListComponent.test.ts delete mode 100644 src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts delete mode 100644 src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts delete mode 100644 src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css delete mode 100644 src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts delete mode 100644 src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingFolderPicker.ts delete mode 100644 src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingHelpers.ts delete mode 100644 src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingProviderService.ts delete mode 100644 src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.css delete mode 100644 src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts delete mode 100644 src/vs/workbench/contrib/chat/common/chatInputWindow.ts delete mode 100644 src/vs/workbench/contrib/chat/common/sessionRouter.ts delete mode 100644 src/vs/workbench/contrib/chat/test/browser/sessionRouter/chatSessionRoutingController.test.ts delete mode 100644 src/vs/workbench/contrib/chat/test/browser/sessionRouter/chatSessionRoutingHelpers.test.ts delete mode 100644 src/vs/workbench/contrib/chat/test/common/chatInputWindow.test.ts delete mode 100644 src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index d728094e601788..7859d45cef764e 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1178,10 +1178,6 @@ "--collapse-from-width", "--slide-from-x", "--slide-from-y", - "--omni-icon-column", - "--omni-input-editor-background", - "--omni-rail", - "--omni-row-gap", "--vg-w1", "--vg-h1", "--vg-w2", diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index 2244851397ffee..3cccaf4b64da5f 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -25,7 +25,6 @@ export const enum AccessibleViewProviderId { InlineChat = 'inlineChat', AgentChat = 'agentChat', QuickChat = 'quickChat', - ChatInputWindow = 'chatInputWindow', InlineCompletions = 'inlineCompletions', KeybindingsEditor = 'keybindingsEditor', Notebook = 'notebook', diff --git a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts index 0aef0fc5f49d92..c118da4c0deaf2 100644 --- a/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts +++ b/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../base/browser/dom.js'; -import { IAnchor } from '../../../base/browser/ui/contextview/contextview.js'; import { IListAccessibilityProvider } from '../../../base/browser/ui/list/listWidget.js'; import { Radio } from '../../../base/browser/ui/radio/radio.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; @@ -52,10 +51,8 @@ export interface ITabDescriptor { export interface ITabbedActionListShowOptions { /** Logical user / source identifier passed through to {@link ActionList}. */ readonly user: string; - /** Element or explicit coordinates the popup is anchored to. */ - readonly anchor: HTMLElement | IAnchor; - /** Optional context-view container. Defaults to the active layout container. */ - readonly container?: HTMLElement; + /** Element the popup is anchored to. */ + readonly anchor: HTMLElement; /** Tabs rendered in order. */ readonly tabs: readonly ITabDescriptor[]; /** Initially active tab id. Must match an entry in {@link tabs}. */ @@ -264,7 +261,7 @@ export class TabbedActionListWidget extends Disposable { this._onDidHide.fire(); }, get anchorPosition() { return listRef?.anchorPosition; }, - }, options.container, false); + }, undefined, false); if (isSwap) { this._swappingTab = false; diff --git a/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts b/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts index 9c694471cf1635..59c616cc0916d6 100644 --- a/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts +++ b/src/vs/platform/actionWidget/test/browser/tabbedActionListWidget.test.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { IAnchor } from '../../../../base/browser/ui/contextview/contextview.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IContextViewDelegate, IContextViewService } from '../../../contextview/browser/contextView.js'; @@ -38,19 +37,17 @@ class FakeContextViewService implements Partial { private _container: HTMLElement | undefined; private _activeDelegate: IContextViewDelegate | undefined; private _activeRenderDisposables: { dispose(): void } | undefined; - lastContainer: HTMLElement | undefined; get isVisible(): boolean { return !!this._activeDelegate; } - showContextView(delegate: IContextViewDelegate, container?: HTMLElement): { close: () => void } { + showContextView(delegate: IContextViewDelegate): { close: () => void } { // Tear down any previous render before showing a new one. this.hideContextView(); this._activeDelegate = delegate; - this.lastContainer = container; this._container = document.createElement('div'); - (container ?? document.body).appendChild(this._container); + document.body.appendChild(this._container); const result = delegate.render(this._container); if (result && typeof (result as { dispose?: () => void }).dispose === 'function') { this._activeRenderDisposables = result as { dispose(): void }; @@ -190,30 +187,4 @@ suite('TabbedActionListWidget', () => { assert.strictEqual(hidden, 1, `expected onDidHide to fire once, got ${hidden}; widget visible: ${widget.isVisible}`); assert.strictEqual(widget.isVisible, false); }); - - test('supports an explicit popup container and coordinate anchor', () => { - const { widget, contextView } = createWidget(disposables); - const popupContainer = document.createElement('div'); - document.body.appendChild(popupContainer); - disposables.add({ dispose: () => popupContainer.remove() }); - const anchor: IAnchor = { x: 10, y: 20, width: 30, height: 1 }; - - widget.show({ - user: 'test', - anchor, - container: popupContainer, - tabs: [{ id: 'Local' }, { id: 'Remote' }], - initialTab: 'Local', - createActionList: () => ({ items: [action('a')] }), - delegate: { onSelect: () => { }, onHide: () => { } }, - }); - - assert.deepStrictEqual({ - container: contextView.lastContainer === popupContainer, - anchor: contextView.getContextViewElement().parentElement === popupContainer, - }, { - container: true, - anchor: true, - }); - }); }); diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 7d0a0879d1d29d..dff9b52d79b030 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -226,7 +226,6 @@ export interface ICommonNativeHostService { getWindowCount(): Promise; getActiveWindowId(): Promise; getActiveWindowPosition(): Promise; - getWindowPosition(options?: INativeHostOptions): Promise; getNativeWindowHandle(windowId: number): Promise; openWindow(options?: IOpenEmptyWindowOptions): Promise; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 7b7f85c07f61a1..f09c80af27d1c8 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -257,10 +257,6 @@ export class NativeHostMainService extends Disposable implements INativeHostMain return undefined; } - async getWindowPosition(windowId: number | undefined, options?: INativeHostOptions): Promise { - return this.windowById(options?.targetWindowId, windowId)?.win?.getBounds(); - } - async getNativeWindowHandle(fallbackWindowId: number | undefined, windowId: number): Promise { const window = this.windowById(windowId, fallbackWindowId); if (window?.win) { diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index a1dfb1104f8b7f..6f0d2ae20c1de6 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -63,11 +63,9 @@ type QuickInputOverlayLayoutCorrection = { readonly width: number; }; -export function getQuickInputWidth(availableWidth: number): number { - return Math.min(availableWidth * 0.62, 600); -} - export class QuickInputController extends Disposable { + private static readonly MAX_WIDTH = 600; // Max total width of quick input widget + private idPrefix: string; private ui: QuickInputUI | undefined; private dimension?: dom.IDimension; @@ -944,7 +942,7 @@ export class QuickInputController extends Disposable { private updateLayout() { if (this.ui && this.isVisible()) { const style = this.ui.container.style; - let width = getQuickInputWidth(this.dimension!.width); + let width = Math.min(this.dimension!.width * 0.62 /* golden cut */, QuickInputController.MAX_WIDTH); style.width = width + 'px'; let listHeight = this.dimension && this.dimension.height * 0.4; diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index ea8c93dcbefe5d..42788d620f7bba 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -396,20 +396,6 @@ Use the narrowest mechanism that represents the change: Do not add an event that mirrors an observable value. Do not use storage keys or provider internals as a side channel between components. -### Omni CI attention boundary - -The floating Omni Chat input owns the presentation contract for external -attention items. `IChatInputWindowService` defines and owns the narrow -`IChatInputWindowCIFailureProvider` registration API in `vs/workbench`; it must -not depend on Sessions models or import from `vs/sessions`. - -The Sessions-layer `OmniCIFailureContribution` owns the registration lifetime. -It adapts `BlockedSessions` into UI-neutral failure data and delegates actions -to the singleton `BlockedSessionsCIFixModel`. The title-bar blocked-sessions -dropdown uses that same singleton so optimistic hiding and duplicate-submission -guards apply globally across both surfaces. Disposing the contribution removes -the provider registration and all Sessions-owned observations. - ## Agents Window telemetry On the first Agents-window handoff, `SelectAgentsFolderContribution` immediately diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index 72e164303054b3..76bf6c9a3ebd7f 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -8,8 +8,6 @@ The sessions list is the primary navigation surface in the Agents Window. It occ The sessions list (`SessionsView` + `SessionsList`) displays user-facing sessions known to `ISessionsManagementService`. Sessions marked with `ISession.isAutomation` by their provider-owned run ledger are excluded before filtering and grouping. Other sessions are aggregated from all registered providers and shown in collapsible **sections**. The user can group, sort, filter, pin, and archive sessions. Selecting a session navigates to it. -When `chat.omni.enabled` is enabled, the Sessions header includes a `Codicon.arrowCircleUpSparkle` action after **New Session** that toggles the Agents-only floating chat input window. The window routes through a provider-neutral Sessions adapter and is not registered in editor workbenches. Its New Session row uses the same recent-workspace/provider model as the welcome picker: Local/GitHub/Remote/custom tabs, workspace labels/descriptions/icons, provider `Select...` actions, search, restored selection, and exact provider identity are rendered in Omni's separate action-widget window. - ### Key Files | File | Purpose | diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 473acfac1c4dc3..b90338250a52d9 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -9,7 +9,7 @@ import { $, isHTMLElement, size } from '../../../../base/browser/dom.js'; import { renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { autorun, IObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -278,10 +278,9 @@ export class ChatView extends AbstractChatView { || this.voiceSessionController.isConnecting.read(reader); const target = this.voiceSessionController.targetSession.read(reader); const hasDraftTarget = this.voiceSessionController.hasDraftTarget.read(reader); - const omniInputOpen = this.voiceSessionController.omniInputOpen.read(reader); const current = this._currentChatResourceObs.read(reader); const ownsVoice = !hasDraftTarget && (!target || (!!current && isEqual(target, current))); - this._voiceInitiatedHereKey.set(!omniInputOpen && active && voiceActive && ownsVoice); + this._voiceInitiatedHereKey.set(active && voiceActive && ownsVoice); })); } @@ -525,9 +524,6 @@ export class ChatView extends AbstractChatView { if (!inputContainerEl) { return; } - const isVoiceSurfaceActive = derived(this, reader => - this._isActiveObs.read(reader) && !this.voiceSessionController.omniInputOpen.read(reader) - ); this._register(setupVoiceInputDecorations({ voiceSessionController: this.voiceSessionController, ttsPlaybackService: this.ttsPlaybackService, @@ -538,7 +534,7 @@ export class ChatView extends AbstractChatView { accessibilityService: this.accessibilityService, }, { inputContainer: inputContainerEl, - isActive: isVoiceSurfaceActive, + isActive: this._isActiveObs, getCurrentResource: () => this._currentChatResource, currentVoiceInputResource: this.newChatVoiceTargetService.currentVoiceInputResource, })); diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 9fb8526c24dc04..514664ea27a2f8 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -1042,7 +1042,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this.voiceSessionController.isConnecting.read(reader), this.voiceSessionController.targetSession.read(reader), this.voiceSessionController.hasDraftTarget.read(reader), - this.voiceSessionController.omniInputOpen.read(reader), )); const action = toAction({ @@ -1159,7 +1158,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this.voiceSessionController.isConnecting.get(), this.voiceSessionController.targetSession.get(), this.voiceSessionController.hasDraftTarget.get(), - this.voiceSessionController.omniInputOpen.get(), ); const dict = this.voiceInputModeService.dictationAvailable.get(); const voice = this.voiceInputModeService.voiceAvailable.get(); @@ -1181,7 +1179,6 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this.voiceSessionController.isConnecting.read(reader); this.voiceSessionController.targetSession.read(reader); this.voiceSessionController.hasDraftTarget.read(reader); - this.voiceSessionController.omniInputOpen.read(reader); this.voiceInputModeService.dictationAvailable.read(reader); this.voiceInputModeService.voiceAvailable.read(reader); this.voiceInputModeService.handsFree.read(reader); diff --git a/src/vs/sessions/contrib/chat/browser/newChatVoice.ts b/src/vs/sessions/contrib/chat/browser/newChatVoice.ts index 4d1fe1908128f0..9d0214507f0a3b 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatVoice.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatVoice.ts @@ -38,8 +38,8 @@ import { setupVoiceInputDecorations } from './voiceInputDecorations.js'; export const NEW_CHAT_VOICE_SENTINEL = URI.from({ scheme: 'sessions-voice', authority: 'new-chat', path: '/composer' }); /** Whether the shared voice transport belongs to the new-session composer. */ -export function isNewChatVoiceSessionActive(connected: boolean, connecting: boolean, targetSession: URI | undefined, hasDraftTarget: boolean, omniInputOpen = false): boolean { - return !omniInputOpen && (connected || connecting) && targetSession === undefined && hasDraftTarget; +export function isNewChatVoiceSessionActive(connected: boolean, connecting: boolean, targetSession: URI | undefined, hasDraftTarget: boolean): boolean { + return (connected || connecting) && targetSession === undefined && hasDraftTarget; } /** New-session composer APIs used by voice mode. */ @@ -281,7 +281,6 @@ export class NewChatVoiceController extends Disposable { voiceSessionController.isConnecting.read(reader), voiceSessionController.targetSession.read(reader), voiceSessionController.hasDraftTarget.read(reader), - voiceSessionController.omniInputOpen.read(reader), ); return voiceActive && isVoiceSurface.read(reader); }); diff --git a/src/vs/sessions/contrib/chat/browser/omniSessionRoutingAdapter.contribution.ts b/src/vs/sessions/contrib/chat/browser/omniSessionRoutingAdapter.contribution.ts deleted file mode 100644 index 632228bfd4d066..00000000000000 --- a/src/vs/sessions/contrib/chat/browser/omniSessionRoutingAdapter.contribution.ts +++ /dev/null @@ -1,610 +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 { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { getErrorMessage, isCancellationError } from '../../../../base/common/errors.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { IMarkdownString } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../base/common/observable.js'; -import { URI } from '../../../../base/common/uri.js'; -import { localize } from '../../../../nls.js'; -import { RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; -import { INotificationService } from '../../../../platform/notification/common/notification.js'; -import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { IChatSessionHistoryItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { IChatSessionRoutingDispatchResult, IChatSessionRoutingNewSessionTarget, IChatSessionRoutingProvider, IChatSessionRoutingProviderService, IChatSessionRoutingWorkspace, IChatSessionRoutingWorkspaceCatalog, IRoutableSession, ROUTER_FIELD_CLIP_LENGTH } from '../../../../workbench/contrib/chat/common/sessionRouter.js'; -import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; -import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { ISessionsRecentWorkspacesService } from '../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; -import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { ChatInteractivity, IChat, ISession, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL, SessionStatus } from '../../../services/sessions/common/session.js'; -import { ICreateNewSessionOptions, ISendRequestOptions, ISessionsManagementService, WorkspaceNotTrustedError } from '../../../services/sessions/common/sessionsManagement.js'; -import { SessionWorkspaceFallback } from './sessionWorkspaceFallback.js'; -import { buildSessionWorkspacePickerCatalog } from './sessionWorkspacePickerModel.js'; - -interface ISessionRoutingTarget { - readonly session: ISession; - readonly chat: IChat; -} - -export class OmniSessionRoutingAdapter extends Disposable implements IChatSessionRoutingProvider { - - private readonly sessions = new Map(); - private readonly sessionResourceAliases = new Map(); - private readonly _onDidChangeSessions = this._register(new Emitter()); - readonly onDidChangeSessions = this._onDidChangeSessions.event; - private readonly _onDidChangeNewSessionWorkspaceCatalog = this._register(new Emitter()); - readonly onDidChangeNewSessionWorkspaceCatalog = this._onDidChangeNewSessionWorkspaceCatalog.event; - private readonly sessionWorkspaceFallback: SessionWorkspaceFallback; - private readonly localBrowseAction: ISessionWorkspaceBrowseAction = { - label: localize('omniSessionRouting.selectLocalWorkspace', "Select..."), - group: SESSION_WORKSPACE_GROUP_LOCAL, - icon: Codicon.folderOpened, - providerId: '', - run: async () => undefined, - }; - - constructor( - private readonly sessionsManagementService: ISessionsManagementService, - private readonly sessionsService: ISessionsService, - private readonly chatSessionsService: IChatSessionsService, - private readonly sessionsProvidersService: ISessionsProvidersService, - private readonly recentWorkspacesService: ISessionsRecentWorkspacesService, - private readonly configurationService: IConfigurationService, - private readonly fileDialogService: IFileDialogService, - fileService: IFileService, - uriIdentityService: IUriIdentityService, - private readonly logService: ILogService, - private readonly notificationService: INotificationService, - ) { - super(); - this.sessionWorkspaceFallback = this._register(new SessionWorkspaceFallback({ - canUseProvider: () => true, - isProviderUnavailable: providerId => this._isProviderUnavailable(providerId), - resolveWorkspace: (folderUri, preferredProviderId) => this._resolveWorkspace(folderUri, preferredProviderId), - }, this.sessionsProvidersService, fileService, uriIdentityService)); - this._register(this.sessionWorkspaceFallback.onDidChange(() => this._onDidChangeNewSessionWorkspaceCatalog.fire())); - this._refreshSessions(); - this._register(this.sessionsManagementService.onDidChangeSessions(() => { - this._refreshSessions(); - this._onDidChangeSessions.fire(); - })); - this._register(this.sessionsManagementService.onDidReplaceSession(({ from, to }) => { - this.sessionResourceAliases.set(from.resource.toString(), to.resource); - this.sessionResourceAliases.set(from.mainChat.get().resource.toString(), to.mainChat.get().resource); - this._refreshSessions(); - this._onDidChangeSessions.fire(); - })); - this._register(this.sessionsManagementService.onDidChangeSessionTypes(() => { - this._refreshSessions(); - this._onDidChangeSessions.fire(); - this._onDidChangeNewSessionWorkspaceCatalog.fire(); - })); - this._register(this.sessionsProvidersService.onDidChangeProviders(() => { - this.sessionWorkspaceFallback.refreshProviders(); - this._onDidChangeNewSessionWorkspaceCatalog.fire(); - })); - this._register(this.recentWorkspacesService.onDidChangeRecentWorkspaces(() => this._onDidChangeNewSessionWorkspaceCatalog.fire())); - this._register(this.configurationService.onDidChangeConfiguration(event => { - if (event.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) { - this._onDidChangeNewSessionWorkspaceCatalog.fire(); - } - })); - } - - getCandidateSessions(token: CancellationToken): readonly IRoutableSession[] { - if (token.isCancellationRequested) { - return []; - } - this._refreshSessions(); - return [...this.sessions.values()].map(session => this._toCandidate(session)); - } - - async getSessionSnapshot(resource: URI, token: CancellationToken): Promise { - if (token.isCancellationRequested) { - return undefined; - } - const target = this._resolveTarget(this._resolveSessionResourceAlias(resource).toString()); - if (!target) { - return undefined; - } - - const candidate = this._toCandidate(target.session); - try { - const history = await this.chatSessionsService.getChatSessionHistory(target.chat.resource, token); - return token.isCancellationRequested ? undefined : this._withHistory(candidate, history); - } catch (error) { - if (!isCancellationError(error) && !token.isCancellationRequested) { - this.logService.trace('[omniSessionRouting] Failed to read session response preview', error); - } - return token.isCancellationRequested ? undefined : candidate; - } - } - - watchSession(resource: URI, listener: () => void): IDisposable { - const store = new DisposableStore(); - const observableWatcher = store.add(new MutableDisposable()); - let watchedSession: ISession | undefined; - let watchedChat: IChat | undefined; - const bind = () => { - const target = this._resolveTarget(this._resolveSessionResourceAlias(resource).toString()); - if (target?.session === watchedSession && target?.chat === watchedChat) { - return; - } - watchedSession = target?.session; - watchedChat = target?.chat; - const session = target?.session; - observableWatcher.value = session ? autorun(reader => { - session.title.read(reader); - session.status.read(reader); - session.updatedAt.read(reader); - session.lastTurnEnd.read(reader); - listener(); - }) : undefined; - }; - store.add(this.onDidChangeSessions(bind)); - bind(); - return store; - } - - async getNewSessionWorkspaceCatalog(): Promise { - const providers = this.sessionsProvidersService.getProviders(); - const catalog = buildSessionWorkspacePickerCatalog({ - providers, - recentWorkspaces: this.recentWorkspacesService.getRecentWorkspaces(), - ownRecentWorkspaces: this.recentWorkspacesService.getRecentWorkspaces(false), - localBrowseAction: providers.some(provider => provider.supportsLocalWorkspaces) ? this.localBrowseAction : undefined, - remoteAgentHostsEnabled: this.configurationService.getValue(RemoteAgentHostsEnabledSettingId), - isProviderUnavailable: providerId => this._isProviderUnavailable(providerId), - }); - const defaultWorkspace = catalog.defaultWorkspace ?? await this.sessionWorkspaceFallback.findWorkspace(); - return { - groups: catalog.tabs.map(tab => ({ - id: tab.id, - label: tab.label, - tooltip: tab.tooltip, - icon: tab.icon, - })), - workspaces: catalog.workspaces.map(recent => this._toRoutingWorkspace(recent.workspace, recent.providerId)), - browseActions: catalog.browseActions.map(action => ({ - id: this._getBrowseActionId(action), - providerId: action.providerId || undefined, - group: action.group, - label: localize('omniSessionRouting.selectWorkspace', "Select..."), - description: action.description, - icon: action.icon, - disabled: !!action.providerId && this._isProviderUnavailable(action.providerId), - })), - defaultWorkspace: defaultWorkspace - ? this._toRoutingWorkspace(defaultWorkspace.workspace, defaultWorkspace.providerId) - : undefined, - }; - } - - selectNewSessionWorkspace(workspace: IChatSessionRoutingWorkspace): void { - const provider = this.sessionsProvidersService.getProvider(workspace.providerId); - if (!provider?.resolveWorkspace(workspace.uri)) { - throw new Error(localize('omniSessionRouting.workspaceProviderUnavailable', "The selected workspace provider is no longer available.")); - } - this.recentWorkspacesService.addRecentWorkspace(workspace.uri, workspace.providerId, true); - } - - async browseNewSessionWorkspace(actionId: string, token: CancellationToken): Promise { - if (token.isCancellationRequested) { - return undefined; - } - try { - if (actionId === 'local') { - return await this._browseForLocalWorkspace(token); - } - const action = this._findBrowseAction(actionId); - if (!action) { - throw new Error(localize('omniSessionRouting.workspaceBrowseUnavailable', "The selected workspace browser is no longer available.")); - } - const workspace = await action.run(); - if (!workspace || token.isCancellationRequested) { - return undefined; - } - const folderUri = workspace.folders[0]?.root; - const provider = this.sessionsProvidersService.getProvider(action.providerId); - if (!folderUri || !provider?.resolveWorkspace(folderUri)) { - throw new Error(localize('omniSessionRouting.workspaceProviderUnavailable', "The selected workspace provider is no longer available.")); - } - return this._toRoutingWorkspace(workspace, action.providerId); - } catch (error) { - if (!isCancellationError(error) && !token.isCancellationRequested) { - this.logService.error('[omniSessionRouting] Failed to browse for a workspace', error); - this.notificationService.error(localize('omniSessionRouting.workspaceBrowseFailed', "Unable to select a workspace.")); - } - return undefined; - } - } - - resolveSessionResource(sessionId: string): URI | undefined { - return this._resolveTarget(sessionId)?.chat.resource; - } - - async dispatchToSession(sessionId: string, message: string, options: IChatSendRequestOptions, token: CancellationToken): Promise { - if (token.isCancellationRequested) { - return this._cancelled(); - } - const target = this._resolveTarget(sessionId); - if (!target) { - return { - status: 'rejected', - reasonCode: 'providerRemoved', - reason: localize('omniSessionRouting.sessionUnavailable', "The selected session is no longer available."), - }; - } - const unsupported = this._getUnsupportedOptions(options); - if (unsupported) { - return unsupported; - } - - try { - const activityBaseline = target.session.lastTurnEnd.get()?.getTime() ?? target.session.updatedAt.get().getTime(); - await this.sessionsManagementService.sendRequest(target.session, target.chat, { - query: message, - attachedContext: options.attachedContext?.length ? [...options.attachedContext] : undefined, - background: true, - }); - return { status: 'sent', resource: target.chat.resource, activityBaseline }; - } catch (error) { - return this._toRejectedResult(error, target.chat.resource); - } - } - - async dispatchToNewSession(target: IChatSessionRoutingNewSessionTarget, message: string, options: IChatSendRequestOptions, token: CancellationToken): Promise { - if (token.isCancellationRequested) { - return this._cancelled(); - } - const unsupported = this._getUnsupportedOptions(options); - if (unsupported) { - return unsupported; - } - - const sendOptions: ISendRequestOptions = { - query: message, - attachedContext: options.attachedContext?.length ? [...options.attachedContext] : undefined, - background: true, - }; - if (target.providerId) { - const provider = this.sessionsProvidersService.getProvider(target.providerId); - const canCreate = target.folder ? !!provider?.resolveWorkspace(target.folder) : !!provider?.supportsQuickChats; - if (!canCreate) { - return { - status: 'rejected', - reasonCode: 'providerRemoved', - reason: localize('omniSessionRouting.workspaceProviderUnavailable', "The selected workspace provider is no longer available."), - }; - } - } - const createOptions = this._toCreateOptions(options, target.providerId); - try { - const session = target.folder - ? await this.sessionsManagementService.createAndSendNewChatRequest(target.folder, sendOptions, createOptions, token) - : await this.sessionsManagementService.createAndSendQuickChatRequest(sendOptions, createOptions, token); - if (!session) { - return { - status: 'rejected', - reasonCode: 'providerRemoved', - reason: localize('omniSessionRouting.sessionNotCreated', "The Sessions provider could not create the new session."), - }; - } - return { status: 'sent', resource: session.mainChat.get().resource, activityBaseline: session.createdAt.getTime() }; - } catch (error) { - return this._toRejectedResult(error); - } - } - - revealSession(resource: URI): Promise { - const resolved = this._resolveSessionResourceAlias(resource); - return this.sessionsService.openSession(this._resolveTarget(resolved.toString())?.session.resource ?? resolved); - } - - private _resolveSessionResourceAlias(resource: URI): URI { - let resolved = resource; - const visited = new Set(); - while (!visited.has(resolved.toString())) { - visited.add(resolved.toString()); - const replacement = this.sessionResourceAliases.get(resolved.toString()); - if (!replacement) { - break; - } - resolved = replacement; - } - return resolved; - } - - private _refreshSessions(): void { - this.sessions.clear(); - for (const session of this.sessionsManagementService.getSessions()) { - if (this._getRoutableChat(session)) { - this.sessions.set(session.sessionId, session); - } - } - } - - private _toRoutingWorkspace(workspace: ISessionWorkspace, providerId: string): IChatSessionRoutingWorkspace { - const folderUri = workspace.folders[0]?.root ?? workspace.uri; - return { - uri: folderUri, - providerId, - group: workspace.group, - label: workspace.label, - description: workspace.description, - icon: workspace.icon, - disabled: this._isProviderUnavailable(providerId), - }; - } - - private _resolveWorkspace(folderUri: URI, preferredProviderId?: string): { readonly providerId: string; readonly workspace: ISessionWorkspace } | undefined { - if (preferredProviderId) { - const provider = this.sessionsProvidersService.getProvider(preferredProviderId); - const workspace = provider?.resolveWorkspace(folderUri); - if (workspace) { - return { providerId: preferredProviderId, workspace }; - } - } - for (const provider of this.sessionsProvidersService.getProviders()) { - const workspace = provider.resolveWorkspace(folderUri); - if (workspace) { - return { providerId: provider.id, workspace }; - } - } - return undefined; - } - - private _getBrowseActionId(action: ISessionWorkspaceBrowseAction): string { - if (action === this.localBrowseAction) { - return 'local'; - } - const provider = this.sessionsProvidersService.getProvider(action.providerId); - const index = provider?.browseActions.indexOf(action) ?? -1; - return `provider:${encodeURIComponent(action.providerId)}:${index}`; - } - - private _findBrowseAction(actionId: string): ISessionWorkspaceBrowseAction | undefined { - for (const provider of this.sessionsProvidersService.getProviders()) { - for (let index = 0; index < provider.browseActions.length; index++) { - const action = provider.browseActions[index]; - if (actionId === `provider:${encodeURIComponent(provider.id)}:${index}`) { - return action; - } - } - } - return undefined; - } - - private async _browseForLocalWorkspace(token: CancellationToken): Promise { - const providers = this.sessionsProvidersService.getProviders().filter(provider => provider.supportsLocalWorkspaces); - if (!providers.length) { - throw new Error(localize('omniSessionRouting.localWorkspaceProviderUnavailable', "No local workspace provider is available.")); - } - const selected = await this.fileDialogService.showOpenDialog({ - canSelectFolders: true, - canSelectFiles: false, - canSelectMany: false, - }); - if (!selected?.length || token.isCancellationRequested) { - return undefined; - } - for (const provider of providers) { - const workspace = provider.resolveWorkspace(selected[0]); - if (workspace) { - return this._toRoutingWorkspace(workspace, provider.id); - } - } - throw new Error(localize('omniSessionRouting.localWorkspaceUnsupported', "No Sessions provider can use the selected folder.")); - } - - private _isProviderUnavailable(providerId: string): boolean { - const provider = this.sessionsProvidersService.getProvider(providerId); - if (!provider || !isAgentHostProvider(provider) || !provider.connectionStatus) { - return false; - } - const status = provider.connectionStatus.get(); - return RemoteAgentHostConnectionStatus.isIncompatible(status) - || (!RemoteAgentHostConnectionStatus.isConnected(status) && !provider.canConnectOnDemand); - } - - private _resolveTarget(sessionId: string): ISessionRoutingTarget | undefined { - this._refreshSessions(); - const session = this.sessions.get(sessionId) ?? this._findSessionByResource(sessionId); - if (!session) { - return undefined; - } - const chat = this._findChatByResource(session, sessionId) ?? this._getRoutableChat(session); - return chat ? { session, chat } : undefined; - } - - private _findSessionByResource(value: string): ISession | undefined { - let resource: URI; - try { - resource = URI.parse(value); - } catch { - return undefined; - } - const session = this.sessionsManagementService.getSession(resource) - ?? this.sessionsManagementService.getSessionForChatResource(resource)?.session; - return session && this.sessions.has(session.sessionId) ? session : undefined; - } - - private _findChatByResource(session: ISession, value: string): IChat | undefined { - return session.chats.get().find(chat => chat.resource.toString() === value && this._isRoutableChat(chat)); - } - - private _getRoutableChat(session: ISession): IChat | undefined { - if (session.status.get() === SessionStatus.Untitled - || session.isArchived.get() - || session.isAutomation?.get()) { - return undefined; - } - const mainChat = session.mainChat.get(); - if (this._isRoutableChat(mainChat)) { - return mainChat; - } - return [...session.chats.get()] - .filter(chat => this._isRoutableChat(chat)) - .sort((a, b) => b.updatedAt.get().getTime() - a.updatedAt.get().getTime())[0]; - } - - private _isRoutableChat(chat: IChat): boolean { - return chat.status.get() !== SessionStatus.Untitled - && !chat.isArchived.get() - && chat.interactivity.get() === ChatInteractivity.Full; - } - - private _toCandidate(session: ISession): IRoutableSession { - const workspace = session.workspace.get(); - const folder = workspace?.folders[0]; - const gitHubInfo = folder?.gitRepository?.gitHubInfo.get(); - return { - sessionId: session.sessionId, - resource: session.resource, - label: session.title.get(), - repo: gitHubInfo ? `${gitHubInfo.owner}/${gitHubInfo.repo}` : undefined, - cwd: folder?.workingDirectory.path, - status: this._statusToString(session.status.get()), - lastActivity: session.lastTurnEnd.get()?.getTime() ?? session.updatedAt.get().getTime(), - description: this._markdownToText(session.description.get()), - }; - } - - private _withHistory(candidate: IRoutableSession, history: readonly IChatSessionHistoryItem[]): IRoutableSession { - let lastResponse: string | undefined; - for (const item of history) { - if (item.type !== 'response') { - continue; - } - for (let index = item.parts.length - 1; index >= 0; index--) { - const part = item.parts[index]; - if (part.kind === 'markdownContent' && part.content.value.trim()) { - lastResponse = part.content.value.trim().slice(0, ROUTER_FIELD_CLIP_LENGTH * 2); - break; - } - } - } - return lastResponse ? { ...candidate, lastResponse } : candidate; - } - - private _statusToString(status: SessionStatus): string { - switch (status) { - case SessionStatus.InProgress: return 'working'; - case SessionStatus.NeedsInput: return 'needsInput'; - case SessionStatus.Completed: return 'idle'; - case SessionStatus.Error: return 'failed'; - case SessionStatus.Untitled: return 'draft'; - } - } - - private _markdownToText(value: IMarkdownString | undefined): string | undefined { - const text = value?.value.trim(); - return text || undefined; - } - - private _getUnsupportedOptions(options: IChatSendRequestOptions): IChatSessionRoutingDispatchResult | undefined { - // The chat widget snapshots every default-enabled tool as `true`. Sessions - // providers own that default tool set, so only an actual disabled-tool - // override is unsupported and must be rejected rather than dropped. - if (options.userSelectedTools && Object.values(options.userSelectedTools.get()).some(enabled => !enabled)) { - return this._unsupported(localize('omniSessionRouting.toolsUnsupported', "The selected tool configuration cannot be sent through Sessions.")); - } - if (options.resolvedVariables?.length) { - return this._unsupported(localize('omniSessionRouting.variablesUnsupported', "Resolved request variables cannot be sent through Sessions.")); - } - if (options.agentHostSessionConfig && Object.keys(options.agentHostSessionConfig).length) { - return this._unsupported(localize('omniSessionRouting.sessionConfigurationUnsupported', "The selected Agent Host session configuration cannot be sent through Sessions.")); - } - return undefined; - } - - private _toCreateOptions(options: IChatSendRequestOptions, providerId?: string): ICreateNewSessionOptions | undefined { - const modeId = options.modeInfo?.modeInstructions?.uri?.toString() - ?? options.modeInfo?.modeInstructions?.name - ?? options.modeInfo?.kind; - const createOptions: ICreateNewSessionOptions = { - providerId, - modelId: options.userSelectedModelId, - modeId, - permissionLevel: options.modeInfo?.permissionLevel, - }; - return createOptions.providerId || createOptions.modelId || createOptions.modeId || createOptions.permissionLevel ? createOptions : undefined; - } - - private _unsupported(reason: string): IChatSessionRoutingDispatchResult { - return { status: 'rejected', reasonCode: 'unsupportedOptions', reason }; - } - - private _cancelled(resource?: URI): IChatSessionRoutingDispatchResult { - return { - status: 'rejected', - resource, - reasonCode: 'cancelled', - reason: localize('omniSessionRouting.cancelled', "The request was cancelled."), - }; - } - - private _toRejectedResult(error: unknown, resource?: URI): IChatSessionRoutingDispatchResult { - if (isCancellationError(error)) { - return this._cancelled(resource); - } - if (error instanceof WorkspaceNotTrustedError) { - return { - status: 'rejected', - resource, - reasonCode: 'workspaceNotTrusted', - reason: localize('omniSessionRouting.workspaceNotTrusted', "The selected workspace or folder is not trusted."), - }; - } - return { status: 'rejected', resource, reason: getErrorMessage(error) }; - } -} - -class OmniSessionRoutingContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.omniSessionRouting'; - - constructor( - @IChatSessionRoutingProviderService routingProviderService: IChatSessionRoutingProviderService, - @ISessionsManagementService sessionsManagementService: ISessionsManagementService, - @ISessionsService sessionsService: ISessionsService, - @IChatSessionsService chatSessionsService: IChatSessionsService, - @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, - @ISessionsRecentWorkspacesService recentWorkspacesService: ISessionsRecentWorkspacesService, - @IConfigurationService configurationService: IConfigurationService, - @IFileDialogService fileDialogService: IFileDialogService, - @IFileService fileService: IFileService, - @IUriIdentityService uriIdentityService: IUriIdentityService, - @ILogService logService: ILogService, - @INotificationService notificationService: INotificationService, - ) { - super(); - const adapter = this._register(new OmniSessionRoutingAdapter( - sessionsManagementService, - sessionsService, - chatSessionsService, - sessionsProvidersService, - recentWorkspacesService, - configurationService, - fileDialogService, - fileService, - uriIdentityService, - logService, - notificationService, - )); - this._register(routingProviderService.registerProvider(adapter)); - } -} - -registerWorkbenchContribution2(OmniSessionRoutingContribution.ID, OmniSessionRoutingContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 8d1951d4ca25ea..caf75671f94126 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -31,7 +31,7 @@ import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js' import { ThemeIcon } from '../../../../base/common/themables.js'; import { ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { IRecentWorkspace, ISessionsRecentWorkspacesService, isWorktreeWorkspaceUri } from '../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; +import { ISessionsRecentWorkspacesService, isWorktreeWorkspaceUri } from '../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; import { SessionWorkspacePickerGroupContext } from '../../../common/contextkeys.js'; // eslint-disable-next-line local/code-import-patterns -- TODO: move remote host options out of providers @@ -43,7 +43,6 @@ import { Menus } from '../../../browser/menus.js'; import { markOnboardingTarget } from '../../../../workbench/contrib/onboarding/browser/spotlight/onboardingTarget.js'; import { NewSessionWorkspacePreselectionSource } from './newSessionComposerService.js'; import { type IResolvedFolderWorkspace, SessionWorkspaceFallback } from './sessionWorkspaceFallback.js'; -import { buildSessionWorkspacePickerCatalog } from './sessionWorkspacePickerModel.js'; export type { IResolvedFolderWorkspace } from './sessionWorkspaceFallback.js'; @@ -409,10 +408,32 @@ export class WorkspacePicker extends Disposable { } protected _getAvailableTabs(): ITabDescriptor[] { - return [...buildSessionWorkspacePickerCatalog({ - providers: this.sessionsProvidersService.getProviders(), - remoteAgentHostsEnabled: this.configurationService.getValue(RemoteAgentHostsEnabledSettingId), - }).tabs]; + const byLabel = new Map(); + const remoteAgentHostsEnabled = this.configurationService.getValue(RemoteAgentHostsEnabledSettingId); + if (remoteAgentHostsEnabled) { + byLabel.set(SESSION_WORKSPACE_GROUP_REMOTE, { + id: SESSION_WORKSPACE_GROUP_REMOTE, + icon: Codicon.beaker, + tooltip: `${SESSION_WORKSPACE_GROUP_REMOTE} (${localize('workspacePicker.experimental', "Experimental")})`, + }); + } + for (const provider of this.sessionsProvidersService.getProviders()) { + if (provider.supportsLocalWorkspaces && !byLabel.has(SESSION_WORKSPACE_GROUP_LOCAL)) { + byLabel.set(SESSION_WORKSPACE_GROUP_LOCAL, { id: SESSION_WORKSPACE_GROUP_LOCAL }); + } + for (const action of provider.browseActions) { + if (action.group === SESSION_WORKSPACE_GROUP_REMOTE && !remoteAgentHostsEnabled) { + continue; + } + if (action.group && !byLabel.has(action.group)) { + byLabel.set(action.group, { id: action.group }); + } + } + } + return Array.from(byLabel.values()).sort((a, b) => + a.id === SESSION_WORKSPACE_GROUP_LOCAL ? -1 + : b.id === SESSION_WORKSPACE_GROUP_LOCAL ? 1 + : a.id.localeCompare(b.id)); } /** @@ -739,14 +760,15 @@ export class WorkspacePicker extends Disposable { * currently active tab when tabs are shown. */ protected _getAllBrowseActions(): ISessionWorkspaceBrowseAction[] { - const providers = this.sessionsProvidersService.getProviders(); - const catalog = buildSessionWorkspacePickerCatalog({ - providers, - localBrowseAction: providers.some(provider => provider.supportsLocalWorkspaces) ? this._localBrowseAction : undefined, - remoteAgentHostsEnabled: this.configurationService.getValue(RemoteAgentHostsEnabledSettingId), - activeGroup: this._isTabFiltered() ? this._activeTab : undefined, - }); - return [...catalog.browseActions]; + const all = this.sessionsProvidersService.getProviders().flatMap(p => p.browseActions); + const hasLocalSupport = this.sessionsProvidersService.getProviders().some(p => p.supportsLocalWorkspaces); + if (hasLocalSupport) { + all.unshift(this._localBrowseAction); + } + if (!this._isTabFiltered()) { + return all; + } + return all.filter(a => a.group === this._activeTab); } /** @@ -798,19 +820,19 @@ export class WorkspacePicker extends Disposable { // Collect recent workspaces from picker storage across all providers const allProviders = this.sessionsProvidersService.getProviders(); + const providerIds = new Set(allProviders.map(p => p.id)); const availableTabs = this._getAvailableTabs(); const activeGroup = this._activeTab ?? (availableTabs.length === 1 ? availableTabs[0].id : undefined); const workspaceGroupAction = this.options.getWorkspaceGroupAction?.(activeGroup); - const catalog = buildSessionWorkspacePickerCatalog({ - providers: allProviders, - recentWorkspaces: this._getRecentWorkspaces(), - localBrowseAction: allProviders.some(provider => provider.supportsLocalWorkspaces) ? this._localBrowseAction : undefined, - remoteAgentHostsEnabled: this.configurationService.getValue(RemoteAgentHostsEnabledSettingId), - activeGroup: this._isTabFiltered() ? this._activeTab : undefined, - }); + const tabFilter = this._isTabFiltered() + ? (w: IResolvedFolderWorkspace) => w.workspace.group === this._activeTab + : undefined; + // Own recents first, then VS Code recents (merged and deduplicated by the service) const recentWorkspaces = workspaceGroupAction?.hideWorkspaceItems ? [] - : catalog.workspaces; + : this._getRecentWorkspaces() + .filter(w => providerIds.has(w.providerId)) + .filter(w => !tabFilter || tabFilter(w)); // Build flat list in recency order (no source grouping) for (const { workspace, providerId } of recentWorkspaces) { @@ -831,7 +853,7 @@ export class WorkspacePicker extends Disposable { } // Browse actions from all providers (filtered to the active tab) - const allBrowseActions = workspaceGroupAction?.hideWorkspaceItems ? [] : catalog.browseActions; + const allBrowseActions = workspaceGroupAction?.hideWorkspaceItems ? [] : this._getAllBrowseActions(); // Remote providers with connection status — shown as dynamic rows // in the Manage submenu on the Remote tab. const remoteProviders = allProviders.filter(isAgentHostProvider).filter(p => p.connectionStatus !== undefined); @@ -1036,21 +1058,28 @@ export class WorkspacePicker extends Disposable { } private _restoreSelectedWorkspace(): IRestoredWorkspaceSelection | undefined { + // Try the checked entry first + const checked = this._restoreCheckedWorkspace(); + if (checked && this._canRestoreProviderWorkspace(checked.providerId)) { + return { + resolved: checked, + source: NewSessionWorkspacePreselectionSource.CheckedWorkspace, + }; + } + + // Agents-owned recents are ordered before VS Code's general recents. try { - const restored = buildSessionWorkspacePickerCatalog({ - providers: this.sessionsProvidersService.getProviders(), - recentWorkspaces: this.recentWorkspacesService.getRecentWorkspaces(), - ownRecentWorkspaces: this.recentWorkspacesService.getRecentWorkspaces(false), - remoteAgentHostsEnabled: this.configurationService.getValue(RemoteAgentHostsEnabledSettingId), - canUseProvider: providerId => this._canRestoreProviderWorkspace(providerId), - isProviderUnavailable: providerId => this._isProviderUnavailable(providerId), - }).defaultWorkspace; - return restored ? { - resolved: restored, - source: restored.checked - ? NewSessionWorkspacePreselectionSource.CheckedWorkspace - : NewSessionWorkspacePreselectionSource.RecentWorkspace, - } : undefined; + for (const recent of this.recentWorkspacesService.getRecentWorkspaces()) { + const folderUri = recent.workspace.folders[0]?.root; + if (!folderUri || !this._canRestoreProviderWorkspace(recent.providerId) || isWorktreeWorkspaceUri(folderUri) || this._isProviderUnavailable(recent.providerId)) { + continue; + } + return { + resolved: recent, + source: NewSessionWorkspacePreselectionSource.RecentWorkspace, + }; + } + return undefined; } catch { return undefined; } @@ -1234,7 +1263,7 @@ export class WorkspacePicker extends Disposable { // -- Recent workspaces (sessions' own history) -- - protected _getRecentWorkspaces(): IRecentWorkspace[] { + protected _getRecentWorkspaces(): IResolvedFolderWorkspace[] { return this.recentWorkspacesService.getRecentWorkspaces(); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePickerModel.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePickerModel.ts deleted file mode 100644 index 6a7f6bb3fe8e3a..00000000000000 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePickerModel.ts +++ /dev/null @@ -1,102 +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 { Codicon } from '../../../../base/common/codicons.js'; -import { localize } from '../../../../nls.js'; -import { ITabDescriptor } from '../../../../platform/actionWidget/browser/tabbedActionListWidget.js'; -import { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; -import { ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../services/sessions/common/session.js'; -import { IRecentWorkspace, isWorktreeWorkspaceUri } from '../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; - -export interface ISessionWorkspacePickerCatalog { - readonly tabs: readonly ITabDescriptor[]; - readonly workspaces: readonly IRecentWorkspace[]; - readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; - readonly defaultWorkspace: IRecentWorkspace | undefined; -} - -export interface ISessionWorkspacePickerCatalogOptions { - readonly providers: readonly ISessionsProvider[]; - readonly recentWorkspaces?: readonly IRecentWorkspace[]; - readonly ownRecentWorkspaces?: readonly IRecentWorkspace[]; - readonly localBrowseAction?: ISessionWorkspaceBrowseAction; - readonly remoteAgentHostsEnabled: boolean; - readonly activeGroup?: string; - readonly canUseProvider?: (providerId: string) => boolean; - readonly isProviderUnavailable?: (providerId: string) => boolean; -} - -/** - * Builds the provider/recent-workspace portion of the Sessions workspace picker. - * Presentation-specific commands and remote-host management rows remain owned by - * the picker, while other Sessions surfaces can reuse the canonical tabs, - * recency, browse-action, and restored-selection rules. - */ -export function buildSessionWorkspacePickerCatalog(options: ISessionWorkspacePickerCatalogOptions): ISessionWorkspacePickerCatalog { - const providerIds = new Set(options.providers.map(provider => provider.id)); - const tabs = getAvailableTabs(options.providers, options.remoteAgentHostsEnabled); - const filterByActiveGroup = !!options.activeGroup && tabs.length > 1; - const workspaces = (options.recentWorkspaces ?? []) - .filter(recent => providerIds.has(recent.providerId)) - .filter(recent => !filterByActiveGroup || recent.workspace.group === options.activeGroup); - const browseActions = [ - ...(options.localBrowseAction ? [options.localBrowseAction] : []), - ...options.providers.flatMap(provider => provider.browseActions), - ].filter(action => !filterByActiveGroup || action.group === options.activeGroup); - - return { - tabs, - workspaces, - browseActions, - defaultWorkspace: getDefaultWorkspace(options), - }; -} - -function getAvailableTabs(providers: readonly ISessionsProvider[], remoteAgentHostsEnabled: boolean): ITabDescriptor[] { - const byLabel = new Map(); - if (remoteAgentHostsEnabled) { - byLabel.set(SESSION_WORKSPACE_GROUP_REMOTE, { - id: SESSION_WORKSPACE_GROUP_REMOTE, - icon: Codicon.beaker, - tooltip: `${SESSION_WORKSPACE_GROUP_REMOTE} (${localize('workspacePicker.experimental', "Experimental")})`, - }); - } - for (const provider of providers) { - if (provider.supportsLocalWorkspaces && !byLabel.has(SESSION_WORKSPACE_GROUP_LOCAL)) { - byLabel.set(SESSION_WORKSPACE_GROUP_LOCAL, { id: SESSION_WORKSPACE_GROUP_LOCAL }); - } - for (const action of provider.browseActions) { - if (action.group === SESSION_WORKSPACE_GROUP_REMOTE && !remoteAgentHostsEnabled) { - continue; - } - if (action.group && !byLabel.has(action.group)) { - byLabel.set(action.group, { id: action.group }); - } - } - } - return [...byLabel.values()].sort((a, b) => - a.id === SESSION_WORKSPACE_GROUP_LOCAL ? -1 - : b.id === SESSION_WORKSPACE_GROUP_LOCAL ? 1 - : a.id.localeCompare(b.id)); -} - -function getDefaultWorkspace(options: ISessionWorkspacePickerCatalogOptions): IRecentWorkspace | undefined { - const canUseProvider = options.canUseProvider ?? (() => true); - const checked = options.ownRecentWorkspaces?.find(recent => { - const folderUri = recent.workspace.folders[0]?.root; - return recent.checked && !!folderUri && !isWorktreeWorkspaceUri(folderUri); - }); - if (checked && canUseProvider(checked.providerId)) { - return checked; - } - - return options.recentWorkspaces?.find(recent => { - const folderUri = recent.workspace.folders[0]?.root; - return !!folderUri - && canUseProvider(recent.providerId) - && !isWorktreeWorkspaceUri(folderUri) - && !options.isProviderUnavailable?.(recent.providerId); - }); -} diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatInput.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/newChatInput.fixture.ts index ddf3cc75868294..571a62b007381d 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatInput.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatInput.fixture.ts @@ -111,7 +111,6 @@ async function renderNewChatInput(context: ComponentFixtureContext, fixtureOptio override readonly voiceState = observableValue<'idle' | 'listening' | 'processing' | 'speaking' | 'error'>('voiceState', 'idle'); override readonly targetSession = observableValue('targetSession', undefined); override readonly hasDraftTarget = observableValue('hasDraftTarget', false); - override readonly omniInputOpen = observableValue('omniInputOpen', false); override readonly transcriptTurns = observableValue('transcriptTurns', []); }()); reg.defineInstance(ITtsPlaybackService, new class extends mock() { diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatVoiceTarget.test.ts b/src/vs/sessions/contrib/chat/test/browser/newChatVoiceTarget.test.ts index f3a53ce5e47b5e..8b6986a4ebd332 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatVoiceTarget.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatVoiceTarget.test.ts @@ -105,6 +105,5 @@ suite('NewChatVoiceTargetService', () => { assert.strictEqual(isNewChatVoiceSessionActive(true, false, URI.parse('agent-host-copilot:/session-1'), false), false); assert.strictEqual(isNewChatVoiceSessionActive(true, false, undefined, false), false); assert.strictEqual(isNewChatVoiceSessionActive(false, false, undefined, true), false); - assert.strictEqual(isNewChatVoiceSessionActive(true, false, undefined, true, true), false); }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts index c5a873d019fbfa..312c91fd1cfb6a 100644 --- a/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts +++ b/src/vs/sessions/contrib/chat/test/browser/newChatWidget.fixture.ts @@ -206,7 +206,6 @@ async function renderNewChatWidget(context: ComponentFixtureContext, options: IN override readonly voiceState = observableValue<'idle' | 'listening' | 'processing' | 'speaking' | 'error'>('voiceState', 'idle'); override readonly targetSession = observableValue('targetSession', undefined); override readonly hasDraftTarget = observableValue('hasDraftTarget', false); - override readonly omniInputOpen = observableValue('omniInputOpen', false); override readonly transcriptTurns = observableValue('transcriptTurns', []); }()); reg.defineInstance(ITtsPlaybackService, new class extends mock() { diff --git a/src/vs/sessions/contrib/chat/test/browser/omniSessionRoutingAdapter.test.ts b/src/vs/sessions/contrib/chat/test/browser/omniSessionRoutingAdapter.test.ts deleted file mode 100644 index b346f827fdfcf0..00000000000000 --- a/src/vs/sessions/contrib/chat/test/browser/omniSessionRoutingAdapter.test.ts +++ /dev/null @@ -1,732 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { constObservable, observableValue } from '../../../../../base/common/observable.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { INotificationService } from '../../../../../platform/notification/common/notification.js'; -import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; -import { IChatRequestVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -import { ChatModeKind, ChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; -import { IChatSessionHistoryItem, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { TestFileService } from '../../../../../workbench/test/common/workbenchTestServices.js'; -import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { IRecentWorkspace, ISessionsRecentWorkspacesService } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; -import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; -import { IChat, ISession, SessionStatus, ChatInteractivity, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; -import { ICreateNewSessionOptions, ISendRequestOptions, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; -import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; -import { OmniSessionRoutingAdapter } from '../../browser/omniSessionRoutingAdapter.contribution.js'; - -suite('OmniSessionRoutingAdapter', () => { - - const store = new DisposableStore(); - let managementService: TestSessionsManagementService; - let providersService: TestSessionsProvidersService; - let recentWorkspacesService: TestRecentWorkspacesService; - let opened: URI[]; - let adapter: OmniSessionRoutingAdapter; - let selectedLocalFolder: URI[] | undefined; - let history: readonly IChatSessionHistoryItem[]; - - setup(() => { - managementService = store.add(new TestSessionsManagementService()); - providersService = store.add(new TestSessionsProvidersService()); - recentWorkspacesService = store.add(new TestRecentWorkspacesService()); - providersService.setProviders([createProvider('provider', { supportsLocalWorkspaces: true })]); - opened = []; - selectedLocalFolder = undefined; - history = []; - const fileService = store.add(new TestFileService()); - adapter = store.add(new OmniSessionRoutingAdapter( - managementService, - upcastPartial({ - openSession: async resource => { opened.push(resource); }, - }), - upcastPartial({ - getChatSessionHistory: async () => history, - }), - providersService, - recentWorkspacesService, - new TestConfigurationService({ [RemoteAgentHostsEnabledSettingId]: true }), - upcastPartial({ - showOpenDialog: async () => selectedLocalFolder, - }), - fileService, - store.add(new UriIdentityService(fileService)), - upcastPartial({ - error: () => { }, - }), - upcastPartial({ - error: () => undefined!, - }), - )); - }); - - teardown(() => store.clear()); - ensureNoDisposablesAreLeakedInTestSuite(); - - test('aggregates provider-neutral sessions and filters drafts, archived, and non-routable chats', () => { - managementService.sessions = [ - createSession('provider-a:one', { providerId: 'provider-a', title: 'One', description: 'First session', repository: 'vscode', status: SessionStatus.InProgress }), - createSession('provider-b:two', { providerId: 'provider-b', title: 'Two', status: SessionStatus.Completed }), - createSession('provider-a:draft', { status: SessionStatus.Untitled }), - createSession('provider-a:archived', { archived: true }), - createSession('provider-a:readonly', { interactivity: ChatInteractivity.ReadOnly }), - ]; - - assert.deepStrictEqual(adapter.getCandidateSessions(CancellationToken.None), [ - { - sessionId: 'provider-a:one', - resource: URI.from({ scheme: 'session', path: '/provider-a:one' }), - label: 'One', - repo: 'microsoft/vscode', - cwd: '/work/vscode', - status: 'working', - lastActivity: Date.parse('2026-08-13T12:00:00Z'), - description: 'First session', - }, - { - sessionId: 'provider-b:two', - resource: URI.from({ scheme: 'session', path: '/provider-b:two' }), - label: 'Two', - repo: 'microsoft/repo', - cwd: '/work/repo', - status: 'idle', - lastActivity: Date.parse('2026-08-13T12:00:00Z'), - description: undefined, - }, - ]); - }); - - test('refreshes on lifecycle changes and rejects a removed provider session', async () => { - const session = createSession('provider:session'); - managementService.sessions = [session]; - managementService.fireSessionsChanged({ added: [session], removed: [], changed: [] }); - assert.deepStrictEqual(adapter.getCandidateSessions(CancellationToken.None).map(candidate => ({ - sessionId: candidate.sessionId, - resource: candidate.resource?.toString(), - })), [{ - sessionId: 'provider:session', - resource: session.resource.toString(), - }]); - assert.strictEqual(adapter.resolveSessionResource(session.sessionId)?.toString(), session.mainChat.get().resource.toString()); - - managementService.sessions = []; - managementService.fireSessionsChanged({ added: [], removed: [session], changed: [] }); - - assert.deepStrictEqual({ - candidates: adapter.getCandidateSessions(CancellationToken.None), - dispatch: await adapter.dispatchToSession(session.sessionId, 'Continue', {}, CancellationToken.None), - }, { - candidates: [], - dispatch: { - status: 'rejected', - reasonCode: 'providerRemoved', - reason: 'The selected session is no longer available.', - }, - }); - }); - - test('publishes live title, status, and response snapshots', async () => { - const title = observableValue('title', 'New session'); - const status = observableValue('status', SessionStatus.InProgress); - const original = { - ...createSession('provider:session', { title: 'New session', status: SessionStatus.InProgress }), - title, - status, - }; - managementService.sessions = [original]; - history = [{ - type: 'response', - parts: [ - { kind: 'markdownContent', content: { value: 'Renaming this session to match your request, then I will make the change.' } }, - { kind: 'markdownContent', content: { value: 'Implemented the requested change.' } }, - ], - participant: 'assistant', - }]; - let changeCount = 0; - store.add(adapter.onDidChangeSessions(() => changeCount++)); - let watchedCount = 0; - store.add(adapter.watchSession(original.resource, () => watchedCount++)); - - title.set('Update routing badge', undefined); - status.set(SessionStatus.Completed, undefined); - const snapshot = await adapter.getSessionSnapshot(original.resource, CancellationToken.None); - - assert.deepStrictEqual({ changeCount, watchedCount, snapshot }, { - changeCount: 0, - watchedCount: 3, - snapshot: { - sessionId: 'provider:session', - resource: original.resource, - label: 'Update routing badge', - repo: 'microsoft/repo', - cwd: '/work/repo', - status: 'idle', - lastActivity: Date.parse('2026-08-13T12:00:00Z'), - description: undefined, - lastResponse: 'Implemented the requested change.', - }, - }); - }); - - test('follows a new session from its provisional resource to the committed session', async () => { - const provisional = createSession('provider:provisional', { title: 'New session', status: SessionStatus.InProgress }); - const committed = createSession('provider:committed', { title: 'Adding repository README', status: SessionStatus.Completed }); - managementService.sessions = [provisional]; - history = [{ - type: 'response', - parts: [{ kind: 'markdownContent', content: { value: 'Added the repository README.' } }], - participant: 'assistant', - }]; - let watchedCount = 0; - store.add(adapter.watchSession(provisional.mainChat.get().resource, () => watchedCount++)); - - managementService.fireSessionReplaced(provisional, committed); - const snapshot = await adapter.getSessionSnapshot(provisional.mainChat.get().resource, CancellationToken.None); - await adapter.revealSession(provisional.mainChat.get().resource); - - assert.deepStrictEqual({ - watchedCount, - label: snapshot?.label, - status: snapshot?.status, - lastResponse: snapshot?.lastResponse, - opened: opened.map(resource => resource.toString()), - }, { - watchedCount: 2, - label: 'Adding repository README', - status: 'idle', - lastResponse: 'Added the repository README.', - opened: [committed.resource.toString()], - }); - }); - - test('publishes canonical grouped recents, browse actions, and restored provider selection', async () => { - const shared = URI.file('/work/shared'); - const local = createProvider('local', { supportsLocalWorkspaces: true, group: SESSION_WORKSPACE_GROUP_LOCAL }); - const github = createProvider('github', { - group: SESSION_WORKSPACE_GROUP_GITHUB, - browseActions: [createBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, workspace(shared, 'GitHub shared', SESSION_WORKSPACE_GROUP_GITHUB))], - }); - const remote = createProvider('remote', { - group: SESSION_WORKSPACE_GROUP_REMOTE, - browseActions: [createBrowseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE, undefined)], - }); - providersService.setProviders([local, github, remote]); - recentWorkspacesService.recents = [ - recent(workspace(shared, 'GitHub shared', SESSION_WORKSPACE_GROUP_GITHUB), 'github', true), - recent(workspace(URI.file('/work/local'), 'Local repo', SESSION_WORKSPACE_GROUP_LOCAL), 'local', false), - ]; - recentWorkspacesService.ownRecents = [recentWorkspacesService.recents[0]]; - - const catalog = await adapter.getNewSessionWorkspaceCatalog(); - - assert.deepStrictEqual({ - groups: catalog.groups.map(group => group.id), - workspaces: catalog.workspaces.map(entry => [entry.label, entry.providerId, entry.group]), - browseActions: catalog.browseActions.map(action => [action.id, action.providerId, action.group, action.label]), - defaultWorkspace: catalog.defaultWorkspace && [catalog.defaultWorkspace.label, catalog.defaultWorkspace.providerId], - }, { - groups: [SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_REMOTE], - workspaces: [ - ['GitHub shared', 'github', SESSION_WORKSPACE_GROUP_GITHUB], - ['Local repo', 'local', SESSION_WORKSPACE_GROUP_LOCAL], - ], - browseActions: [ - ['local', undefined, SESSION_WORKSPACE_GROUP_LOCAL, 'Select...'], - ['provider:github:0', 'github', SESSION_WORKSPACE_GROUP_GITHUB, 'Select...'], - ['provider:remote:0', 'remote', SESSION_WORKSPACE_GROUP_REMOTE, 'Select...'], - ], - defaultWorkspace: ['GitHub shared', 'github'], - }); - }); - - test('falls back to the most frequent recent session workspace when no workspace is checked', async () => { - const first = createSession('provider:first', { repository: 'frequent' }); - const second = createSession('provider:second', { repository: 'other' }); - const third = createSession('provider:third', { repository: 'frequent' }); - providersService.setProviders([createProvider('provider', { - supportsLocalWorkspaces: true, - sessions: [first, second, third], - })]); - - const catalog = await adapter.getNewSessionWorkspaceCatalog(); - - assert.deepStrictEqual( - catalog.defaultWorkspace && [catalog.defaultWorkspace.label, catalog.defaultWorkspace.providerId, catalog.defaultWorkspace.uri.toString()], - ['frequent', 'provider', URI.file('/work/frequent').toString()] - ); - }); - - test('refreshes workspace catalog lifecycle and persists exact provider selections', () => { - let changes = 0; - store.add(adapter.onDidChangeNewSessionWorkspaceCatalog(() => changes++)); - const selected = workspace(URI.file('/work/shared'), 'Shared', SESSION_WORKSPACE_GROUP_GITHUB); - const github = createProvider('github', { group: SESSION_WORKSPACE_GROUP_GITHUB }); - providersService.setProviders([github]); - recentWorkspacesService.fireChanged(); - - adapter.selectNewSessionWorkspace({ - uri: selected.folders[0].root, - providerId: 'github', - group: selected.group, - label: selected.label, - icon: selected.icon, - }); - - assert.deepStrictEqual({ - changes, - added: recentWorkspacesService.added.map(entry => [entry.uri.toString(), entry.providerId, entry.checked]), - }, { - changes: 3, - added: [[selected.folders[0].root.toString(), 'github', true]], - }); - }); - - test('returns exact local and provider browse selections', async () => { - const shared = URI.file('/work/shared'); - const localFolder = URI.file('/work/local'); - const local = createProvider('local', { supportsLocalWorkspaces: true, group: SESSION_WORKSPACE_GROUP_LOCAL }); - const github = createProvider('github', { - group: SESSION_WORKSPACE_GROUP_GITHUB, - browseActions: [createBrowseAction('github', SESSION_WORKSPACE_GROUP_GITHUB, workspace(shared, 'GitHub shared', SESSION_WORKSPACE_GROUP_GITHUB))], - }); - providersService.setProviders([local, github]); - selectedLocalFolder = [localFolder]; - const catalog = await adapter.getNewSessionWorkspaceCatalog(); - const githubAction = catalog.browseActions.find(action => action.providerId === 'github'); - - const localSelection = await adapter.browseNewSessionWorkspace('local', CancellationToken.None); - const githubSelection = await adapter.browseNewSessionWorkspace(githubAction!.id, CancellationToken.None); - - assert.deepStrictEqual({ - local: localSelection && [localSelection.uri.toString(), localSelection.providerId, localSelection.group], - github: githubSelection && [githubSelection.uri.toString(), githubSelection.providerId, githubSelection.group], - }, { - local: [localFolder.toString(), 'local', SESSION_WORKSPACE_GROUP_LOCAL], - github: [shared.toString(), 'github', SESSION_WORKSPACE_GROUP_GITHUB], - }); - }); - - test('returns an explicit rejection when the owning provider disappears during dispatch', async () => { - const session = createSession('provider:session'); - managementService.sessions = [session]; - managementService.sendError = new Error(`Sessions provider 'provider' not found`); - - const result = await adapter.dispatchToSession(session.sessionId, 'Continue', {}, CancellationToken.None); - - assert.deepStrictEqual(result, { - status: 'rejected', - resource: session.mainChat.get().resource, - reason: `Sessions provider 'provider' not found`, - }); - }); - - test('sends existing sessions through Sessions management with attachments in the background', async () => { - const session = createSession('provider:session'); - managementService.sessions = [session]; - const attachment = upcastPartial({ id: 'file', name: 'file' }); - - const result = await adapter.dispatchToSession(session.sessionId, 'Continue', { - attachedContext: [attachment], - userSelectedTools: constObservable({ tool: true }), - }, CancellationToken.None); - - assert.deepStrictEqual({ - result, - send: managementService.existingSend, - }, { - result: { - status: 'sent', - resource: session.mainChat.get().resource, - activityBaseline: session.lastTurnEnd.get()!.getTime(), - }, - send: { - session, - chat: session.mainChat.get(), - options: { query: 'Continue', attachedContext: [attachment], background: true }, - }, - }); - }); - - test('creates and sends a folder session with supported model, mode, permission, and attachments', async () => { - const created = createSession('provider:created'); - managementService.createdSession = created; - const folder = URI.file('/work/repo'); - const attachment = upcastPartial({ id: 'file', name: 'file' }); - - const result = await adapter.dispatchToNewSession({ folder, providerId: 'provider' }, 'Build it', { - attachedContext: [attachment], - userSelectedModelId: 'model', - modeInfo: { - kind: ChatModeKind.Agent, - isBuiltin: true, - modeInstructions: undefined, - telemetryModeId: 'agent', - applyCodeBlockSuggestionId: undefined, - permissionLevel: ChatPermissionLevel.AutoApprove, - }, - }, CancellationToken.None); - - assert.deepStrictEqual({ - result, - folderSend: managementService.folderSend, - }, { - result: { - status: 'sent', - resource: created.mainChat.get().resource, - activityBaseline: created.createdAt.getTime(), - }, - folderSend: { - folder, - options: { query: 'Build it', attachedContext: [attachment], background: true }, - createOptions: { providerId: 'provider', modelId: 'model', modeId: 'agent', permissionLevel: ChatPermissionLevel.AutoApprove }, - }, - }); - }); - - test('creates and sends a quick chat when no folder is selected', async () => { - const created = createSession('provider:quick'); - managementService.createdSession = created; - - const result = await adapter.dispatchToNewSession({}, 'Explain this', {}, CancellationToken.None); - - assert.deepStrictEqual({ - result, - quickSend: managementService.quickSend, - }, { - result: { - status: 'sent', - resource: created.mainChat.get().resource, - activityBaseline: created.createdAt.getTime(), - }, - quickSend: { - options: { query: 'Explain this', attachedContext: undefined, background: true }, - createOptions: undefined, - }, - }); - - test('rejects a missing selected workspace provider instead of rerouting', async () => { - const result = await adapter.dispatchToNewSession({ - folder: URI.file('/work/repo'), - providerId: 'missing', - }, 'Build it', {}, CancellationToken.None); - - assert.deepStrictEqual(result, { - status: 'rejected', - reasonCode: 'providerRemoved', - reason: 'The selected workspace provider is no longer available.', - }); - assert.strictEqual(managementService.folderSend, undefined); - }); - }); - - test('rejects unsupported request context instead of dropping it', async () => { - const session = createSession('provider:session'); - managementService.sessions = [session]; - - const result = await adapter.dispatchToSession(session.sessionId, 'Continue', { - userSelectedTools: constObservable({ tool: false }), - }, CancellationToken.None); - - assert.deepStrictEqual(result, { - status: 'rejected', - reasonCode: 'unsupportedOptions', - reason: 'The selected tool configuration cannot be sent through Sessions.', - }); - assert.strictEqual(managementService.existingSend, undefined); - }); - - test('sends with the selected model when its configuration cannot be forwarded', async () => { - const session = createSession('provider:session'); - managementService.sessions = [session]; - - const result = await adapter.dispatchToSession(session.sessionId, 'Continue', { - userSelectedModelId: 'model', - userSelectedModelConfiguration: { reasoningEffort: 'high', contextSize: 1_000_000 }, - }, CancellationToken.None); - - assert.deepStrictEqual({ - result, - send: managementService.existingSend, - }, { - result: { - status: 'sent', - resource: session.mainChat.get().resource, - activityBaseline: session.lastTurnEnd.get()!.getTime(), - }, - send: { - session, - chat: session.mainChat.get(), - options: { query: 'Continue', attachedContext: undefined, background: true }, - }, - }); - }); - - test('rejects cancelled sends before dispatch', async () => { - const session = createSession('provider:session'); - managementService.sessions = [session]; - const cts = new CancellationTokenSource(); - cts.cancel(); - - const result = await adapter.dispatchToSession(session.sessionId, 'Continue', {}, cts.token); - - assert.deepStrictEqual(result, { - status: 'rejected', - resource: undefined, - reasonCode: 'cancelled', - reason: 'The request was cancelled.', - }); - assert.strictEqual(managementService.existingSend, undefined); - cts.dispose(); - }); - - test('opens adapter results through Sessions service', async () => { - const resource = URI.parse('session:/provider/session'); - - await adapter.revealSession(resource); - - assert.deepStrictEqual(opened, [resource]); - }); -}); - -class TestSessionsProvidersService extends Disposable implements ISessionsProvidersService { - declare readonly _serviceBrand: undefined; - - private readonly changeEmitter = this._register(new Emitter()); - readonly onDidChangeProviders = this.changeEmitter.event; - private providers: ISessionsProvider[] = []; - - setProviders(providers: ISessionsProvider[]): void { - const removed = this.providers; - this.providers = providers; - this.changeEmitter.fire({ added: providers, removed }); - } - - registerProvider(provider: ISessionsProvider) { - this.setProviders([...this.providers, provider]); - return toDisposable(() => this.setProviders(this.providers.filter(candidate => candidate !== provider))); - } - - getProviders(): ISessionsProvider[] { - return [...this.providers]; - } - - getProvider(providerId: string): T | undefined { - return this.providers.find(provider => provider.id === providerId) as T | undefined; - } -} - -class TestRecentWorkspacesService extends Disposable implements ISessionsRecentWorkspacesService { - declare readonly _serviceBrand: undefined; - - private readonly changeEmitter = this._register(new Emitter()); - readonly onDidChangeRecentWorkspaces = this.changeEmitter.event; - recents: IRecentWorkspace[] = []; - ownRecents: IRecentWorkspace[] = []; - readonly added: Array<{ uri: URI; providerId: string | undefined; checked: boolean }> = []; - - getRecentWorkspaces(includeVSCodeRecents = true): IRecentWorkspace[] { - return [...(includeVSCodeRecents ? this.recents : this.ownRecents)]; - } - - addRecentWorkspace(uri: URI, providerId: string | undefined, checked: boolean): void { - this.added.push({ uri, providerId, checked }); - this.changeEmitter.fire(); - } - - removeRecentWorkspace(): void { } - clearCheckedWorkspace(): void { } - fireChanged(): void { - this.changeEmitter.fire(); - } -} - -class TestSessionsManagementService extends mock() { - declare readonly _serviceBrand: undefined; - - private readonly sessionsChangedEmitter = new Emitter(); - private readonly sessionTypesChangedEmitter = new Emitter(); - private readonly sessionReplacedEmitter = new Emitter<{ readonly from: ISession; readonly to: ISession }>(); - override readonly onDidChangeSessions = this.sessionsChangedEmitter.event; - override readonly onDidChangeSessionTypes = this.sessionTypesChangedEmitter.event; - override readonly onDidReplaceSession = this.sessionReplacedEmitter.event; - - sessions: ISession[] = []; - createdSession: ISession | undefined; - sendError: Error | undefined; - existingSend: { session: ISession; chat: IChat; options: ISendRequestOptions } | undefined; - folderSend: { folder: URI; options: ISendRequestOptions; createOptions: ICreateNewSessionOptions | undefined } | undefined; - quickSend: { options: ISendRequestOptions; createOptions: ICreateNewSessionOptions | undefined } | undefined; - - override getSessions(): ISession[] { - return this.sessions; - } - - override getSession(resource: URI): ISession | undefined { - return this.sessions.find(session => session.resource.toString() === resource.toString()); - } - - override getSessionForChatResource(resource: URI): { session: ISession; chat: IChat } | undefined { - for (const session of this.sessions) { - const chat = session.chats.get().find(candidate => candidate.resource.toString() === resource.toString()); - if (chat) { - return { session, chat }; - } - } - return undefined; - } - - override async sendRequest(session: ISession, chat: IChat, options: ISendRequestOptions): Promise { - if (this.sendError) { - throw this.sendError; - } - this.existingSend = { session, chat, options }; - } - - override async createAndSendNewChatRequest(folder: URI, options: ISendRequestOptions, createOptions?: ICreateNewSessionOptions): Promise { - this.folderSend = { folder, options, createOptions }; - return this.createdSession; - } - - override async createAndSendQuickChatRequest(options: ISendRequestOptions, createOptions?: ICreateNewSessionOptions): Promise { - this.quickSend = { options, createOptions }; - return this.createdSession; - } - - fireSessionsChanged(event: ISessionsChangeEvent): void { - this.sessionsChangedEmitter.fire(event); - } - - fireSessionReplaced(from: ISession, to: ISession): void { - this.sessions = this.sessions.filter(session => session !== from); - this.sessions.push(to); - this.sessionReplacedEmitter.fire({ from, to }); - } - - dispose(): void { - this.sessionsChangedEmitter.dispose(); - this.sessionTypesChangedEmitter.dispose(); - this.sessionReplacedEmitter.dispose(); - } -} - -function createSession(sessionId: string, options: { - readonly providerId?: string; - readonly title?: string; - readonly description?: string; - readonly repository?: string; - readonly status?: SessionStatus; - readonly archived?: boolean; - readonly interactivity?: ChatInteractivity; -} = {}): ISession { - const providerId = options.providerId ?? 'provider'; - const status = options.status ?? SessionStatus.Completed; - const repository = options.repository ?? 'repo'; - const resource = URI.parse(`session:/${sessionId}`); - const chat = upcastPartial({ - resource: URI.parse(`chat:/${sessionId}`), - createdAt: new Date('2026-08-13T10:00:00Z'), - title: constObservable(options.title ?? sessionId), - updatedAt: constObservable(new Date('2026-08-13T12:00:00Z')), - status: constObservable(status), - isArchived: constObservable(options.archived ?? false), - interactivity: constObservable(options.interactivity ?? ChatInteractivity.Full), - }); - return upcastPartial({ - sessionId, - resource, - providerId, - sessionType: 'test', - createdAt: new Date('2026-08-13T10:00:00Z'), - title: constObservable(options.title ?? sessionId), - updatedAt: constObservable(new Date('2026-08-13T12:00:00Z')), - status: constObservable(status), - isArchived: constObservable(options.archived ?? false), - isAutomation: constObservable(false), - description: constObservable(options.description ? { value: options.description } : undefined), - lastTurnEnd: constObservable(new Date('2026-08-13T12:00:00Z')), - workspace: constObservable({ - uri: URI.file(`/work/${repository}`), - label: repository, - icon: { id: 'folder' }, - folders: [{ - root: URI.file(`/work/${repository}`), - workingDirectory: URI.file(`/work/${repository}`), - name: repository, - description: undefined, - gitRepository: { - uri: URI.file(`/work/${repository}`), - workTreeUri: undefined, - baseBranchName: undefined, - gitHubInfo: constObservable({ owner: 'microsoft', repo: repository }), - }, - }], - requiresWorkspaceTrust: false, - isVirtualWorkspace: false, - }), - chats: constObservable([chat]), - mainChat: constObservable(chat), - }); -} - -function createProvider(id: string, options: { - readonly supportsLocalWorkspaces?: boolean; - readonly group?: string; - readonly browseActions?: readonly ISessionWorkspaceBrowseAction[]; - readonly sessions?: readonly ISession[]; -} = {}): ISessionsProvider { - return upcastPartial({ - id, - label: id, - order: 0, - supportsLocalWorkspaces: options.supportsLocalWorkspaces, - browseActions: options.browseActions ?? [], - onDidChangeSessions: Event.None, - getSessions: () => [...options.sessions ?? []], - resolveWorkspace: (uri: URI) => workspace(uri, uri.path.split('/').filter(Boolean).at(-1) ?? uri.path, options.group), - }); -} - -function createBrowseAction(providerId: string, group: string, selection: ISessionWorkspace | undefined): ISessionWorkspaceBrowseAction { - return { - label: 'Provider action', - group, - icon: { id: 'folder-opened' }, - providerId, - run: async () => selection, - }; -} - -function workspace(uri: URI, label: string, group?: string): ISessionWorkspace { - return { - uri, - label, - group, - icon: { id: 'folder' }, - folders: [{ - root: uri, - workingDirectory: uri, - name: label, - description: undefined, - }], - requiresWorkspaceTrust: false, - isVirtualWorkspace: false, - }; -} - -function recent(workspace: ISessionWorkspace, providerId: string, checked: boolean): IRecentWorkspace { - return { workspace, providerId, checked }; -} diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePickerModel.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePickerModel.test.ts deleted file mode 100644 index 46ec2558c4f762..00000000000000 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePickerModel.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { upcastPartial } from '../../../../../base/test/common/mock.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; -import { ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; -import { IRecentWorkspace } from '../../../../services/sessions/browser/sessionsRecentWorkspacesService.js'; -import { buildSessionWorkspacePickerCatalog } from '../../browser/sessionWorkspacePickerModel.js'; - -suite('SessionWorkspacePickerModel', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('builds canonical tabs and filters recents and browse actions by group', () => { - const githubAction = browseAction('github', SESSION_WORKSPACE_GROUP_GITHUB); - const remoteAction = browseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE); - const customAction = browseAction('custom', 'Zebra'); - const providers = [ - provider('local', true), - provider('github', false, githubAction), - provider('remote', false, remoteAction), - provider('custom', false, customAction), - ]; - const recents = [ - recent('/github/vscode', 'github', SESSION_WORKSPACE_GROUP_GITHUB), - recent('/local/vscode', 'local', SESSION_WORKSPACE_GROUP_LOCAL), - ]; - - const catalog = buildSessionWorkspacePickerCatalog({ - providers, - recentWorkspaces: recents, - localBrowseAction: browseAction('', SESSION_WORKSPACE_GROUP_LOCAL), - remoteAgentHostsEnabled: true, - activeGroup: SESSION_WORKSPACE_GROUP_GITHUB, - }); - - assert.deepStrictEqual({ - tabs: catalog.tabs.map(tab => tab.id), - workspaces: catalog.workspaces.map(workspace => [workspace.workspace.label, workspace.providerId]), - browseActions: catalog.browseActions.map(action => [action.providerId, action.group]), - }, { - tabs: [SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_GITHUB, SESSION_WORKSPACE_GROUP_REMOTE, 'Zebra'], - workspaces: [['vscode', 'github']], - browseActions: [['github', SESSION_WORKSPACE_GROUP_GITHUB]], - }); - }); - - test('preserves recency and provider identity for duplicate workspace URIs', () => { - const shared = URI.file('/work/shared'); - const catalog = buildSessionWorkspacePickerCatalog({ - providers: [provider('first'), provider('second')], - recentWorkspaces: [ - recentWorkspace(shared, 'second', false), - recentWorkspace(shared, 'first', false), - ], - remoteAgentHostsEnabled: false, - }); - - assert.deepStrictEqual(catalog.workspaces.map(workspace => ({ - uri: workspace.workspace.folders[0].root.toString(), - providerId: workspace.providerId, - })), [ - { uri: shared.toString(), providerId: 'second' }, - { uri: shared.toString(), providerId: 'first' }, - ]); - }); - - test('restores checked selection before recents and skips unavailable or worktree fallbacks', () => { - const checked = recent('/remote/checked', 'remote', SESSION_WORKSPACE_GROUP_REMOTE, true); - const unavailable = recent('/remote/unavailable', 'remote', SESSION_WORKSPACE_GROUP_REMOTE); - const worktree = recent('/work/copilot-worktrees/repo', 'local', SESSION_WORKSPACE_GROUP_LOCAL); - const fallback = recent('/local/fallback', 'local', SESSION_WORKSPACE_GROUP_LOCAL); - const providers = [provider('remote'), provider('local')]; - - const checkedCatalog = buildSessionWorkspacePickerCatalog({ - providers, - recentWorkspaces: [unavailable, worktree, fallback], - ownRecentWorkspaces: [checked], - remoteAgentHostsEnabled: true, - isProviderUnavailable: providerId => providerId === 'remote', - }); - const fallbackCatalog = buildSessionWorkspacePickerCatalog({ - providers, - recentWorkspaces: [unavailable, worktree, fallback], - ownRecentWorkspaces: [], - remoteAgentHostsEnabled: true, - isProviderUnavailable: providerId => providerId === 'remote', - }); - - assert.deepStrictEqual({ - checked: checkedCatalog.defaultWorkspace?.workspace.label, - fallback: fallbackCatalog.defaultWorkspace?.workspace.label, - }, { - checked: 'checked', - fallback: 'fallback', - }); - }); -}); - -function provider(id: string, supportsLocalWorkspaces = false, ...browseActions: ISessionWorkspaceBrowseAction[]): ISessionsProvider { - return upcastPartial({ - id, - order: 0, - supportsLocalWorkspaces, - browseActions, - }); -} - -function browseAction(providerId: string, group: string): ISessionWorkspaceBrowseAction { - return { - label: 'Select...', - group, - icon: Codicon.folderOpened, - providerId, - run: async () => undefined, - }; -} - -function recent(path: string, providerId: string, group: string, checked = false): IRecentWorkspace { - return recentWorkspace(URI.file(path), providerId, checked, group); -} - -function recentWorkspace(uri: URI, providerId: string, checked: boolean, group?: string): IRecentWorkspace { - const label = uri.path.split('/').filter(Boolean).at(-1) ?? uri.path; - const workspace: ISessionWorkspace = { - uri, - label, - group, - icon: Codicon.folder, - folders: [{ - root: uri, - workingDirectory: uri, - name: label, - description: undefined, - }], - requiresWorkspaceTrust: false, - isVirtualWorkspace: false, - }; - return { workspace, providerId, checked }; -} diff --git a/src/vs/sessions/contrib/chat/test/browser/voiceBridge.test.ts b/src/vs/sessions/contrib/chat/test/browser/voiceBridge.test.ts index 66587bf7ad46bc..f2915ab8098062 100644 --- a/src/vs/sessions/contrib/chat/test/browser/voiceBridge.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/voiceBridge.test.ts @@ -36,7 +36,6 @@ suite('SessionsVoiceNewComposerContribution', () => { const controller = new class extends mock() { override readonly isConnected = isConnected; override readonly isConnecting = isConnecting; - override readonly omniInputOpen = constObservable(false); override disconnect(): void { disconnectCount++; } }; return { controller, getDisconnectCount: () => disconnectCount }; diff --git a/src/vs/sessions/contrib/sessions/browser/blockedSessionsCIFixModel.ts b/src/vs/sessions/contrib/sessions/browser/blockedSessionsCIFixModel.ts index b61574decce07c..073d7b588a51ba 100644 --- a/src/vs/sessions/contrib/sessions/browser/blockedSessionsCIFixModel.ts +++ b/src/vs/sessions/contrib/sessions/browser/blockedSessionsCIFixModel.ts @@ -7,7 +7,6 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { derived, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ChatSendResult, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; import { ISession } from '../../../services/sessions/common/session.js'; @@ -17,13 +16,6 @@ import { GitHubPullRequestCIModel } from '../../github/browser/models/githubPull import { GitHubCheckStatus } from '../../github/common/types.js'; import { ISessionCIFixModel, ISessionCIFixState } from './views/sessionsList.js'; -export interface IBlockedSessionsCIFixModel extends ISessionCIFixModel { - readonly _serviceBrand: undefined; - readonly hiddenSessions: IObservable>; -} - -export const IBlockedSessionsCIFixModel = createDecorator('blockedSessionsCIFixModel'); - /** * Backs the per-session "Fix CI" row shown in the blocked-sessions dropdown for * sessions whose pull request has failing CI checks. Exposes a reactive summary @@ -36,9 +28,7 @@ export const IBlockedSessionsCIFixModel = createDecorator>(); diff --git a/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts b/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts index fce37e968cbbc6..bb4cab1eeb068c 100644 --- a/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts +++ b/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts @@ -13,7 +13,7 @@ import { AgentSessionApprovalKind, AgentSessionApprovalModel, agentSessionApprov import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { BlockedSessionReason, BlockedSessions, IBlockedSession } from '../../blockedSessions/browser/blockedSessions.js'; -import { BlockedSessionsCIFixModel, IBlockedSessionsCIFixModel } from './blockedSessionsCIFixModel.js'; +import { BlockedSessionsCIFixModel } from './blockedSessionsCIFixModel.js'; import { getFirstApprovalAcrossChats, IApprovedSession } from './views/sessionsList.js'; /** @@ -57,10 +57,10 @@ export class BlockedSessionsIndicatorModel extends Disposable { } /** Drives the per-session "Fix CI" row; shared with the dropdown list. */ - private readonly _ciFixModel: IBlockedSessionsCIFixModel; + private readonly _ciFixModel: BlockedSessionsCIFixModel; /** The CI-fix model, shared with the dropdown list so the fix action and the hide-while-fixing agree. */ - get ciFixModel(): IBlockedSessionsCIFixModel { + get ciFixModel(): BlockedSessionsCIFixModel { return this._ciFixModel; } @@ -106,16 +106,15 @@ export class BlockedSessionsIndicatorModel extends Disposable { @ISessionsService private readonly _sessionsService: ISessionsService, @IInstantiationService instantiationService: IInstantiationService, @IProductService productService: IProductService, - @IBlockedSessionsCIFixModel sharedCIFixModel: IBlockedSessionsCIFixModel, ) { super(); - // The model owns the approval and blocked-session models it creates. The CI-fix - // model is a shared service so every surface uses one in-flight submission guard. - // Optional parameters remain test seams for fixtures to supply preset instances. + // The model owns the approval model, blocked-sessions model and CI-fix model; + // the optional parameters are test seams so fixtures/tests can supply preset + // instances (only register — and thus dispose — the ones we created ourselves). this._approvalModel = approvalModel ?? this._register(instantiationService.createInstance(AgentSessionApprovalModel)); this._blockedSessionsModel = blockedSessions ?? this._register(instantiationService.createInstance(BlockedSessions)); - this._ciFixModel = ciFixModel ?? sharedCIFixModel; + this._ciFixModel = ciFixModel ?? this._register(instantiationService.createInstance(BlockedSessionsCIFixModel)); // The blocked-sessions feature is only enabled outside of stable builds. const enabled = productService.quality !== 'stable'; diff --git a/src/vs/sessions/contrib/sessions/browser/omniCIFailureContribution.ts b/src/vs/sessions/contrib/sessions/browser/omniCIFailureContribution.ts deleted file mode 100644 index aa1457d5e5a908..00000000000000 --- a/src/vs/sessions/contrib/sessions/browser/omniCIFailureContribution.ts +++ /dev/null @@ -1,90 +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 { equals } from '../../../../base/common/arrays.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { derivedOpts, IObservable } from '../../../../base/common/observable.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; -import { IChatInputWindowCIFailure, IChatInputWindowCIFailureProvider, IChatInputWindowService } from '../../../../workbench/contrib/chat/common/chatInputWindow.js'; -import { BlockedSessionReason, BlockedSessions } from '../../blockedSessions/browser/blockedSessions.js'; -import { IBlockedSessionsCIFixModel } from './blockedSessionsCIFixModel.js'; - -export class OmniCIFailureProvider extends Disposable implements IChatInputWindowCIFailureProvider { - - readonly failures: IObservable; - - constructor( - private readonly _blockedSessions: BlockedSessions, - private readonly _ciFixModel: IBlockedSessionsCIFixModel, - enabled: boolean, - ) { - super(); - - this.failures = derivedOpts({ - owner: this, - equalsFn: (a, b) => equals(a, b, (x, y) => - x.sessionResource.toString() === y.sessionResource.toString() - && x.occurrenceId === y.occurrenceId - && x.label === y.label - && x.failed === y.failed - && x.pending === y.pending - && x.updatedAt === y.updatedAt), - }, reader => { - if (!enabled) { - return []; - } - - const hiddenSessions = this._ciFixModel.hiddenSessions.read(reader); - const failures: IChatInputWindowCIFailure[] = []; - for (const blocked of this._blockedSessions.blockedSessionsWithReasons.read(reader)) { - if (blocked.reason !== BlockedSessionReason.FailingCI || hiddenSessions.has(blocked.session.sessionId)) { - continue; - } - const state = this._ciFixModel.getCIFix(blocked.session).read(reader); - if (!state) { - continue; - } - failures.push({ - sessionResource: blocked.session.resource, - occurrenceId: blocked.occurrenceId, - label: blocked.session.title.read(reader), - failed: state.failed, - pending: state.pending, - updatedAt: blocked.session.updatedAt.read(reader).getTime(), - }); - } - return failures; - }); - } - - fixCI(sessionResource: URI): void { - const blocked = this._blockedSessions.blockedSessionsWithReasons.get().find(candidate => - candidate.reason === BlockedSessionReason.FailingCI - && candidate.session.resource.toString() === sessionResource.toString()); - if (blocked) { - this._ciFixModel.fixCI(blocked.session); - } - } -} - -export class OmniCIFailureContribution extends Disposable { - - static readonly ID = 'sessions.contrib.omniCIFailure'; - - constructor( - @IChatInputWindowService chatInputWindowService: IChatInputWindowService, - @IInstantiationService instantiationService: IInstantiationService, - @IProductService productService: IProductService, - @IBlockedSessionsCIFixModel ciFixModel: IBlockedSessionsCIFixModel, - ) { - super(); - - const blockedSessions = this._register(instantiationService.createInstance(BlockedSessions)); - const provider = this._register(new OmniCIFailureProvider(blockedSessions, ciFixModel, productService.quality !== 'stable')); - this._register(chatInputWindowService.registerCIFailureProvider(provider)); - } -} diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index f040bf7697c693..6a0bd7123b3161 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -4,13 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { IViewDescriptor, IViewsRegistry, Extensions as ViewContainerExtensions, WindowEnablement, ViewContainer, IViewContainersRegistry, ViewContainerLocation } from '../../../../workbench/common/views.js'; import { localize, localize2 } from '../../../../nls.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { MenuRegistry } from '../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; @@ -24,12 +21,6 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING } from './views/sessionsList.js'; import { SessionsMouseNavigationContribution } from './sessionsMouseNavigation.js'; -import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID } from '../../../../workbench/contrib/chat/common/chatInputWindow.js'; -import { OmniChatEnabledSettingId } from '../../../../workbench/contrib/chat/common/sessionRouter.js'; -import { Menus } from '../../../browser/menus.js'; -import { OmniCIFailureContribution } from './omniCIFailureContribution.js'; -import { BlockedSessionsCIFixModel, IBlockedSessionsCIFixModel } from './blockedSessionsCIFixModel.js'; import './sessionDetailsAction.js'; import { SessionsWindowNotifier } from './sessionsWindowNotifier.js'; @@ -37,8 +28,6 @@ const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.comment const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); const SessionsContainerId = 'agentic.workbench.view.sessionsContainer'; -registerSingleton(IBlockedSessionsCIFixModel, BlockedSessionsCIFixModel, InstantiationType.Delayed); - const agentSessionsViewContainer: ViewContainer = Registry.as(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer({ id: SessionsContainerId, title: AGENT_SESSIONS_VIEW_TITLE, @@ -70,20 +59,6 @@ const sessionsViewPaneDescriptor: IViewDescriptor = { Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([sessionsViewPaneDescriptor], agentSessionsViewContainer); -MenuRegistry.appendMenuItem(Menus.SidebarSessionsHeader, { - command: { - id: CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID, - title: localize2('chat.toggleInputWindow', "Toggle Floating Chat Input Window"), - icon: Codicon.arrowCircleUpSparkle, - }, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and( - ChatContextKeys.enabled, - ContextKeyExpr.equals(`config.${OmniChatEnabledSettingId}`, true) - ), -}); - Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'sessions', properties: { @@ -99,7 +74,6 @@ Registry.as(ConfigurationExtensions.Configuration).regis registerWorkbenchContribution2(AutomationsCustomViewContribution.ID, AutomationsCustomViewContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(OmniCIFailureContribution.ID, OmniCIFailureContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(NewSessionActionViewItemContribution.ID, NewSessionActionViewItemContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionConversationsActionViewItemContribution.ID, SessionConversationsActionViewItemContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionsMouseNavigationContribution.ID, SessionsMouseNavigationContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts b/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts index d9582361062c1d..dee4c9f3cd0258 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts @@ -42,7 +42,6 @@ suite('BlockedSessionsIndicatorModel', () => { sessionsService as unknown as ISessionsService, instantiationService, productService, - ciFixModel as unknown as BlockedSessionsCIFixModel, )); // Keep the derived live so it recomputes on visibility/dismissal changes. store.add(autorun(reader => { model.blockedSessions.read(reader); })); diff --git a/src/vs/sessions/contrib/sessions/test/browser/omniCIFailureContribution.test.ts b/src/vs/sessions/contrib/sessions/test/browser/omniCIFailureContribution.test.ts deleted file mode 100644 index 53cd887822de21..00000000000000 --- a/src/vs/sessions/contrib/sessions/test/browser/omniCIFailureContribution.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ISession } from '../../../../services/sessions/common/session.js'; -import { BlockedSessionReason, BlockedSessions, IBlockedSession } from '../../../blockedSessions/browser/blockedSessions.js'; -import { IBlockedSessionsCIFixModel } from '../../browser/blockedSessionsCIFixModel.js'; -import { OmniCIFailureProvider } from '../../browser/omniCIFailureContribution.js'; -import { ISessionCIFixState } from '../../browser/views/sessionsList.js'; - -suite('OmniCIFailureProvider', () => { - - const store = ensureNoDisposablesAreLeakedInTestSuite(); - - function createProvider(enabled = true): { - provider: OmniCIFailureProvider; - blockedSessions: TestBlockedSessions; - ciFixModel: TestCIFixModel; - } { - const blockedSessions = new TestBlockedSessions(); - const ciFixModel = new TestCIFixModel(); - const provider = store.add(new OmniCIFailureProvider( - blockedSessions as unknown as BlockedSessions, - ciFixModel as unknown as IBlockedSessionsCIFixModel, - enabled, - )); - return { provider, blockedSessions, ciFixModel }; - } - - function snapshot(provider: OmniCIFailureProvider) { - return provider.failures.get().map(failure => ({ - resource: failure.sessionResource.toString(), - occurrenceId: failure.occurrenceId, - label: failure.label, - failed: failure.failed, - pending: failure.pending, - updatedAt: failure.updatedAt, - })); - } - - test('publishes only actionable failing CI sessions', () => { - const { provider, blockedSessions, ciFixModel } = createProvider(); - const failing = new TestSession('failing', 'Failing CI', 2000); - const needsInput = new TestSession('input', 'Needs Input', 3000); - const alreadyFixed = new TestSession('fixed', 'Already Fixed', 1000); - blockedSessions.setBlocked([ - blocked(needsInput, BlockedSessionReason.NeedsInput, 'needsInput'), - blocked(failing, BlockedSessionReason.FailingCI, 'failingCI:sha1'), - blocked(alreadyFixed, BlockedSessionReason.FailingCI, 'failingCI:sha2'), - ]); - ciFixModel.setState(failing, { failed: 2, pending: 1 }); - ciFixModel.setState(alreadyFixed, undefined); - - assert.deepStrictEqual(snapshot(provider), [{ - resource: 'test-session:/failing', - occurrenceId: 'failingCI:sha1', - label: 'Failing CI', - failed: 2, - pending: 1, - updatedAt: 2000, - }]); - }); - - test('updates counts and hides fixes in flight', () => { - const { provider, blockedSessions, ciFixModel } = createProvider(); - const session = new TestSession('session', 'Session', 1000); - blockedSessions.setBlocked([blocked(session, BlockedSessionReason.FailingCI, 'failingCI:sha1')]); - ciFixModel.setState(session, { failed: 1, pending: 2 }); - assert.deepStrictEqual(snapshot(provider).map(({ failed, pending }) => ({ failed, pending })), [{ failed: 1, pending: 2 }]); - - ciFixModel.setState(session, { failed: 3, pending: 0 }); - assert.deepStrictEqual(snapshot(provider).map(({ failed, pending }) => ({ failed, pending })), [{ failed: 3, pending: 0 }]); - - ciFixModel.setHidden(['session']); - assert.deepStrictEqual(snapshot(provider), []); - }); - - test('dispatches a fix once to the current failing session and respects gating', () => { - const { provider, blockedSessions, ciFixModel } = createProvider(); - const session = new TestSession('session', 'Session', 1000); - blockedSessions.setBlocked([blocked(session, BlockedSessionReason.FailingCI, 'failingCI:sha1')]); - ciFixModel.setState(session, { failed: 1, pending: 0 }); - - provider.fixCI(session.resource); - provider.fixCI(URI.parse('test-session:/missing')); - - const disabled = createProvider(false); - disabled.blockedSessions.setBlocked([blocked(session, BlockedSessionReason.FailingCI, 'failingCI:sha1')]); - disabled.ciFixModel.setState(session, { failed: 1, pending: 0 }); - assert.deepStrictEqual({ - fixed: ciFixModel.fixedSessionIds, - disabledFailures: snapshot(disabled.provider), - }, { - fixed: ['session'], - disabledFailures: [], - }); - }); -}); - -function blocked(session: TestSession, reason: BlockedSessionReason, occurrenceId: string): IBlockedSession { - return { session: session as unknown as ISession, reason, occurrenceId }; -} - -class TestSession { - readonly resource: URI; - readonly title: IObservable; - readonly updatedAt: IObservable; - - constructor( - readonly sessionId: string, - title: string, - updatedAt: number, - ) { - this.resource = URI.parse(`test-session:/${sessionId}`); - this.title = observableValue(`test.title.${sessionId}`, title); - this.updatedAt = observableValue(`test.updatedAt.${sessionId}`, new Date(updatedAt)); - } -} - -class TestBlockedSessions { - private readonly _blocked = observableValue('test.blocked', []); - readonly blockedSessionsWithReasons: IObservable = this._blocked; - - setBlocked(blocked: readonly IBlockedSession[]): void { - this._blocked.set(blocked, undefined); - } -} - -class TestCIFixModel { - private readonly _hidden = observableValue>('test.hidden', new Set()); - readonly hiddenSessions: IObservable> = this._hidden; - private readonly _states = new Map>(); - readonly fixedSessionIds: string[] = []; - - getCIFix(session: ISession): IObservable { - return this._stateFor(session); - } - - private _stateFor(session: ISession): ISettableObservable { - let state = this._states.get(session); - if (!state) { - state = observableValue(`test.ci.${session.sessionId}`, undefined); - this._states.set(session, state); - } - return state; - } - - setState(session: TestSession, state: ISessionCIFixState | undefined): void { - this._stateFor(session as unknown as ISession).set(state, undefined); - } - - setHidden(sessionIds: readonly string[]): void { - this._hidden.set(new Set(sessionIds), undefined); - } - - fixCI(session: ISession): void { - this.fixedSessionIds.push(session.sessionId); - } -} diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index bb948f1594d29f..3ee3a41457de05 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -221,8 +221,6 @@ import '../workbench/contrib/speech/browser/speech.contribution.js'; // Chat import '../workbench/contrib/chat/browser/chat.shared.contribution.js'; -import '../workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.js'; -import './contrib/chat/browser/omniSessionRoutingAdapter.contribution.js'; //import '../workbench/contrib/inlineChat/browser/inlineChat.contribution.js'; import '../workbench/contrib/mcp/browser/mcp.contribution.js'; import '../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; diff --git a/src/vs/workbench/browser/window.ts b/src/vs/workbench/browser/window.ts index b23f9e55dcd062..09cddff2956470 100644 --- a/src/vs/workbench/browser/window.ts +++ b/src/vs/workbench/browser/window.ts @@ -154,9 +154,8 @@ export abstract class BaseWindow extends Disposable { didClear = true; (window as { vscodeOriginalClearTimeout?: typeof window.clearTimeout }).vscodeOriginalClearTimeout?.apply(this, [handle]); timeoutDisposables.delete(timeoutDisposable); - // Remove from the window's DisposableStore. Re-disposal is a no-op and - // avoids re-registering the already-disposed timeout as a leak. - disposables.delete(timeoutDisposable); + // Remove from the window's DisposableStore without re-disposing (we're already inside dispose) + disposables.deleteAndLeak(timeoutDisposable); }); disposables.add(timeoutDisposable); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index e7ccadceae9b73..a5a281f743ac3c 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -257,7 +257,6 @@ registerAction2(class extends Action2 { const voiceController = accessor.get(IVoiceSessionController); const keybindingService = accessor.get(IKeybindingService); const handsFree = accessor.get(IConfigurationService).getValue('agents.voice.handsFree') === true; - const omniHasFocus = accessor.get(IContextKeyService).getContextKeyValue(ChatContextKeys.inChatInputWindow.key) === true; const activeWindow = getActiveWindow(); voiceController.setActiveWindow(activeWindow); @@ -273,13 +272,8 @@ registerAction2(class extends Action2 { // An explicit press in another composer transfers Voice Mode ownership to // that composer. The draft sentinel deliberately clears the concrete target. - const currentSession = omniHasFocus - ? undefined - : await accessor.get(ICommandService).executeCommand('_chat.voice.getCurrentSession'); - voiceController.setOmniInputActive(omniHasFocus); - if (omniHasFocus) { - voiceController.setDraftTarget(); - } else if (currentSession) { + const currentSession = await accessor.get(ICommandService).executeCommand('_chat.voice.getCurrentSession'); + if (currentSession) { try { const resource = URI.parse(currentSession); if (resource.scheme === 'sessions-voice') { diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts index 73b189f0ac5200..3a1a836156204f 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts @@ -167,7 +167,6 @@ export class AgentsVoiceWidget extends Disposable { private readonly _pttKeyLabel: ISettableObservable = observableValue(this, undefined); private readonly _statusText: ISettableObservable = observableValue(this, ''); private readonly _popoutAvailable: ISettableObservable = observableValue(this, true); - private readonly _voiceControlsSuppressed: ISettableObservable = observableValue(this, false); private readonly _feedbackDialogState: ISettableObservable = observableValue(this, null); private readonly _showOnboarding: ISettableObservable = observableValue(this, false); private readonly _onboardingPendingConnect: ISettableObservable = observableValue(this, false); @@ -630,7 +629,6 @@ export class AgentsVoiceWidget extends Disposable { private _updateDOMInputBoxLayout(reader: IReader): void { const voiceState = this._voiceState.read(reader); - const voiceControlsSuppressed = this._voiceControlsSuppressed.read(reader); const isConnected = this._isConnected.read(reader); const isConnecting = this._isConnecting.read(reader); const isReconnecting = this._isReconnecting.read(reader); @@ -688,19 +686,19 @@ export class AgentsVoiceWidget extends Disposable { this._feedbackDialogComponent.element.style.display = 'none'; // Input box container — show transcript inside or placeholder - this._inputBoxContainer!.style.display = voiceControlsSuppressed ? 'none' : 'flex'; + this._inputBoxContainer!.style.display = 'flex'; const transcriptTurns = this._transcriptTurns.read(reader); const hasTranscript = transcriptTurns.some(t => t.text.length > 0 || (t.speaker === 'user' && t.isPartial)); // The ambient glow is owned by the glow controller; clear it whenever the // input box shouldn't be lit so no stale frame is left behind. - const shouldShowInputGlow = !voiceControlsSuppressed && showConnected && (voiceState === 'listening' || voiceState === 'speaking'); + const shouldShowInputGlow = showConnected && (voiceState === 'listening' || voiceState === 'speaking'); if (!shouldShowInputGlow) { this._glowController?.clear(); } // Toggle processing comet animation when agent is thinking - this._inputBoxContainer!.classList.toggle('processing', !voiceControlsSuppressed && voiceState === 'processing'); + this._inputBoxContainer!.classList.toggle('processing', voiceState === 'processing'); if (hasTranscript) { if (showExpanded) { @@ -785,7 +783,7 @@ export class AgentsVoiceWidget extends Disposable { this._inputBoxToolbar!.style.display = 'flex'; // Mic button — always visible (primary action) - this._inputBoxMicBtn!.style.display = voiceControlsSuppressed ? 'none' : ''; + this._inputBoxMicBtn!.style.display = ''; const keyLabel = this._pttKeyLabel.read(reader); const micTooltip = keyLabel ? localize('agentsVoice.pushToTalkKey', "Push to talk ({0})", keyLabel) @@ -808,12 +806,12 @@ export class AgentsVoiceWidget extends Disposable { this._inputBoxMicBtn!.onmouseup = (e: MouseEvent) => { if (isSecondaryPointerGesture(e)) { return; } this.callbacks.pttUp(); }; // Connection indicator — visible when connected - this._inputBoxConnIndicator!.style.display = !voiceControlsSuppressed && showConnected ? '' : 'none'; + this._inputBoxConnIndicator!.style.display = showConnected ? '' : 'none'; this._inputBoxConnIndicator!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.disconnect(); }; // Mute microphone button — visible when connected, keeps the session alive const muted = this._isMuted.read(reader); - this._inputBoxMuteBtn!.style.display = !voiceControlsSuppressed && showConnected ? '' : 'none'; + this._inputBoxMuteBtn!.style.display = showConnected ? '' : 'none'; this._inputBoxMuteBtn!.classList.toggle('codicon-mic', !muted); this._inputBoxMuteBtn!.classList.toggle('codicon-mute', muted); const muteColor = muted ? 'var(--vscode-editorError-foreground)' : 'var(--vscode-descriptionForeground)'; @@ -829,7 +827,6 @@ export class AgentsVoiceWidget extends Disposable { this._inputBoxMuteBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.toggleMute(); }; // Feedback button — always visible - this._inputBoxFeedbackBtn!.style.display = voiceControlsSuppressed ? 'none' : ''; this._inputBoxFeedbackBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this._toggleFeedbackDialog(); }; // Sessions button — always visible, icon toggles with expanded state @@ -1052,10 +1049,6 @@ export class AgentsVoiceWidget extends Disposable { this._statusText.set(text, undefined); } - setVoiceControlsSuppressed(suppressed: boolean): void { - this._voiceControlsSuppressed.set(suppressed, undefined); - } - setPopoutAvailable(available: boolean): void { this._popoutAvailable.set(available, undefined); } diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts index 26a7355f67b352..62f94629cebbc4 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidgetBinding.ts @@ -56,23 +56,21 @@ export function bindWidgetToController(widget: AgentsVoiceWidget, services: IWid const statusText = controller.statusText.read(reader); const turns = controller.transcriptTurns.read(reader); const targetSession = controller.targetSession.read(reader); - const omniInputOpen = controller.omniInputOpen.read(reader); widget.setConnected(connected); widget.setConnecting(connecting); widget.setReconnecting(reconnecting); widget.setMuted(muted); - widget.setVoiceControlsSuppressed(omniInputOpen); - widget.setVoiceState(omniInputOpen ? 'idle' : state); + widget.setVoiceState(state); widget.setPendingToolConfirmations(toolConfirmations); // Respect showTranscript setting — hide transcript when disabled const showTranscript = configurationService?.getValue('agents.voice.showTranscript') !== false; - widget.setTranscriptTurns(!omniInputOpen && showTranscript ? turns : []); + widget.setTranscriptTurns(showTranscript ? turns : []); widget.setStatusText(statusText); widget.setSelectedTargetSession(targetSession); // Resolve speaking session label from the model - if (speakingSession && !omniInputOpen) { + if (speakingSession) { const sessions = agentSessionsService.model.sessions; const match = sessions.find(s => s.resource.toString() === speakingSession.toString()); widget.setSpeakingSession(speakingSession, match?.label); @@ -149,7 +147,7 @@ function _updateSessionData(widget: AgentsVoiceWidget, services: IWidgetBindingS // Show all non-archived sessions so the user can target any for transcription. const sessions = agentSessionsService.model.sessions.filter(s => !s.isArchived()); const toolConfirmations = voiceSessionController.pendingToolConfirmations.get(); - const speakingSession = voiceSessionController.omniInputOpen.get() ? undefined : voicePlaybackService.speakingSession.get(); + const speakingSession = voicePlaybackService.speakingSession.get(); // Sort: NeedsInput first, then InProgress, then Completed; most recent first within const statusOrder = (s: typeof sessions[0]) => diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/sessionListComponent.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/sessionListComponent.ts index 45595ffeb0868b..4b5bb4076851b0 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/sessionListComponent.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/sessionListComponent.ts @@ -37,14 +37,6 @@ export interface SessionListProps { readonly onNewSession: () => void; } -export function getSessionListNavigationIndex(index: number, direction: 'up' | 'down', count: number): number | undefined { - if (count === 0) { - return undefined; - } - const delta = direction === 'up' ? -1 : 1; - return (index + delta + count) % count; -} - function hoverIcon(className: string, ariaLabel: string): HTMLElement { const el = dom.$(`span.codicon.${className}`); el.role = 'button'; @@ -92,19 +84,6 @@ function createSessionRow(session: SessionRowData, props: SessionListProps): HTM if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); row.click(); - } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { - e.preventDefault(); - const container = row.parentElement?.parentElement; - const rows = Array.from(container?.children ?? []) - .map(child => child.firstElementChild) - .filter((child): child is HTMLElement => dom.isHTMLElement(child) && child.role === 'option'); - const nextIndex = getSessionListNavigationIndex(rows.indexOf(row), e.key === 'ArrowUp' ? 'up' : 'down', rows.length); - if (nextIndex !== undefined) { - rows[nextIndex].focus(); - if (rows[nextIndex].getAttribute('aria-selected') !== 'true') { - rows[nextIndex].click(); - } - } } }); diff --git a/src/vs/workbench/contrib/agentsVoice/test/browser/sessionListComponent.test.ts b/src/vs/workbench/contrib/agentsVoice/test/browser/sessionListComponent.test.ts deleted file mode 100644 index 9952152cb16540..00000000000000 --- a/src/vs/workbench/contrib/agentsVoice/test/browser/sessionListComponent.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { getSessionListNavigationIndex } from '../../browser/components/sessionListComponent.js'; - -suite('Session list component', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('navigates and wraps through session rows', () => { - assert.deepStrictEqual([ - getSessionListNavigationIndex(0, 'up', 3), - getSessionListNavigationIndex(2, 'down', 3), - getSessionListNavigationIndex(0, 'down', 0), - ], [2, 0, undefined]); - }); -}); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 6dc3c261688d8f..56a0207afbb114 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -43,16 +43,6 @@ export class QuickChatAccessibilityHelp implements IAccessibleViewImplementation } } -export class ChatInputWindowAccessibilityHelp implements IAccessibleViewImplementation { - readonly priority = 121; - readonly name = 'chatInputWindow'; - readonly type = AccessibleViewType.Help; - readonly when = ChatContextKeys.inChatInputWindow; - getProvider(accessor: ServicesAccessor) { - return getChatAccessibilityHelpProvider(accessor, undefined, 'chatInputWindow'); - } -} - export class EditsChatAccessibilityHelp implements IAccessibleViewImplementation { readonly priority = 119; readonly name = 'editsView'; @@ -73,17 +63,8 @@ export class AgentChatAccessibilityHelp implements IAccessibleViewImplementation } } -export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView' | 'chatInputWindow', keybindingService: IKeybindingService, supportsFileReferences: boolean, isSessionsWindow: boolean = false, stickyPromptHeaderShown: boolean = false): string { +export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', keybindingService: IKeybindingService, supportsFileReferences: boolean, isSessionsWindow: boolean = false, stickyPromptHeaderShown: boolean = false): string { const content = []; - if (type === 'chatInputWindow') { - content.push(localize('chatInputWindow.overview', 'The floating chat input window is an input-only surface. It has no response list; instead each request you submit is routed to the coding session it best matches, and its response appears in that session rather than here.')); - content.push(localize('chatInputWindow.routing', 'When no existing session is a confident match, a new session is started for the request. When an existing session matches, a destination picker appears with a countdown before sending. Press Escape from the input box to cancel, or use Tab to reach the picker, the arrow keys to move, Space to select more than one destination, and Enter to send. The picker also offers starting a new session.')); - content.push(localize('chatInputWindow.requestHistory', 'In the input box, use up and down arrows to navigate your request history. Edit input and use Enter or the submit button to route a new request.')); - content.push(localize('chatInputWindow.dictate', 'To dictate your request using on-device speech-to-text, invoke the Dictate command{0}. Invoke it again to stop.', '')); - content.push(localize('chatInputWindow.close', 'To close the floating chat input window, invoke the Close Floating Chat Input Window command, or toggle it with the Toggle Floating Chat Input Window command{0}.', '')); - content.push(localize('chatInputWindow.signals', "Accessibility Signals can be changed via settings with a prefix of signals.chat. By default, if a request takes more than 4 seconds, you will hear a sound indicating that progress is still occurring.")); - return content.join('\n'); - } if (type === 'panelChat' || type === 'quickChat' || type === 'editsView' || type === 'agentView') { content.push(localize('chat.fileChangesDisclosure', 'File change summaries show the total files, additions, and deletions. Focus the disclosure and press Enter or Space to show or hide the individual files. Focus an additions and deletions label and press Enter or Space to open the changes in a diff editor.')); } @@ -199,7 +180,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'qui return content.join('\n'); } -export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, editor: ICodeEditor | undefined, type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView' | 'chatInputWindow'): AccessibleContentProvider | undefined { +export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, editor: ICodeEditor | undefined, type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView'): AccessibleContentProvider | undefined { const widgetService = accessor.get(IChatWidgetService); const keybindingService = accessor.get(IKeybindingService); const environmentService = accessor.get(IWorkbenchEnvironmentService); @@ -217,19 +198,13 @@ export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, edi const cachedPosition = inputEditor.getPosition(); inputEditor.getSupportedActions(); - const helpText = getAccessibilityHelpText( - type, - keybindingService, - widget.supportsFileReferences, - environmentService.isSessionsWindow, - isStickyPromptHeaderShown(widget, configurationService) - ); + const helpText = getAccessibilityHelpText(type, keybindingService, widget.supportsFileReferences, environmentService.isSessionsWindow, isStickyPromptHeaderShown(widget, configurationService)); return new AccessibleContentProvider( - type === 'panelChat' ? AccessibleViewProviderId.PanelChat : type === 'inlineChat' ? AccessibleViewProviderId.InlineChat : type === 'agentView' ? AccessibleViewProviderId.AgentChat : type === 'chatInputWindow' ? AccessibleViewProviderId.ChatInputWindow : AccessibleViewProviderId.QuickChat, + type === 'panelChat' ? AccessibleViewProviderId.PanelChat : type === 'inlineChat' ? AccessibleViewProviderId.InlineChat : type === 'agentView' ? AccessibleViewProviderId.AgentChat : AccessibleViewProviderId.QuickChat, { type: AccessibleViewType.Help }, () => helpText, () => { - if (type === 'quickChat' || type === 'editsView' || type === 'agentView' || type === 'panelChat' || type === 'chatInputWindow') { + if (type === 'quickChat' || type === 'editsView' || type === 'agentView' || type === 'panelChat') { if (cachedPosition) { inputEditor.setPosition(cachedPosition); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts index b5778352789488..9e01a27d9e7c38 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts @@ -5,21 +5,15 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { ResourceSet } from '../../../../../base/common/map.js'; import { isElectron } from '../../../../../base/common/platform.js'; -import { extUriBiasedIgnorePathCase, IExtUri } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; import { IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; -import { ITerminalCommand, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; -import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/editor.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; @@ -40,9 +34,10 @@ import { ChatInstructionsPickerPick } from '../promptSyntax/attachInstructionsAc import { IChatSessionsService, isAgentHostTarget } from '../../common/chatSessionsService.js'; import { getAgentSessionProviderIcon, AgentSessionProviders } from '../agentSessions/agentSessions.js'; import { ITerminalService } from '../../../terminal/browser/terminal.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ITerminalCommand, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { buildHostLocalEventsPath } from '../copilotCliEventsUri.js'; -import { IChatSessionRoutingProviderService, IRoutableSession } from '../../common/sessionRouter.js'; /** * Command ID that extensions can call to enable debug tools for the current @@ -68,25 +63,6 @@ export function shouldShowOpenEditorsContext(widget: Pick; - -export function isSameSessionWorkspace(current: SessionWorkspaceIdentity, candidate: SessionWorkspaceIdentity, extUri: IExtUri = extUriBiasedIgnorePathCase): boolean { - const normalizeRepository = (value: string | undefined) => value?.replace(/[\\/]+$/, '').toLowerCase(); - const currentRepo = normalizeRepository(current.repo); - const candidateRepo = normalizeRepository(candidate.repo); - if (currentRepo && candidateRepo) { - return currentRepo === candidateRepo; - } - - return !!current.cwd && !!candidate.cwd && extUri.isEqual(URI.file(current.cwd), URI.file(candidate.cwd)); -} - -export function getSessionWorkspaceName(workspace: SessionWorkspaceIdentity): string { - const repoName = workspace.repo?.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1); - const folderName = workspace.cwd?.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1); - return repoName || folderName || localize('chatContext.sessions.thisWorkspace', "This Workspace"); -} - export class ChatContextContributions extends Disposable implements IWorkbenchContribution { static readonly ID = 'chat.contextContributions'; @@ -355,9 +331,6 @@ class SessionReferenceContextPickerPick implements IChatContextPickerItem { @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, @IPathService private readonly _pathService: IPathService, @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, - @IChatSessionRoutingProviderService private readonly _routingProviderService: IChatSessionRoutingProviderService, - @ILogService private readonly _logService: ILogService, - @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, ) { } isEnabled(widget: IChatWidget): boolean { @@ -370,71 +343,12 @@ class SessionReferenceContextPickerPick implements IChatContextPickerItem { return { placeholder: localize('chatContext.sessions.placeholder', 'Select a session'), picks: (async () => { - const entries: { pick: IChatContextPickerPickItem; lastActivity: number; workspace: SessionWorkspaceIdentity }[] = []; - const includedResources = new ResourceSet(resource => this._uriIdentityService.extUri.getComparisonKey(resource)); - let currentWorkspace: SessionWorkspaceIdentity | undefined; - const routingProvider = this._routingProviderService.getProvider(); - if (routingProvider) { - let currentSession: IRoutableSession | undefined; - try { - currentSession = currentSessionResource - ? await routingProvider.getSessionSnapshot?.(currentSessionResource, CancellationToken.None) - : undefined; - } catch (error) { - this._logService.warn('[chatContext] Failed to resolve the current routed session:', error); - } - if (currentSession) { - currentWorkspace = { cwd: currentSession.cwd, repo: currentSession.repo }; - } - let candidates: readonly IRoutableSession[] = []; - try { - candidates = await routingProvider.getCandidateSessions(CancellationToken.None); - } catch (error) { - this._logService.warn('[chatContext] Failed to resolve routed session attachments:', error); - } - for (const candidate of candidates) { - const sessionResource = candidate.resource ?? routingProvider.resolveSessionResource(candidate.sessionId); - if (!sessionResource) { - continue; - } - if (candidate.sessionId === currentSession?.sessionId || (currentSessionResource && this._uriIdentityService.extUri.isEqual(sessionResource, currentSessionResource))) { - currentWorkspace = { cwd: candidate.cwd, repo: candidate.repo }; - continue; - } - if (onlyShowAttachableCopilotCliSessions && !this._canAttachCopilotCliSession(sessionResource)) { - continue; - } - includedResources.add(sessionResource); - const pick: IChatContextPickerPickItem = { - label: candidate.label, - description: candidate.lastActivity ? new Date(candidate.lastActivity).toLocaleString() : undefined, - asAttachment: (): IChatRequestVariableEntry => ({ - kind: 'generic', - id: `session:${candidate.sessionId}`, - name: candidate.label, - value: { sessionReference: true, sessionResource: sessionResource.toString() }, - }), - }; - entries.push({ - pick, - lastActivity: candidate.lastActivity ?? 0, - workspace: { cwd: candidate.cwd, repo: candidate.repo }, - }); - } - } + const picks: { pick: IChatContextPickerPickItem; lastActivity: number }[] = []; const sessionProviderFilter = [AgentSessionProviders.Local, AgentSessionProviders.Background, AgentSessionProviders.AgentHostCopilot]; for await (const group of this._chatSessionsService.getChatSessionItems(sessionProviderFilter, CancellationToken.None)) { const providerIcon = getAgentSessionProviderIcon(group.chatSessionType); for (const item of group.items) { - const workspace = { - cwd: item.metadata?.workingDirectoryPath ?? item.metadata?.worktreePath, - repo: item.metadata?.repositoryPath, - }; - if (currentSessionResource && this._uriIdentityService.extUri.isEqual(item.resource, currentSessionResource)) { - currentWorkspace ??= workspace; - continue; - } - if (includedResources.has(item.resource)) { + if (currentSessionResource && item.resource.toString() === currentSessionResource.toString()) { continue; } const sessionResource = item.resource; @@ -443,40 +357,24 @@ class SessionReferenceContextPickerPick implements IChatContextPickerItem { } const icon = item.iconPath ?? providerIcon; const lastActivity = item.timing.lastRequestEnded ?? item.timing.created; - const pick: IChatContextPickerPickItem = { - label: item.label, - description: new Date(lastActivity).toLocaleString(), - asAttachment: (): IChatRequestVariableEntry => ({ - kind: 'sessionReference', - id: sessionResource.toString(), - name: item.label, - value: sessionResource, - icon, - }) - }; - entries.push({ pick, lastActivity, workspace }); + picks.push({ + lastActivity, + pick: { + label: item.label, + description: new Date(lastActivity).toLocaleString(), + asAttachment: (): IChatRequestVariableEntry => ({ + kind: 'sessionReference', + id: sessionResource.toString(), + name: item.label, + value: sessionResource, + icon, + }) + } + }); } } - entries.sort((a, b) => b.lastActivity - a.lastActivity); - if (!currentSessionResource || (!currentWorkspace?.cwd && !currentWorkspace?.repo)) { - return entries.map(entry => entry.pick); - } - - const sameWorkspace = entries.filter(entry => isSameSessionWorkspace(currentWorkspace, entry.workspace, this._uriIdentityService.extUri)); - const otherWorkspaces = entries.filter(entry => !isSameSessionWorkspace(currentWorkspace, entry.workspace, this._uriIdentityService.extUri)); - if (otherWorkspaces.length === 0) { - return sameWorkspace.map(entry => entry.pick); - } - const groupedPicks: (IChatContextPickerPickItem | IQuickPickSeparator)[] = []; - if (sameWorkspace.length > 0) { - groupedPicks.push({ type: 'separator', label: getSessionWorkspaceName(currentWorkspace) }); - groupedPicks.push(...sameWorkspace.map(entry => entry.pick)); - } - if (otherWorkspaces.length > 0) { - groupedPicks.push({ type: 'separator', label: localize('chatContext.sessions.otherWorkspaces', "Other Workspaces") }); - groupedPicks.push(...otherWorkspaces.map(entry => entry.pick)); - } - return groupedPicks; + picks.sort((a, b) => b.lastActivity - a.lastActivity); + return picks.map(({ pick }) => pick); })() }; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts index 7f9ab1c9d85df2..64d9148614a5cd 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts @@ -50,7 +50,7 @@ import { IChatWidget, IChatWidgetService, IQuickChatService } from '../chat.js'; import { IChatContextPickerItem, IChatContextPickService, IChatContextValueItem, isChatContextPickerPickItem } from '../attachments/chatContextPickService.js'; import { IChatExecuteActionContext } from './chatExecuteActions.js'; import { IChatAttachmentResolveService } from '../attachments/chatAttachmentResolveService.js'; -import { isChatInputWindow, isQuickChat } from '../widget/chatWidget.js'; +import { isQuickChat } from '../widget/chatWidget.js'; import { resizeImage } from '../chatImageUtils.js'; import { registerPromptActions } from '../promptSyntax/promptFileActions.js'; import { CHAT_CATEGORY } from './chatActions.js'; @@ -557,8 +557,7 @@ export class AttachContextAction extends Action2 { }); } - const quickInputService = await (context?.contextPicker ?? widget.contextPicker)?.prepare(); - instantiationService.invokeFunction(this._show.bind(this), widget, quickPickItems, context?.placeholder, quickInputService); + instantiationService.invokeFunction(this._show.bind(this), widget, quickPickItems, context?.placeholder); } private _show(accessor: ServicesAccessor, widget: IChatWidget, additionPicks: IContextPickItemItem[] | undefined, placeholder?: string, quickInputServiceOverride?: IQuickInputService) { @@ -596,7 +595,7 @@ export class AttachContextAction extends Action2 { } else { instantiationService.invokeFunction(this._handleQPPick.bind(this), widget, isBackgroundAccept, item); } - if (isQuickChat(widget) && !isChatInputWindow(widget)) { + if (isQuickChat(widget)) { quickChatService.open(); } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index d308825d25f96b..1882af7904d22a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -33,7 +33,7 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; -import { type IChatAcceptInputOptions, IChatContextPickerDelegate, IChatWidget, IChatWidgetService } from '../chat.js'; +import { type IChatAcceptInputOptions, IChatWidget, IChatWidgetService } from '../chat.js'; import { getAgentSessionProvider, AgentSessionProviders, AgentSessionTarget } from '../agentSessions/agentSessions.js'; import { getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; import { ctxHasEditorModification, ctxHasRequestInProgress, ctxIsGlobalEditingSession } from '../chatEditing/chatEditingEditorContextKeys.js'; @@ -49,7 +49,6 @@ export interface IChatExecuteActionContext { inputValue?: string; acceptInputOptions?: IChatAcceptInputOptions; voice?: IVoiceChatExecuteActionContext; - contextPicker?: IChatContextPickerDelegate; } abstract class SubmitAction extends Action2 { @@ -195,9 +194,6 @@ export class ChatSubmitAction extends SubmitAction { ChatContextKeys.inputHasSendableContent, ContextKeyExpr.or(whenNotInProgress, ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.Sent)), ChatContextKeys.chatSessionOptionsValid, - // A submission that is being routed/dispatched off-model (omni-chat) - // disables sending until it resolves or the draft changes. - ChatContextKeys.inputSubmitPending.negate(), ); super({ @@ -228,7 +224,6 @@ export class ChatSubmitAction extends SubmitAction { whenNoActiveRequest, menuCondition, ChatContextKeys.withinEditSessionDiff.negate(), - ChatContextKeys.inputSubmitPending.negate(), ), group: 'navigation', alt: { @@ -250,33 +245,6 @@ export class ChatSubmitAction extends SubmitAction { } } -class ChatSubmitPendingAction extends Action2 { - static readonly ID = 'workbench.action.chat.submitPending'; - - constructor() { - super({ - id: ChatSubmitPendingAction.ID, - title: localize2('interactive.submitPending.label', "Sending Request…"), - f1: false, - category: CHAT_CATEGORY, - icon: ThemeIcon.modify(Codicon.loading, 'spin'), - precondition: ChatContextKeys.inputSubmitPending, - menu: { - id: MenuId.ChatExecute, - order: 4, - when: ContextKeyExpr.and( - whenNoActiveRequest, - ChatContextKeys.withinEditSessionDiff.negate(), - ChatContextKeys.inputSubmitPending, - ), - group: 'navigation', - }, - }); - } - - run(): void { } -} - export const ToggleAgentModeActionId = 'workbench.action.chat.toggleAgentMode'; @@ -763,8 +731,7 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { const precondition = ContextKeyExpr.and( ChatContextKeys.inputHasSendableContent, notInProgressOrEditing, - ChatContextKeys.chatSessionOptionsValid, - ChatContextKeys.inputSubmitPending.negate(), + ChatContextKeys.chatSessionOptionsValid ); super({ @@ -780,8 +747,7 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { order: 4, when: ContextKeyExpr.and( notInProgressOrEditing, - menuCondition, - ChatContextKeys.inputSubmitPending.negate()), + menuCondition), group: 'navigation', alt: { id: 'workbench.action.chat.sendToNewChat', @@ -1202,7 +1168,6 @@ class ExecuteHandoffAction extends Action2 { export function registerChatExecuteActions(): DisposableStore { const store = new DisposableStore(); store.add(registerAction2(ChatSubmitAction)); - store.add(registerAction2(ChatSubmitPendingAction)); store.add(registerAction2(ChatEditingSessionSubmitAction)); store.add(registerAction2(SubmitWithoutDispatchingAction)); store.add(registerAction2(CancelAction)); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts index 9cc3e961dd3548..dbb267b6dc9770 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts @@ -130,7 +130,6 @@ export class ConfigureToolsAction extends Action2 { when: ContextKeyExpr.and( ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ChatContextKeys.lockedToCodingAgent.negate(), - ChatContextKeys.inChatInputWindow.negate(), ), id: MenuId.ChatInput, group: 'navigation', 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 0e16beea20f14f..bba09c727a8585 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -3726,7 +3726,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } else if (status === ToolCallStatus.PendingConfirmation) { // The protocol can refresh a pending tool's command without an // intervening status transition. Refresh the whole presentation, not - // just its message, so Omni and voice expose the command that is + // just its message, so voice exposes the command that is // actually awaiting approval while preserving the current gate. const prepared = toolCallStateToPreparedInvocation(tc, opts.backendSession, this._config.connectionAuthority, opts.sessionResource.authority); invocation.updatePreparedInvocation(prepared, invocation.parameters); 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 fc31c33734e72e..3917e49d3ca58d 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -78,7 +78,6 @@ import { HOOK_SCHEMA_URI, hookFileSchema } from '../common/promptSyntax/hookSche import { AGENT_DOCUMENTATION_URL, AgentHostAgentDebugLogEnabledSettingId, AgentHostAgentDebugLogMaxEventsSettingId, HOOK_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, PromptFileSource, PromptsType, SKILL_DOCUMENTATION_URL } from '../common/promptSyntax/promptTypes.js'; import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../common/promptSyntax/service/promptsServiceImpl.js'; -import { ISessionRouter } from '../common/sessionRouter.js'; import { BuiltinToolsContribution } from '../common/tools/builtinTools/tools.js'; import { ChatArtifactsService, IChatArtifactsService } from '../common/tools/chatArtifactsService.js'; import { ChatTodoListService, IChatTodoListService } from '../common/tools/chatTodoListService.js'; @@ -91,7 +90,7 @@ import { IChatLayoutService } from '../common/widget/chatLayoutService.js'; import { ChatResponseResourceFileSystemProvider, ChatResponseResourceWorkbenchContribution, IChatResponseResourceFileSystemProvider } from '../common/widget/chatResponseResourceFileSystemProvider.js'; import { ChatWidgetHistoryService, IChatWidgetHistoryService } from '../common/widget/chatWidgetHistoryService.js'; import { registerChatAccessibilityActions } from './actions/chatAccessibilityActions.js'; -import { AgentChatAccessibilityHelp, ChatInputWindowAccessibilityHelp, EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; +import { AgentChatAccessibilityHelp, EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; import { ModeOpenChatGlobalAction, registerChatActions } from './actions/chatActions.js'; import { ChatAgentRecommendation } from './actions/chatAgentRecommendationActions.js'; import { CodeBlockActionRendering, registerChatCodeBlockActions, registerChatCodeCompareBlockActions } from './actions/chatCodeblockActions.js'; @@ -126,8 +125,6 @@ import { ChatGoalSummaryService, IChatGoalSummaryService } from './chatGoalSumma import { ChatSubmitRequestHandlerService, IChatSubmitRequestHandlerService } from './chatSubmitRequestHandlerService.js'; import { PromptsDebugContribution } from './promptsDebugContribution.js'; import { PromptLanguageFeaturesProvider } from './promptSyntax/promptFileContributions.js'; -import './sessionRouter/chatSessionRoutingProviderService.js'; -import { SessionRouterService } from './sessionRouter/sessionRouterService.js'; import { ChatSpeechToTextService, DictationSettingId, IChatSpeechToTextService } from './speechToText/chatSpeechToTextService.js'; import './telemetry/chatModelCountTelemetry.js'; import { ChatToolRiskAssessmentService, IChatToolRiskAssessmentService } from './tools/chatToolRiskAssessmentService.js'; @@ -265,12 +262,6 @@ configurationRegistry.registerConfiguration({ tags: ['experimental'], agentsWindow: { default: true }, }, - 'chat.omni.enabled': { - type: 'boolean', - markdownDescription: nls.localize('chat.omni.enabled', "Enables the floating chat input window and its entry points. Requests submitted from the window are scored against existing agent sessions and routed with an advisory badge."), - default: false, - tags: ['experimental'] - }, 'chat.fontSize': { type: 'number', description: nls.localize('chat.fontSize', "Controls the font size in pixels in chat messages."), @@ -2969,7 +2960,6 @@ AccessibleViewRegistry.register(new PanelChatAccessibilityHelp()); AccessibleViewRegistry.register(new QuickChatAccessibilityHelp()); AccessibleViewRegistry.register(new EditsChatAccessibilityHelp()); AccessibleViewRegistry.register(new AgentChatAccessibilityHelp()); -AccessibleViewRegistry.register(new ChatInputWindowAccessibilityHelp()); AccessibleViewRegistry.register(new ChatFindAccessibilityHelp()); registerEditorFeature(ChatInputBoxContentProvider); @@ -3082,7 +3072,6 @@ registerSingleton(IChatAccessibilityService, ChatAccessibilityService, Instantia registerSingleton(IChatWidgetHistoryService, ChatWidgetHistoryService, InstantiationType.Delayed); registerSingleton(ILanguageModelsConfigurationService, LanguageModelsConfigurationService, InstantiationType.Delayed); registerSingleton(ILanguageModelsService, LanguageModelsService, InstantiationType.Delayed); -registerSingleton(ISessionRouter, SessionRouterService, InstantiationType.Delayed); registerSingleton(ILanguageModelStatsService, LanguageModelStatsService, InstantiationType.Delayed); registerSingleton(IChatSlashCommandService, ChatSlashCommandService, InstantiationType.Delayed); registerSingleton(IChatAgentService, ChatAgentService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 1fd97c9e640093..7dad455e5d18b0 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -4,9 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IMouseWheelEvent } from '../../../../base/browser/mouseEvent.js'; -import { IAnchor } from '../../../../base/browser/ui/contextview/contextview.js'; import { Event } from '../../../../base/common/event.js'; -import { AnchorPosition } from '../../../../base/common/layout.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; @@ -16,7 +14,6 @@ import { EditDeltaInfo } from '../../../../editor/common/textModelEditSource.js' import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { PreferredGroup } from '../../../services/editor/common/editorService.js'; import { IChatRequestVariableEntry } from '../common/attachments/chatVariableEntries.js'; import { IDynamicVariable } from '../common/attachments/chatVariables.js'; @@ -38,10 +35,6 @@ import { AgentSessionTarget } from './agentSessions/agentSessions.js'; export { ChatOutline } from './chatOutline.js'; -export interface IChatContextPickerDelegate { - prepare(): Promise; -} - /** * A workspace item that can be selected in the workspace picker. */ @@ -242,7 +235,6 @@ export type ChatTreeItem = IChatRequestViewModel | IChatResponseViewModel | ICha export interface IChatListItemRendererOptions { readonly renderStyle?: 'compact' | 'minimal'; - readonly questionCarouselFitContent?: boolean; readonly noHeader?: boolean; readonly noFooter?: boolean; readonly renderDetectedCommandsWithRequest?: boolean; @@ -268,10 +260,7 @@ export interface IChatWidgetViewOptions { renderFollowups?: boolean; renderStyle?: 'compact' | 'minimal'; renderInputToolbarBelowInput?: boolean; - inputEditorMaxHeight?: number; renderGettingStartedTip?: boolean | (() => boolean); - /** Whether notifications deferred during first-use flows may render in this widget. */ - deferredNotificationsEnabled?: boolean; supportsFileReferences?: boolean; filter?: (item: ChatTreeItem) => boolean; /** @@ -310,11 +299,6 @@ export interface IChatWidgetViewOptions { * immediately open a new session. */ sessionTypePickerDelegate?: ISessionTypePickerDelegate; - /** - * Session type whose model pool should be shown when this widget is only a - * routing surface and its temporary local model is not the eventual target. - */ - modelPickerSessionType?: string; /** * Optional delegate for the workspace picker. @@ -336,13 +320,6 @@ export interface IChatWidgetViewOptions { * instead of silently dropping them. */ submitHandler?: (query: string, mode: ChatModeKind, attachedContext?: IChatRequestVariableEntry[], isVoiceModeInput?: boolean) => Promise; - onDidChangeModelPickerVisibility?: (visible: boolean) => void | Promise; - inputPickerPosition?: AnchorPosition | (() => AnchorPosition); - inputPickerContainer?: HTMLElement | (() => HTMLElement | undefined); - inputPickerAnchor?: (anchor: HTMLElement) => HTMLElement | IAnchor; - inputPickerOpenOnMouseUp?: boolean; - contextPicker?: IChatContextPickerDelegate; - /** * Whether we are running in the sessions window. * When true, the secondary toolbar (permissions picker) is hidden. @@ -364,7 +341,6 @@ export function isIChatViewViewContext(context: IChatWidgetViewContext): context export interface IChatResourceViewContext { isQuickChat?: boolean; isInlineChat?: boolean; - isChatInputWindow?: boolean; } export function isIChatResourceViewContext(context: IChatWidgetViewContext): context is IChatResourceViewContext { @@ -447,7 +423,6 @@ export interface IChatWidget { lastSelectedAgent: IChatAgentData | undefined; readonly scopedContextKeyService: IContextKeyService; readonly input: ChatInputPart; - readonly contextPicker: IChatContextPickerDelegate | undefined; /** The main input part at the bottom of the widget. Unlike `input`, this always returns the main input, not the inline editing input. */ readonly inputPart: ChatInputPart; readonly attachmentModel: ChatAttachmentModel; diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts deleted file mode 100644 index 989f381f789a5b..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindow.contribution.ts +++ /dev/null @@ -1,66 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as nls from '../../../../../nls.js'; -import * as dom from '../../../../../base/browser/dom.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; -import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; -import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID, CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID, IChatInputWindowService } from '../../common/chatInputWindow.js'; -import { OmniChatEnabledSettingId } from '../../common/sessionRouter.js'; - -// Registers the singleton implementation (side-effect import). -import './chatInputWindowService.js'; - -const inputWindowEnabled = ContextKeyExpr.and( - ChatContextKeys.enabled, - ContextKeyExpr.equals(`config.${OmniChatEnabledSettingId}`, true) -); - -CommandsRegistry.registerCommand(CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID, (accessor, text: string) => { - return accessor.get(IChatInputWindowService).acceptVoiceInput(text); -}); - -registerAction2(class extends Action2 { - constructor() { - super({ - id: CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID, - title: nls.localize2('chat.toggleInputWindow', "Toggle Floating Chat Input Window"), - icon: Codicon.arrowCircleUpSparkle, - f1: false, - precondition: inputWindowEnabled, - }); - } - async run(accessor: ServicesAccessor): Promise { - const invokingWindow = dom.getActiveWindow(); - const invokingWindowBounds = { - x: invokingWindow.screenX, - y: invokingWindow.screenY, - width: invokingWindow.outerWidth, - height: invokingWindow.outerHeight, - }; - const chatInputWindowService = accessor.get(IChatInputWindowService); - await chatInputWindowService.toggleWindow(invokingWindowBounds); - } -}); - -registerAction2(class extends Action2 { - constructor() { - super({ - id: 'workbench.action.chat.closeInputWindow', - title: nls.localize2('chat.closeInputWindow', "Close Floating Chat Input Window"), - category: Categories.View, - f1: false, - icon: Codicon.closeSmall, - }); - } - run(accessor: ServicesAccessor): void { - accessor.get(IChatInputWindowService).closeWindow(); - } -}); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts b/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts deleted file mode 100644 index efceae4c8c5bf9..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/chatInputWindowService.ts +++ /dev/null @@ -1,1697 +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 './media/chatInputWindow.css'; -import * as dom from '../../../../../base/browser/dom.js'; -import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js'; -import { DeferredPromise, disposableTimeout, timeout } from '../../../../../base/common/async.js'; -import { Button } from '../../../../../base/browser/ui/button/button.js'; -import { IAnchor } from '../../../../../base/browser/ui/contextview/contextview.js'; -import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { AnchorPosition } from '../../../../../base/common/layout.js'; -import { KeyCode } from '../../../../../base/common/keyCodes.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { mainWindow } from '../../../../../base/browser/window.js'; -import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; -import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; -import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; -import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { IAuxiliaryWindowService, IAuxiliaryWindow } from '../../../../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; -import { IRectangle } from '../../../../../platform/window/common/window.js'; -import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; -import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; -import { asCssVariable } from '../../../../../platform/theme/common/colorUtils.js'; -import { chartsOrange } from '../../../../../platform/theme/common/colors/chartsColors.js'; -import { editorBackground } from '../../../../../platform/theme/common/colorRegistry.js'; -import { inputBackground, inputBorder } from '../../../../../platform/theme/common/colors/inputColors.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -import { IHostService } from '../../../../services/host/browser/host.js'; -import { localize } from '../../../../../nls.js'; -import { ChatAgentLocation } from '../../common/constants.js'; -import { ChatMode } from '../../common/chatModes.js'; -import { IChatModelReference, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js'; -import { IChatModel } from '../../common/model/chatModel.js'; -import { isResponseVM } from '../../common/model/chatViewModel.js'; -import { ChatWidget } from '../widget/chatWidget.js'; -import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { ChatSessionRoutingController, IChatSessionRoutingHost } from '../sessionRouter/chatSessionRoutingController.js'; -import { combineVoiceInput } from '../voiceClient/voiceInputUtils.js'; -import { IChatInputWindowCIFailure, IChatInputWindowCIFailureProvider, IChatInputWindowService, ChatInputWindowStorageKeys, CHAT_INPUT_WINDOW_DEFAULT_HEIGHT, CHAT_INPUT_WINDOW_SET_VOICE_TARGET_COMMAND_ID, getChatInputWindowBounds, IChatInputWindowPositionOffset } from '../../common/chatInputWindow.js'; -import { autorun, IReader, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; -import { AgentSessionStatus } from '../agentSessions/agentSessionsModel.js'; -import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; -import { IVoiceSessionController } from '../voiceClient/voiceSessionController.js'; -import { IMicCaptureService } from '../voiceClient/micCaptureService.js'; -import { ITtsPlaybackService } from '../voiceClient/ttsPlaybackService.js'; -import { setupVoiceInputDecorations } from '../voiceClient/voiceInputDecorations.js'; -import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; -import { getQuickInputWidth } from '../../../../../platform/quickinput/browser/quickInputController.js'; -import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; -import { IChatSessionRoutingProviderService, OmniChatEnabledSettingId } from '../../common/sessionRouter.js'; -import { QuickInputService } from '../../../../services/quickinput/browser/quickInputService.js'; -import { AgentSessionProviders } from '../agentSessions/agentSessions.js'; -import { derivePendingId, getVoiceToolApprovalCommand, isPendingIdResolved, markPendingIdResolved } from '../../common/voiceClient/voiceClientService.js'; -import { ConfirmationOptionKind } from '../../../../../platform/agentHost/common/state/protocol/state.js'; - -const CHAT_INPUT_WINDOW_ACTION_WIDGET_HEIGHT = 420; -const CHAT_INPUT_WINDOW_ACTION_WIDGET_WIDTH = 420; -const CHAT_INPUT_WINDOW_ACTION_WIDGET_MARGIN = 4; -const CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT = 44; -const CHAT_INPUT_WINDOW_MAX_PENDING_HEIGHT = 360; -const CHAT_INPUT_WINDOW_MIN_CONFIRMATION_HEIGHT = 112; -const CHAT_INPUT_WINDOW_CONTEXT_PICKER_TRANSITION_DELAY = 100; - -type ChatInputActionWidgetPlacement = 'above' | 'right'; - -interface IChatInputWindowPendingChat { - readonly kind: 'chat'; - readonly id: string; - readonly model: IChatModel; -} - -interface IChatInputWindowPendingCIFailure { - readonly kind: 'ciFailure'; - readonly id: string; - readonly failure: IChatInputWindowCIFailure; - readonly provider: IChatInputWindowCIFailureProvider; -} - -type ChatInputWindowPendingItem = IChatInputWindowPendingChat | IChatInputWindowPendingCIFailure; - -function getDescendantElements(parent: HTMLElement, className?: string): HTMLElement[] { - const result: HTMLElement[] = []; - const visit = (element: HTMLElement) => { - for (const child of element.children) { - if (!dom.isHTMLElement(child)) { - continue; - } - if (!className || child.classList.contains(className)) { - result.push(child); - } - visit(child); - } - }; - visit(parent); - return result; -} - -/** - * Hosts a frameless, always-on-top auxiliary window containing the full chat - * input box — dictation, voice mode, and the glow animation. Submissions are - * intercepted and routed to the best-matching existing session (or a new one) - * via the shared {@link ChatSessionRoutingController}. - */ -export class ChatInputWindowService extends Disposable implements IChatInputWindowService { - - declare readonly _serviceBrand: undefined; - - private readonly _onDidChangeOpen = this._register(new Emitter()); - readonly onDidChangeOpen: Event = this._onDidChangeOpen.event; - - private readonly _auxiliaryWindowRef = this._register(new MutableDisposable()); - private _window: IAuxiliaryWindow | undefined; - private readonly _windowDisposables = this._register(new DisposableStore()); - private readonly _ownershipChannel: BroadcastChannel; - private _modelRef: IChatModelReference | undefined; - private _widget: ChatWidget | undefined; - private _pendingVoiceRoute: DeferredPromise | undefined; - private readonly _pendingResolvedInteractionCheck = this._register(new MutableDisposable()); - private _pendingPromptIndex = 0; - private _activePendingSessionResource: URI | undefined; - private readonly _dismissedPendingRequests = observableValue>(this, new Set()); - private readonly _dismissedCIFailures = observableValue>(this, new Set()); - private readonly _ciFailureProviders = observableValue(this, []); - private _fitWindowToContent: () => void = () => { }; - /** The single input row; routing results are inserted immediately after it. */ - private _row: HTMLElement | undefined; - private _lead: HTMLElement | undefined; - private _trail: HTMLElement | undefined; - /** Shared routing + advisory-badge behaviour; recreated per widget, torn down on close. */ - private _routingController: ChatSessionRoutingController | undefined; - /** In-flight `openWindow()` operation, so concurrent toggles stay idempotent. */ - private _openOperation: Promise | undefined; - private _desiredOpen = false; - private readonly _ownershipId = mainWindow.crypto.randomUUID(); - private _ownershipClaim: { readonly timestamp: number; readonly id: string } | undefined; - private readonly _actionWidgetWindow = this._register(new MutableDisposable()); - private _actionWidgetLayoutGeneration = 0; - private _actionWidgetVisibilityCount = 0; - private _actionWidgetOpenOperation: Promise | undefined; - private _actionWidgetOwner: IAuxiliaryWindow | undefined; - private _actionWidgetWindowAnchorY = 0; - private _actionWidgetAnchorPosition = AnchorPosition.BELOW; - private _actionWidgetPlacement: ChatInputActionWidgetPlacement = 'above'; - private readonly _contextPicker = this._register(new MutableDisposable()); - /** Bounds of the window that invoked omni, captured before the auxiliary window opens. */ - private _invokingWindowBounds: IRectangle = this._windowBounds(mainWindow); - private _invokingWindow = mainWindow; - - get isOpen(): boolean { - return !!this._window; - } - - get hasFocus(): boolean { - return this._window?.window.document.hasFocus() ?? false; - } - - registerCIFailureProvider(provider: IChatInputWindowCIFailureProvider): IDisposable { - this._ciFailureProviders.set([...this._ciFailureProviders.get(), provider], undefined); - return toDisposable(() => { - const providers = this._ciFailureProviders.get(); - const index = providers.indexOf(provider); - if (index >= 0) { - this._ciFailureProviders.set(providers.filter(candidate => candidate !== provider), undefined); - } - }); - } - - constructor( - @IAuxiliaryWindowService private readonly auxiliaryWindowService: IAuxiliaryWindowService, - @IStorageService private readonly storageService: IStorageService, - @IThemeService private readonly themeService: IThemeService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IChatService private readonly chatService: IChatService, - @ICommandService private readonly commandService: ICommandService, - @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, - @ILogService private readonly logService: ILogService, - @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, - @IMicCaptureService private readonly micCaptureService: IMicCaptureService, - @ITtsPlaybackService private readonly ttsPlaybackService: ITtsPlaybackService, - @IAccessibilityService private readonly accessibilityService: IAccessibilityService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IKeybindingService private readonly keybindingService: IKeybindingService, - @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, - @IHostService private readonly hostService: IHostService, - @IFileDialogService private readonly fileDialogService: IFileDialogService, - @IChatSessionRoutingProviderService private readonly routingProviderService: IChatSessionRoutingProviderService, - ) { - super(); - - const ownershipChannel = new BroadcastChannel('chat-input-window-ownership'); - ownershipChannel.onmessage = e => { - const incoming = e.data; - if (incoming?.type !== 'claim' || typeof incoming.timestamp !== 'number' || typeof incoming.id !== 'string') { - return; - } - const current = this._ownershipClaim; - const incomingWins = !current - || incoming.timestamp > current.timestamp - || (incoming.timestamp === current.timestamp && incoming.id > current.id); - if (incomingWins) { - this.closeWindow(); - } - }; - this._register({ dispose: () => ownershipChannel.close() }); - this._ownershipChannel = ownershipChannel; - - this._register(dom.addDisposableListener(mainWindow, 'beforeunload', () => { - if (this._window) { - this.closeWindow(); - } - })); - - const wasOpen = this.storageService.getBoolean(ChatInputWindowStorageKeys.WindowOpen, StorageScope.WORKSPACE, false); - if (wasOpen) { - this.storageService.store(ChatInputWindowStorageKeys.WindowOpen, false, StorageScope.WORKSPACE, StorageTarget.MACHINE); - } - this._dismissedCIFailures.set(new Set( - this.storageService.getObject(ChatInputWindowStorageKeys.DismissedCIFailures, StorageScope.PROFILE, []) - ), undefined); - - const closeAndResetPositionWhenDisabled = () => { - if (!this._isEnabled()) { - this.closeWindow(); - this.storageService.remove(ChatInputWindowStorageKeys.WindowPositionOffset, StorageScope.WORKSPACE); - } - }; - this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(OmniChatEnabledSettingId)) { - closeAndResetPositionWhenDisabled(); - } - })); - this._register(this.chatEntitlementService.onDidChangeSentiment(closeAndResetPositionWhenDisabled)); - closeAndResetPositionWhenDisabled(); - } - - async openWindow(invokingWindowBounds?: IRectangle): Promise { - if (!this._isEnabled()) { - return; - } - this._desiredOpen = true; - if (this._window) { - return; - } - // Coalesce concurrent open/toggle calls so we never create two aux windows. - if (this._openOperation) { - return this._openOperation; - } - this._invokingWindow = dom.getActiveWindow(); - this._invokingWindowBounds = this._isUsableWindowBounds(invokingWindowBounds) - ? invokingWindowBounds - : this._windowBounds(this._invokingWindow); - this._openOperation = this._doOpenWindow(); - try { - await this._openOperation; - } catch (error) { - this._desiredOpen = false; - this._disposeWidget(); - this._window = undefined; - this._windowDisposables.clear(); - this._auxiliaryWindowRef.clear(); - this.storageService.store(ChatInputWindowStorageKeys.WindowOpen, false, StorageScope.WORKSPACE, StorageTarget.MACHINE); - throw error; - } finally { - this._openOperation = undefined; - } - } - - private async _doOpenWindow(): Promise { - const bounds = this._defaultBounds(); - - const auxiliaryWindow = await this.auxiliaryWindowService.open({ - bounds, - alwaysOnTop: true, - frameless: true, - transparent: true, - disableFullscreen: true, - nativeTitlebar: false, - disableMaximize: true, - notResizable: true, - noBackgroundThrottling: true, - backgroundColor: '#00000000', - }); - if (!this._desiredOpen || !this._isEnabled()) { - auxiliaryWindow.dispose(); - return; - } - - this._window = auxiliaryWindow; - this._auxiliaryWindowRef.value = auxiliaryWindow; - this.voiceSessionController.setOmniInputOpen(true); - const surface = dom.append(auxiliaryWindow.container, dom.$('.chat-input-window')); - - const workspace = this.workspaceContextService.getWorkspace(); - const projectName = workspace.folders.length > 0 ? workspace.folders[0].name : ''; - auxiliaryWindow.window.document.title = projectName - ? localize('chatInputWindow.titleWithProject', "Chat Input — {0}", projectName) - : localize('chatInputWindow.title', "Chat Input"); - auxiliaryWindow.container.style.overflow = 'hidden'; - auxiliaryWindow.window.document.body.classList.add('chat-input-window-body'); - auxiliaryWindow.window.document.body.style.setProperty('margin', '0', 'important'); - auxiliaryWindow.window.document.body.style.setProperty('overflow', 'hidden', 'important'); - - this._windowDisposables.clear(); - - const applyThemeColors = () => { - const theme = this.themeService.getColorTheme(); - const surfaceColor = theme.getColor(inputBackground)?.toString() ?? '#3c3c3c'; - const border = theme.getColor(inputBorder)?.toString() ?? 'transparent'; - auxiliaryWindow.window.document.body.style.setProperty('background-color', 'transparent', 'important'); - surface.style.backgroundColor = surfaceColor; - surface.style.setProperty('--omni-input-editor-background', surfaceColor); - surface.style.border = `1px solid ${border}`; - }; - - surface.style.display = 'flex'; - surface.style.flex = '1 1 auto'; - surface.style.flexDirection = 'column'; - surface.style.minHeight = '0'; - - const row = dom.append(surface, dom.$('.chat-input-window-row')); - this._row = row; - const lead = dom.append(row, dom.$('.chat-input-window-lead', { - 'aria-hidden': 'true', - title: localize('chatInputWindow.drag', "Drag to move"), - })); - this._lead = lead; - lead.style.setProperty('-webkit-app-region', 'drag'); - lead.appendChild(renderIcon(Codicon.grabber)); - - applyThemeColors(); - this._windowDisposables.add(this.themeService.onDidColorThemeChange(() => applyThemeColors())); - - // Host the real chat input (dictation, voice mode, glow) by rendering a - // compact ChatWidget. The response list is filtered out so only the input - // box shows. Submission is intercepted via submitHandler (the routing - // seam) and routed to the best-matching existing session. - this._renderChatWidget(auxiliaryWindow, surface, row, bounds); - const pendingActiveWindowSync = this._windowDisposables.add(new MutableDisposable()); - this._windowDisposables.add(autorun(reader => { - const ownsVoice = this.voiceSessionController.omniInputActive.read(reader); - if (ownsVoice || auxiliaryWindow.window.document.hasFocus()) { - return; - } - pendingActiveWindowSync.value = dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => { - const activeWindow = dom.getActiveWindow(); - if (activeWindow !== auxiliaryWindow.window) { - this.voiceSessionController.setActiveWindow(activeWindow); - } - }); - })); - - const trail = dom.append(row, dom.$('.chat-input-window-trail')); - this._trail = trail; - const close = dom.append(trail, dom.$('a.chat-input-window-close', { - role: 'button', - tabindex: '0', - 'aria-label': localize('chatInputWindow.close.label', "Close"), - })); - close.appendChild(renderIcon(Codicon.closeSmall)); - this._windowDisposables.add(dom.addDisposableListener(close, dom.EventType.CLICK, () => this.closeWindow())); - this._windowDisposables.add(dom.addStandardDisposableListener(close, dom.EventType.KEY_DOWN, event => { - if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { - event.preventDefault(); - this.closeWindow(); - } - })); - this._renderPendingPrompts(auxiliaryWindow, surface); - - // Clean up when the user closes the window via OS controls. Guard by window - // identity so a stale unload after a quick reopen can't tear down the new one. - Event.once(auxiliaryWindow.onUnload)(() => { - if (this._window !== auxiliaryWindow) { - return; - } - this._storeWindowPosition(auxiliaryWindow); - this._disposeWidget(); - this._desiredOpen = false; - this._ownershipClaim = undefined; - this._window = undefined; - this._windowDisposables.clear(); - this._auxiliaryWindowRef.value = undefined; - this.storageService.store(ChatInputWindowStorageKeys.WindowOpen, false, StorageScope.WORKSPACE, StorageTarget.MACHINE); - this._onDidChangeOpen.fire(false); - }); - - this.storageService.store(ChatInputWindowStorageKeys.WindowOpen, true, StorageScope.WORKSPACE, StorageTarget.MACHINE); - this._onDidChangeOpen.fire(true); - } - - closeWindow(): void { - this._desiredOpen = false; - this._ownershipClaim = undefined; - if (!this._window) { return; } - - this._storeWindowPosition(this._window); - this.storageService.store(ChatInputWindowStorageKeys.WindowOpen, false, StorageScope.WORKSPACE, StorageTarget.MACHINE); - - // Cancel any in-flight submission so routing can't dispatch after close. - this._routingController?.cancelPending(); - this._disposeWidget(); - this._window = undefined; - this._windowDisposables.clear(); - this._auxiliaryWindowRef.value = undefined; - this._onDidChangeOpen.fire(false); - } - - async toggleWindow(invokingWindowBounds?: IRectangle): Promise { - if (this._desiredOpen || this.isOpen) { - this.closeWindow(); - } else { - const claim = { timestamp: Date.now(), id: this._ownershipId }; - this._ownershipClaim = claim; - this._ownershipChannel.postMessage({ type: 'claim', ...claim }); - await this.openWindow(invokingWindowBounds); - } - } - - async acceptVoiceInput(text: string): Promise { - const window = this._window?.window; - const widget = this._widget; - if ((!window?.document.hasFocus() && !this.voiceSessionController.omniInputActive.get()) || !widget || !this._routingController) { - return false; - } - - this._completePendingVoiceRoute(false); - const pendingRoute = new DeferredPromise(); - this._pendingVoiceRoute = pendingRoute; - const routeTimeout = disposableTimeout(() => pendingRoute.complete(false), 30_000); - try { - await widget.acceptInput(combineVoiceInput(widget.getInput(), text), { - preserveFocus: true, - isVoiceModeInput: true, - }); - return await pendingRoute.p; - } finally { - routeTimeout.dispose(); - if (this._pendingVoiceRoute === pendingRoute) { - this._completePendingVoiceRoute(false); - } - } - } - - private _completePendingVoiceRoute(resource: URI | false): void { - const pendingRoute = this._pendingVoiceRoute; - if (!pendingRoute) { - return; - } - this._pendingVoiceRoute = undefined; - void pendingRoute.complete(resource); - } - - private _renderChatWidget(auxiliaryWindow: IAuxiliaryWindow, surface: HTMLElement, row: HTMLElement, openingBounds: IRectangle): void { - this._dismissedPendingRequests.set(new Set(), undefined); - // The glow CSS keys off `.monaco-workbench .interactive-session - // .chat-input-container` - the aux container already tracks the - // `monaco-workbench` class, so we only need the `.interactive-session` - // wrapper here. - const parent = dom.append(row, dom.$('.interactive-session')); - parent.style.flex = '1 1 auto'; - parent.style.minWidth = '0'; - const editorOverflowWidgetsDomNode = dom.append(auxiliaryWindow.window.document.body, dom.$('.chat-editor-overflow.monaco-editor')); - this._windowDisposables.add(toDisposable(() => editorOverflowWidgetsDomNode.remove())); - - const scopedContextKeyService = this._windowDisposables.add(this.contextKeyService.createScoped(parent)); - // Mark this surface so its dedicated accessibility help (routing + how to - // close) takes precedence over the generic Quick Chat help. - ChatContextKeys.inChatInputWindow.bindTo(scopedContextKeyService).set(true); - const scopedInstantiationService = this._windowDisposables.add(this.instantiationService.createChild( - new ServiceCollection([ - IContextKeyService, - scopedContextKeyService, - ]) - )); - - const widget: ChatWidget = this._windowDisposables.add(scopedInstantiationService.createInstance( - ChatWidget, - ChatAgentLocation.Chat, - { isQuickChat: true, isChatInputWindow: true }, - { - autoScroll: true, - renderInputOnTop: true, - renderStyle: 'compact', - inputEditorMaxHeight: 250, - renderGettingStartedTip: false, - deferredNotificationsEnabled: false, - // Show only the input box — drop every response list item. - filter: () => false, - enableImplicitContext: false, - defaultMode: ChatMode.Agent, - modelPickerSessionType: AgentSessionProviders.AgentHostCopilot, - menus: { telemetrySource: 'chatInputWindow' }, - // Routing seam: intercept submission before local execution and - // route it to the best-matching existing session (or a new one), - // forwarding any explicit attachments on the input. - submitHandler: (query, mode, attachedContext, isVoiceModeInput) => this._routingController?.handleSubmit(query, mode, attachedContext, isVoiceModeInput) ?? Promise.resolve(false), - onDidChangeModelPickerVisibility: visible => this._setActionWidgetVisible(auxiliaryWindow, surface, undefined, visible, 'above'), - inputPickerPosition: () => this._actionWidgetAnchorPosition, - inputPickerContainer: () => this._actionWidgetWindow.value?.container, - inputPickerAnchor: anchor => this._getActionWidgetAnchor(anchor), - inputPickerOpenOnMouseUp: true, - contextPicker: { - prepare: (): Promise => this._prepareContextPicker(auxiliaryWindow, surface, scopedContextKeyService, widget), - }, - editorOverflowWidgetsDomNode, - }, - { - inputEditorBackground: inputBackground, - resultEditorBackground: editorBackground, - listBackground: editorBackground, - listForeground: editorBackground, - overlayBackground: editorBackground, - } - )); - this._widget = widget; - widget.render(parent); - widget.setVisible(true); - const inputContainer = widget.input.inputContainerElement; - if (inputContainer) { - try { - const inputValue = observableFromEvent(this, widget.inputEditor.onDidChangeModelContent, () => widget.getInput()); - this._windowDisposables.add(setupVoiceInputDecorations({ - voiceSessionController: this.voiceSessionController, - ttsPlaybackService: this.ttsPlaybackService, - micCaptureService: this.micCaptureService, - configurationService: this.configurationService, - keybindingService: this.keybindingService, - themeService: this.themeService, - accessibilityService: this.accessibilityService, - }, { - inputContainer, - glowContainer: surface, - isActive: this.voiceSessionController.omniInputOpen, - inputValue, - isOwner: this.voiceSessionController.omniInputOpen, - })); - } catch (error) { - this.logService.error('[chatInputWindow] Failed to initialize voice decorations', error); - } - } - - const modelRef = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { disableBackgroundKeepAlive: true, debugOwner: 'ChatInputWindow' }); - this._modelRef = modelRef; - widget.setModel(modelRef.object); - widget.setInputPlaceholder(localize('chatInputWindow.inputPlaceholder', "Send a request to any session or folder...")); - - let fitWindowToInput = () => { }; - - // Route submissions through the shared controller, inserting its advisory - // panel below the input and excluding this window's scratch session from - // the routing candidates so it can never route to itself. - const host: IChatSessionRoutingHost = { - widget, - getOwnSessionResource: () => this._modelRef?.object.sessionResource, - getRoutingProvider: () => this.routingProviderService.getProvider(), - getPendingReplySessionResource: () => this._activePendingSessionResource, - getSelectedModelLabel: () => widget.inputPart.selectedLanguageModel.get()?.metadata.name, - onWillRoute: () => this.voiceSessionController.prepareForRoutingRequest(), - onWillDispatchRoute: resource => this.voiceSessionController.markRoutedRequestPending(resource), - onDidRejectRoute: (resource, isVoiceModeInput) => { - if (resource) { - this.voiceSessionController.clearRoutedRequest(resource); - } - if (isVoiceModeInput) { - this._completePendingVoiceRoute(false); - } - }, - onDidResolveRoute: (resource, kind, isVoiceModeInput, requestId) => { - if (resource) { - this.voiceSessionController.markRoutedRequestPending(resource, requestId); - } - if (isVoiceModeInput) { - this._completePendingVoiceRoute(resource ?? false); - } - this.commandService.executeCommand(CHAT_INPUT_WINDOW_SET_VOICE_TARGET_COMMAND_ID, resource?.toString(), kind).catch(() => { }); - }, - onDidDismissRoute: (resource, requestId) => { - const dismissed = new Set(this._dismissedPendingRequests.get()); - dismissed.add(this._pendingRequestKey(resource, requestId)); - this._dismissedPendingRequests.set(dismissed, undefined); - this.voiceSessionController.clearRoutedRequest(resource); - }, - onDidChangeActionWidgetVisibility: (visible, anchor) => this._setActionWidgetVisible(auxiliaryWindow, surface, anchor, visible, 'right'), - getActionWidgetContainer: () => this._actionWidgetWindow.value?.container, - getActionWidgetAnchor: anchor => this._getActionWidgetAnchor(anchor), - getActionWidgetAnchorPosition: () => this._actionWidgetAnchorPosition, - pickFolder: async defaultUri => (await this.fileDialogService.showOpenDialog({ - title: localize('chatInputWindow.selectSessionFolder', "Select Folder for New Session"), - openLabel: localize('chatInputWindow.selectFolder', "Select Folder"), - canSelectFolders: true, - canSelectFiles: false, - canSelectMany: false, - defaultUri, - }))?.[0], - placeBadge: (badge) => { - const row = this._row; - if (!surface.isConnected || !row) { - return; - } - row.after(badge); - fitWindowToInput(); - const observerDisposables = this._windowDisposables.add(new DisposableStore()); - const resizeObserver = new auxiliaryWindow.window.ResizeObserver(() => fitWindowToInput()); - observerDisposables.add(toDisposable(() => resizeObserver.disconnect())); - resizeObserver.observe(badge); - const observer = new auxiliaryWindow.window.MutationObserver(() => { - if (!badge.isConnected) { - observerDisposables.dispose(); - fitWindowToInput(); - } - }); - observerDisposables.add(toDisposable(() => observer.disconnect())); - observer.observe(surface, { childList: true }); - }, - }; - this._routingController = this._windowDisposables.add(this.instantiationService.createInstance(ChatSessionRoutingController, host, 'chatInputWindow')); - - // Fit the frameless window to the widget's own content and any routing - // panel below it. Measuring the input container itself includes the - // height the host assigned and creates a feedback loop with empty space. - let lastContentHeight: number | undefined; - let didInitialPosition = false; - // Renderer screen coordinates lag native moves, so retain the requested - // position until each queued bounds update has completed. - let currentPosition = { x: openingBounds.x, y: openingBounds.y }; - let pendingBounds: IRectangle | undefined; - let applyingBounds = false; - const getRowHeight = () => { - // Measure the input's rendered height synchronously rather than - // reading `widget.contentHeight`, which is backed by a - // ResizeObserver and lags a frame behind. `layout()` re-lays the - // editor synchronously, so the observable is stale until the - // observer fires next frame; sizing the window to that stale height - // while the editor grows (e.g. pasting text or cycling input - // history) clips the new lines and makes the window grow - // inconsistently until the observer catches up. - let contentHeight = Math.ceil(widget.input.element.offsetHeight); - if (widget.attachmentModel.size > 0) { - contentHeight += Math.max(0, CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT - widget.input.inputRowHeight); - } - return Math.max(CHAT_INPUT_WINDOW_INITIAL_SURFACE_HEIGHT, contentHeight); - }; - const applyPendingBounds = async () => { - if (applyingBounds) { - return; - } - applyingBounds = true; - try { - while (pendingBounds && this._window === auxiliaryWindow) { - const bounds = pendingBounds; - pendingBounds = undefined; - currentPosition = { x: bounds.x, y: bounds.y }; - await auxiliaryWindow.setBounds(bounds); - } - } finally { - applyingBounds = false; - } - }; - fitWindowToInput = () => { - const win = this._window?.window; - if (!win || win !== auxiliaryWindow.window) { - return; - } - const width = this._defaultWidth(); - const rowHeight = getRowHeight(); - const extraHeight = Array.from(surface.children) - .filter(child => child !== this._row) - .reduce((height, child) => { - const element = child as HTMLElement; - const position = auxiliaryWindow.window.getComputedStyle(element).position; - return position === 'absolute' || position === 'fixed' - ? height - : height + element.offsetHeight; - }, 0); - const contentHeight = rowHeight + extraHeight + 4; - if (contentHeight === lastContentHeight) { - return; - } - lastContentHeight = contentHeight; - if (!didInitialPosition) { - didInitialPosition = true; - const initialBounds = this._positionedBounds(width, contentHeight); - currentPosition = { x: initialBounds.x, y: initialBounds.y }; - } else if (!applyingBounds) { - currentPosition = { x: win.screenX, y: win.screenY }; - } - pendingBounds = { ...currentPosition, width, height: contentHeight }; - void applyPendingBounds(); - }; - this._fitWindowToContent = fitWindowToInput; - - let layingOut = false; - const layout = () => { - if (layingOut) { - return; - } - layingOut = true; - try { - const chrome = (this._lead?.offsetWidth ?? 0) + (this._trail?.offsetWidth ?? 0); - const rowStyle = auxiliaryWindow.window.getComputedStyle(row); - const horizontalPadding = Number.parseFloat(rowStyle.paddingLeft) + Number.parseFloat(rowStyle.paddingRight); - const available = Math.max(0, row.clientWidth - chrome - horizontalPadding); - parent.style.width = `${available}px`; - widget.input.layout(available); - const rowHeight = getRowHeight(); - widget.layoutForInputHeight(rowHeight, available); - fitWindowToInput(); - } finally { - layingOut = false; - } - }; - layout(); - this._windowDisposables.add(widget.onDidChangeContentHeight(() => fitWindowToInput())); - const updateAttachmentLayout = () => { - row.classList.toggle('has-attachments', widget.attachmentModel.size > 0); - layout(); - }; - this._windowDisposables.add(widget.attachmentModel.onDidChange(updateAttachmentLayout)); - updateAttachmentLayout(); - const scheduledInputLayout = this._windowDisposables.add(new MutableDisposable()); - this._windowDisposables.add(widget.inputEditor.onDidChangeModelContent(() => { - // Submit controls change after the editor event; measure them in the - // next frame so the editor yields space before they can cover close. - scheduledInputLayout.value = dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => layout()); - })); - - this._windowDisposables.add(dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => { - layout(); - // Focus the input only after the window has been positioned: the - // `moveTo`/`resizeTo` above blur the editor, so focusing in a - // follow-up frame (after the OS window is settled and keyed) is what - // makes the caret actually render. - this._windowDisposables.add(dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, () => { - widget.focusInput(); - })); - })); - // Refresh editor focus and transfer the voice capture lease back to omni - // when an in-progress omni turn regains OS focus. - this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'focus', () => { - const activeElement = auxiliaryWindow.window.document.activeElement; - if (!activeElement - || activeElement === auxiliaryWindow.window.document.body - || activeElement === auxiliaryWindow.window.document.documentElement - || widget.inputEditor.getDomNode()?.contains(activeElement)) { - widget.focusInput(); - } - if (this.voiceSessionController.omniInputActive.get()) { - this.voiceSessionController.setOmniInputActive(true); - this.voiceSessionController.setActiveWindow(auxiliaryWindow.window); - } - })); - this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'resize', layout)); - } - - private _renderPendingPrompts(auxiliaryWindow: IAuxiliaryWindow, surface: HTMLElement): void { - const panel = dom.append(surface, dom.$('.chat-input-window-pending-panel')); - const header = dom.append(panel, dom.$('.chat-input-window-pending-header', { 'aria-live': 'polite' })); - const marker = dom.append(header, dom.$('span.chat-input-window-pending-marker', { 'aria-hidden': 'true' })); - marker.appendChild(renderIcon(Codicon.gripper)); - const label = dom.append(header, dom.$('span.chat-input-window-pending-label')); - const navigation = dom.append(header, dom.$('.chat-input-window-pending-navigation')); - const previous = this._appendPendingNavigationButton(navigation, Codicon.chevronLeft, localize('chatInputWindow.pending.previous', "Previous Item")); - const next = this._appendPendingNavigationButton(navigation, Codicon.chevronRight, localize('chatInputWindow.pending.next', "Next Item")); - const approvalFallback = dom.append(panel, dom.$('.chat-input-window-pending-approval-fallback')); - const approvalTitle = dom.append(approvalFallback, dom.$('.chat-input-window-pending-approval-title')); - const approvalMessage = dom.append(approvalFallback, dom.$('.chat-input-window-pending-approval-message')); - const approvalCommand = dom.append(approvalFallback, dom.$('code.chat-input-window-pending-approval-command')); - const approvalDisclaimer = dom.append(approvalFallback, dom.$('.chat-input-window-pending-approval-disclaimer')); - const approvalActions = dom.append(approvalFallback, dom.$('.chat-input-window-pending-approval-actions')); - const ciFallback = dom.append(panel, dom.$('.chat-input-window-pending-ci-fallback')); - const ciTitle = dom.append(ciFallback, dom.$('.chat-input-window-pending-ci-title')); - const ciDetail = dom.append(ciFallback, dom.$('.chat-input-window-pending-ci-detail', { 'aria-live': 'polite' })); - const ciActions = dom.append(ciFallback, dom.$('.chat-input-window-pending-ci-actions')); - const approvalActionDisposables = this._windowDisposables.add(new MutableDisposable()); - const ciActionDisposables = this._windowDisposables.add(new MutableDisposable()); - let lastActivatedPendingItem: string | undefined; - let displayedApproval: { readonly invocation: IChatToolInvocation; readonly occurrence: string } | undefined; - let displayedPendingOccurrence: string | undefined; - let displayedCIFailure: IChatInputWindowPendingCIFailure | undefined; - let renderedCIFailureId: string | undefined; - const renderCIFailure = (entry: IChatInputWindowPendingCIFailure | undefined) => { - displayedCIFailure = entry; - if (renderedCIFailureId !== entry?.id) { - renderedCIFailureId = entry?.id; - ciActionDisposables.value = new DisposableStore(); - ciActions.replaceChildren(); - if (entry) { - const button = ciActionDisposables.value.add(new Button(ciActions, { - title: localize('chatInputWindow.pending.fixCITooltip', "Fix failing CI checks"), - ...defaultButtonStyles, - small: true, - buttonBackground: asCssVariable(chartsOrange), - buttonHoverBackground: `color-mix(in srgb, ${asCssVariable(chartsOrange)} 88%, black)`, - buttonBorder: asCssVariable(chartsOrange), - })); - button.label = localize('chatInputWindow.pending.fixCI', "Fix CI"); - ciActionDisposables.value.add(button.onDidClick(() => { - entry.provider.fixCI(entry.failure.sessionResource); - this._widget?.focusInput(); - })); - const dismissButton = ciActionDisposables.value.add(new Button(ciActions, { - ...defaultButtonStyles, - small: true, - secondary: true, - })); - dismissButton.label = localize('chatInputWindow.pending.dismissCI', "Dismiss"); - ciActionDisposables.value.add(dismissButton.onDidClick(() => { - const dismissed = new Set(this._dismissedCIFailures.get()); - dismissed.add(entry.id); - this._dismissedCIFailures.set(dismissed, undefined); - this.storageService.store( - ChatInputWindowStorageKeys.DismissedCIFailures, - JSON.stringify([...dismissed].slice(-100)), - StorageScope.PROFILE, - StorageTarget.MACHINE, - ); - this._widget?.focusInput(); - })); - } - } - if (!entry) { - ciTitle.textContent = ''; - ciDetail.textContent = ''; - return; - } - - ciTitle.textContent = localize('chatInputWindow.pending.ciTitle', "CI is failing for {0}", entry.failure.label); - ciDetail.textContent = localize( - 'chatInputWindow.pending.ciDetail', - "{0} checks failed, {1} pending", - entry.failure.failed, - entry.failure.pending, - ); - }; - const renderApprovalFallback = (approval: typeof displayedApproval) => { - approvalActionDisposables.value = new DisposableStore(); - approvalActions.replaceChildren(); - if (!approval) { - return; - } - const state = approval.invocation.state.get(); - if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation - && state.type !== IChatToolInvocation.StateKind.WaitingForPostApproval) { - return; - } - const messages = state.confirmationMessages; - const confirmationTitle = renderAsPlaintext(messages?.title ?? approval.invocation.invocationMessage); - approvalTitle.textContent = confirmationTitle; - const confirmationMessage = renderAsPlaintext(messages?.message ?? ''); - const showConfirmationMessage = !!confirmationMessage && confirmationMessage !== confirmationTitle; - approvalMessage.textContent = showConfirmationMessage ? confirmationMessage : ''; - dom.setVisibility(showConfirmationMessage, approvalMessage); - approvalCommand.textContent = getVoiceToolApprovalCommand(approval.invocation) ?? ''; - dom.setVisibility(!!approvalCommand.textContent, approvalCommand); - const approvalReason = messages?.approvalReason?.status === 'complete' - ? renderAsPlaintext(messages.approvalReason.explanation) - : ''; - approvalDisclaimer.textContent = [renderAsPlaintext(messages?.disclaimer ?? ''), approvalReason].filter(Boolean).join('\n'); - dom.setVisibility(!!approvalDisclaimer.textContent, approvalDisclaimer); - - const confirm = (reason: Parameters[1]) => { - markPendingIdResolved(approval.occurrence); - IChatToolInvocation.confirmWith(approval.invocation, reason); - }; - const options = messages?.customOptions; - if (options?.length) { - for (const option of options) { - const button = approvalActionDisposables.value.add(new Button(approvalActions, { - ...defaultButtonStyles, - small: true, - secondary: option.kind === ConfirmationOptionKind.Deny, - })); - button.label = option.label; - approvalActionDisposables.value.add(button.onDidClick(() => confirm({ - type: ToolConfirmKind.UserAction, - selectedButton: option.id, - selectedButtonKind: option.kind, - }))); - } - } else { - const allowButton = approvalActionDisposables.value.add(new Button(approvalActions, { - ...defaultButtonStyles, - small: true, - })); - allowButton.label = messages?.confirmResults - ? localize('chatInputWindow.pending.allowAndReview', "Allow and Review Once") - : localize('chatInputWindow.pending.allow', "Allow Once"); - approvalActionDisposables.value.add(allowButton.onDidClick(() => confirm({ type: ToolConfirmKind.UserAction }))); - const skipButton = approvalActionDisposables.value.add(new Button(approvalActions, { - ...defaultButtonStyles, - small: true, - secondary: true, - })); - skipButton.label = localize('chatInputWindow.pending.skip', "Skip"); - approvalActionDisposables.value.add(skipButton.onDidClick(() => confirm({ type: ToolConfirmKind.Skipped }))); - } - }; - - const parent = dom.append(panel, dom.$('.chat-input-window-pending-widget.interactive-session')); - this._windowDisposables.add(dom.addDisposableListener(parent, dom.EventType.CLICK, event => { - const approval = displayedApproval; - const target = event.target; - if (!(target instanceof auxiliaryWindow.window.Element)) { - return; - } - if (approval && target.closest('.chat-confirmation-widget-buttons')) { - const state = approval.invocation.state.get(); - if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation - || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { - markPendingIdResolved(approval.occurrence); - } - } - this._notifyPendingItemResolvedAfterInteraction(); - }, { capture: true })); - this._windowDisposables.add(dom.addDisposableListener(parent, dom.EventType.KEY_DOWN, () => { - this._notifyPendingItemResolvedAfterInteraction(); - }, { capture: true })); - const scopedContextKeyService = this._windowDisposables.add(this.contextKeyService.createScoped(parent)); - ChatContextKeys.inChatInputWindow.bindTo(scopedContextKeyService).set(true); - const scopedInstantiationService = this._windowDisposables.add(this.instantiationService.createChild( - new ServiceCollection([ - IContextKeyService, - scopedContextKeyService, - ]) - )); - const widget = this._windowDisposables.add(scopedInstantiationService.createInstance( - ChatWidget, - ChatAgentLocation.Chat, - { isQuickChat: true, isChatInputWindow: true }, - { - autoScroll: true, - renderInputOnTop: true, - renderStyle: 'compact', - renderGettingStartedTip: false, - rendererOptions: { questionCarouselFitContent: true }, - filter: item => isResponseVM(item) && ( - !!item.model.isPendingConfirmation.get() - || item.model.response.value.some(part => part.kind === 'questionCarousel' && !part.isUsed) - ), - enableImplicitContext: false, - defaultMode: ChatMode.Ask, - menus: { telemetrySource: 'chatInputWindowPending' }, - }, - { - inputEditorBackground: inputBackground, - resultEditorBackground: editorBackground, - listBackground: editorBackground, - listForeground: editorBackground, - overlayBackground: editorBackground, - } - )); - widget.render(parent); - // Tool approvals and questions are rendered in ChatInputPart rather than - // the response list. Keep it mounted; CSS hides only the editor chrome. - widget.setInputVisible(true); - widget.setVisible(true); - const list = widget.transcriptDomNode; - - let pendingItems: readonly ChatInputWindowPendingItem[] = []; - let layingOut = false; - let lastPendingHeight: number | undefined; - let lastPendingWidth: number | undefined; - let confirmationWidgetLayoutHeight = 0; - let displayedItemId: string | undefined; - const layout = () => { - if (layingOut || !panel.classList.contains('shown')) { - return; - } - layingOut = true; - try { - if (displayedCIFailure) { - this._fitWindowToContent(); - return; - } - for (const row of getDescendantElements(list, 'monaco-list-row')) { - const confirmations = getDescendantElements(row, 'chat-confirmation-widget-container'); - const hasConfirmation = confirmations.length > 0; - row.classList.toggle('chat-input-window-confirmation-row', hasConfirmation); - for (const confirmation of confirmations) { - confirmation.classList.toggle( - 'chat-input-window-modified-files-confirmation', - getDescendantElements(confirmation, 'chat-modified-files-confirmation').length > 0, - ); - } - for (const value of getDescendantElements(row, 'value')) { - value.classList.toggle('chat-input-window-confirmation-value', hasConfirmation); - } - } - panel.classList.toggle('tool-approval-fallback', !!displayedApproval && !panel.classList.contains('question')); - const width = Math.max(0, panel.clientWidth); - if (lastPendingHeight === undefined || lastPendingWidth !== width) { - if (lastPendingWidth !== width) { - confirmationWidgetLayoutHeight = 0; - } - lastPendingWidth = width; - widget.layout(lastPendingHeight ?? CHAT_INPUT_WINDOW_MAX_PENDING_HEIGHT, width); - } - const listBounds = list.getBoundingClientRect(); - const renderedRows = getDescendantElements(list, 'interactive-item-container'); - const renderedContentHeight = renderedRows.reduce((height, row) => { - const rowBounds = row.getBoundingClientRect(); - const confirmation = getDescendantElements(row, 'chat-confirmation-widget-container')[0]; - const confirmationBounds = confirmation?.getBoundingClientRect(); - const paddingBottom = parseFloat(dom.getWindow(row).getComputedStyle(row).paddingBottom); - const renderedDescendantBottom = confirmation - ? getDescendantElements(confirmation).reduce( - (bottom, element) => Math.max(bottom, element.getBoundingClientRect().bottom), - confirmationBounds?.bottom ?? 0, - ) - : 0; - const confirmationBottom = confirmationBounds - ? Math.max(confirmationBounds.top + (confirmation?.scrollHeight ?? 0), renderedDescendantBottom) - : 0; - const bottom = Math.max(rowBounds.bottom, confirmationBottom + paddingBottom); - return Math.max(height, bottom - listBounds.top); - }, 0); - const isQuestion = panel.classList.contains('question'); - const questionContainer = isQuestion - ? getDescendantElements(parent, 'chat-question-carousel-widget-container').find(element => element.childElementCount > 0) - : undefined; - const questionContentHeight = questionContainer - ? questionContainer.getBoundingClientRect().bottom - parent.getBoundingClientRect().top - : 0; - const contentHeight = isQuestion - ? Math.max(widget.contentHeight, questionContentHeight) - : renderedContentHeight || widget.contentHeight; - const minimumHeight = isQuestion ? 1 : CHAT_INPUT_WINDOW_MIN_CONFIRMATION_HEIGHT; - const measuredHeight = isQuestion - ? Math.max(minimumHeight, Math.ceil(contentHeight)) - : Math.min(CHAT_INPUT_WINDOW_MAX_PENDING_HEIGHT, Math.max(minimumHeight, Math.ceil(contentHeight))); - // Approval content (diff summaries, risk badges, button rows) can - // render after the first frame. Grow to accommodate it, but never - // shrink this prompt and re-enter a resize oscillation. - const height = isQuestion - ? measuredHeight - : Math.max(lastPendingHeight ?? 0, measuredHeight); - const heightChanged = height !== lastPendingHeight; - if (heightChanged) { - lastPendingHeight = height; - parent.style.height = `${height}px`; - this._fitWindowToContent(); - } - if (isQuestion && heightChanged) { - widget.layout(height, width); - } else if (!panel.classList.contains('question') && height > confirmationWidgetLayoutHeight) { - // Keep the virtual row constrained below the input/header, and - // allow only monotonic growth when approval details render late. - confirmationWidgetLayoutHeight = height; - widget.layout(height, width); - scheduleLayout(); - } - } finally { - layingOut = false; - } - }; - const scheduledLayout = this._windowDisposables.add(new MutableDisposable()); - const scheduleLayout = () => { - scheduledLayout.value = dom.scheduleAtNextAnimationFrame(auxiliaryWindow.window, layout); - }; - const showPendingItem = (index: number) => { - if (pendingItems.length === 0) { - this._pendingPromptIndex = 0; - lastPendingHeight = undefined; - lastPendingWidth = undefined; - confirmationWidgetLayoutHeight = 0; - displayedItemId = undefined; - displayedApproval = undefined; - displayedPendingOccurrence = undefined; - renderApprovalFallback(undefined); - renderCIFailure(undefined); - lastActivatedPendingItem = undefined; - this._activePendingSessionResource = undefined; - panel.classList.remove('shown', 'question', 'tool-approval-fallback', 'ci-failure'); - widget.setModel(undefined); - this._fitWindowToContent(); - return; - } - this._pendingPromptIndex = (index + pendingItems.length) % pendingItems.length; - const item = pendingItems[this._pendingPromptIndex]; - if (displayedItemId !== item.id) { - displayedItemId = item.id; - lastPendingHeight = undefined; - confirmationWidgetLayoutHeight = 0; - } - panel.classList.add('shown'); - - const hasMultiple = pendingItems.length > 1; - header.classList.toggle('hidden', !hasMultiple); - label.textContent = hasMultiple - ? localize('chatInputWindow.pending.count', "Item {0} of {1}", this._pendingPromptIndex + 1, pendingItems.length) - : ''; - navigation.classList.toggle('hidden', !hasMultiple); - for (const button of [previous, next]) { - button.classList.toggle('disabled', !hasMultiple); - button.setAttribute('aria-disabled', String(!hasMultiple)); - button.tabIndex = hasMultiple ? 0 : -1; - } - - if (item.kind === 'ciFailure') { - this._activePendingSessionResource = undefined; - displayedApproval = undefined; - displayedPendingOccurrence = undefined; - renderApprovalFallback(undefined); - renderCIFailure(item); - panel.classList.remove('question', 'tool-approval-fallback'); - panel.classList.add('ci-failure'); - widget.setModel(undefined); - scheduleLayout(); - return; - } - - const model = item.model; - this._activePendingSessionResource = model.sessionResource; - renderCIFailure(undefined); - panel.classList.remove('ci-failure'); - const hasPendingQuestion = this._hasPendingQuestion(model); - const pendingApproval = this._getPendingToolApproval(model); - const pendingOccurrence = pendingApproval?.occurrence ?? this._getPendingQuestionOccurrence(model); - displayedApproval = pendingApproval; - displayedPendingOccurrence = pendingOccurrence; - renderApprovalFallback(pendingApproval); - const omniInputOpen = this.voiceSessionController.omniInputOpen.get(); - if (!omniInputOpen) { - lastActivatedPendingItem = undefined; - } - panel.classList.toggle('question', hasPendingQuestion); - panel.classList.toggle('tool-approval-fallback', !hasPendingQuestion && !!pendingApproval); - widget.setModel(model); - if (pendingOccurrence && omniInputOpen && pendingOccurrence !== lastActivatedPendingItem) { - // The pending card is the most direct observation that this exact - // question or approval is visible in omni. Activate it once so a - // coalesced/missed state transition cannot leave a visible prompt - // unannounced. Voice narration dedup is occurrence-based, so the - // normal state-change path and this UI path remain exactly-once. - lastActivatedPendingItem = pendingOccurrence; - this.voiceSessionController.announceSessionInOmni(model.sessionResource); - } - scheduleLayout(); - }; - - this._windowDisposables.add(dom.addDisposableListener(previous, dom.EventType.CLICK, () => showPendingItem(this._pendingPromptIndex - 1))); - this._windowDisposables.add(dom.addDisposableListener(next, dom.EventType.CLICK, () => showPendingItem(this._pendingPromptIndex + 1))); - this._windowDisposables.add(widget.onDidChangeContentHeight(scheduleLayout)); - const pendingMutationObserver = new auxiliaryWindow.window.MutationObserver(scheduleLayout); - pendingMutationObserver.observe(widget.domNode, { childList: true, subtree: true, attributes: true }); - this._windowDisposables.add(toDisposable(() => pendingMutationObserver.disconnect())); - this._windowDisposables.add(dom.addDisposableListener(auxiliaryWindow.window, 'resize', scheduleLayout)); - this._loadPendingSessionModels(); - this._windowDisposables.add(autorun(reader => { - this.voiceSessionController.omniInputOpen.read(reader); - const dismissedPendingRequests = this._dismissedPendingRequests.read(reader); - const dismissedCIFailures = this._dismissedCIFailures.read(reader); - const displayedResource = this._activePendingSessionResource; - if (displayedResource && displayedPendingOccurrence) { - const displayedModel = this.chatService.getSession(displayedResource); - const currentOccurrence = displayedModel - ? this._getPendingToolApproval(displayedModel)?.occurrence ?? this._getPendingQuestionOccurrence(displayedModel) - : undefined; - if (currentOccurrence !== displayedPendingOccurrence) { - this.voiceSessionController.notifyPendingItemResolved(displayedResource); - displayedPendingOccurrence = undefined; - } - } - const currentItemId = pendingItems[this._pendingPromptIndex]?.id; - const activeTarget = this.voiceSessionController.targetSession.read(reader)?.toString(); - const pendingChats: IChatInputWindowPendingChat[] = [...this.chatService.chatModels.read(reader)] - .filter(model => !!model.requestNeedsInput.read(reader) && !this._hasOnlyResolvedPendingTools(model, reader)) - .filter(model => !dismissedPendingRequests.has(this._pendingRequestKey(model.sessionResource, model.lastRequest?.id))) - .sort((a, b) => - Number(b.sessionResource.toString() === activeTarget) - Number(a.sessionResource.toString() === activeTarget) - || Number(this._hasPendingQuestion(b)) - Number(this._hasPendingQuestion(a)) - || b.lastMessageDate - a.lastMessageDate) - .map(model => ({ - kind: 'chat', - id: `chat:${this._pendingRequestKey(model.sessionResource, model.lastRequest?.id)}`, - model, - })); - const ciFailures: IChatInputWindowPendingCIFailure[] = []; - for (const provider of this._ciFailureProviders.read(reader)) { - for (const failure of provider.failures.read(reader)) { - const item: IChatInputWindowPendingCIFailure = { - kind: 'ciFailure', - id: `ci:${failure.sessionResource.toString()}:${failure.occurrenceId}`, - failure, - provider, - }; - if (!dismissedCIFailures.has(item.id)) { - ciFailures.push(item); - } - } - } - ciFailures.sort((a, b) => b.failure.updatedAt - a.failure.updatedAt); - pendingItems = [...pendingChats, ...ciFailures]; - const preservedIndex = currentItemId - ? pendingItems.findIndex(item => item.id === currentItemId) - : -1; - showPendingItem(preservedIndex >= 0 ? preservedIndex : Math.min(this._pendingPromptIndex, pendingItems.length - 1)); - })); - } - - private _notifyPendingItemResolvedAfterInteraction(): void { - const resource = this._activePendingSessionResource; - if (!resource) { - return; - } - const model = this.chatService.getSession(resource); - const occurrence = model - ? this._getPendingToolApproval(model)?.occurrence ?? this._getPendingQuestionOccurrence(model) - : undefined; - if (!occurrence) { - return; - } - this._pendingResolvedInteractionCheck.value = disposableTimeout(() => { - const currentModel = this.chatService.getSession(resource); - const currentOccurrence = currentModel - ? this._getPendingToolApproval(currentModel)?.occurrence ?? this._getPendingQuestionOccurrence(currentModel) - : undefined; - if (currentOccurrence !== occurrence) { - this.voiceSessionController.notifyPendingItemResolved(resource); - } - }, 0); - } - - private _loadPendingSessionModels(): void { - const refs = this._windowDisposables.add(new DisposableMap()); - const loads = new Set(); - const cts = new CancellationTokenSource(); - this._windowDisposables.add(toDisposable(() => cts.dispose(true))); - const update = async () => { - const pendingSessions = this.agentSessionsService.model.sessions - .filter(session => !session.isArchived() && session.status === AgentSessionStatus.NeedsInput); - const pendingKeys = new Set(pendingSessions.map(session => session.resource.toString())); - for (const key of refs.keys()) { - if (!pendingKeys.has(key)) { - refs.deleteAndDispose(key); - } - } - await Promise.all(pendingSessions.map(async session => { - const key = session.resource.toString(); - if (this.chatService.getSession(session.resource) || refs.has(key) || loads.has(key)) { - return; - } - loads.add(key); - try { - const ref = await this.chatService.acquireOrLoadSession(session.resource, ChatAgentLocation.Chat, cts.token, 'ChatInputWindow-pending'); - if (!ref) { - return; - } - if (cts.token.isCancellationRequested || !this.agentSessionsService.model.sessions.some(candidate => - candidate.resource.toString() === key && candidate.status === AgentSessionStatus.NeedsInput && !candidate.isArchived())) { - ref.dispose(); - return; - } - refs.set(key, ref); - } catch (error) { - if (!cts.token.isCancellationRequested) { - this.logService.warn(`[chatInputWindow] Failed to load pending session ${key}:`, error); - } - } finally { - loads.delete(key); - } - })); - }; - this._windowDisposables.add(this.agentSessionsService.model.onDidChangeSessions(() => void update())); - void update(); - } - - private _appendPendingNavigationButton(container: HTMLElement, icon: ThemeIcon, ariaLabel: string): HTMLElement { - const button = dom.append(container, dom.$('a.chat-input-window-pending-navigation-button', { - role: 'button', - tabindex: '0', - 'aria-label': ariaLabel, - })); - button.appendChild(renderIcon(icon)); - this._windowDisposables.add(dom.addStandardDisposableListener(button, dom.EventType.KEY_DOWN, event => { - if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { - event.preventDefault(); - button.click(); - } - })); - return button; - } - - private _pendingRequestKey(resource: URI, requestId: string | undefined): string { - return `${resource.toString()}\0${requestId ?? ''}`; - } - - private _hasPendingQuestion(model: IChatModel): boolean { - return model.lastRequest?.response?.response.value.some(part => part.kind === 'questionCarousel' && !part.isUsed) ?? false; - } - - private _getPendingQuestionOccurrence(model: IChatModel): string | undefined { - const request = model.lastRequest; - const question = request?.response?.response.value.find(part => - part.kind === 'questionCarousel' && !part.isUsed && !part.answeredExternally); - return request && question ? derivePendingId(request.id, question, this._windowDisposables) : undefined; - } - - private _hasOnlyResolvedPendingTools(model: IChatModel, reader: IReader): boolean { - const request = model.lastRequest; - const parts = request?.response?.response.value; - if (!request || !parts) { - return false; - } - let sawResolvedTool = false; - for (const part of parts) { - if (part.kind === 'questionCarousel' && !part.isUsed && !part.answeredExternally) { - return false; - } - if (part.kind === 'elicitation2' && part.state.get() === 'pending') { - return false; - } - if ((part.kind === 'planReview' || part.kind === 'confirmation') && !part.isUsed) { - return false; - } - if (part.kind !== 'toolInvocation') { - continue; - } - const state = part.state.get(); - if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation - && state.type !== IChatToolInvocation.StateKind.WaitingForPostApproval - && state.type !== IChatToolInvocation.StateKind.WaitingForAuthentication) { - continue; - } - const occurrence = derivePendingId(request.id, part, this._windowDisposables); - if (!isPendingIdResolved(occurrence, reader)) { - return false; - } - sawResolvedTool = true; - } - return sawResolvedTool; - } - - private _getPendingToolApproval(model: IChatModel): { readonly invocation: IChatToolInvocation; readonly occurrence: string } | undefined { - const request = model.lastRequest; - const parts = request?.response?.response.value; - if (!request || !parts) { - return undefined; - } - for (const part of parts) { - if (part.kind !== 'toolInvocation') { - continue; - } - const state = part.state.get(); - if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation - && state.type !== IChatToolInvocation.StateKind.WaitingForPostApproval - && state.type !== IChatToolInvocation.StateKind.WaitingForAuthentication) { - continue; - } - - const occurrence = derivePendingId(request.id, part, this._windowDisposables); - if (!isPendingIdResolved(occurrence)) { - return { invocation: part, occurrence }; - } - } - return undefined; - } - - private _setActionWidgetVisible(auxiliaryWindow: IAuxiliaryWindow, surface: HTMLElement, anchor: HTMLElement | undefined, visible: boolean, placement: ChatInputActionWidgetPlacement): Promise { - if (!visible) { - if (this._actionWidgetOwner !== auxiliaryWindow) { - return Promise.resolve(); - } - this._actionWidgetVisibilityCount = Math.max(0, this._actionWidgetVisibilityCount - 1); - if (this._actionWidgetVisibilityCount === 0) { - this._actionWidgetLayoutGeneration++; - this._actionWidgetOwner = undefined; - this._actionWidgetWindow.clear(); - } - return Promise.resolve(); - } - - if (this._actionWidgetOwner !== auxiliaryWindow) { - this._actionWidgetLayoutGeneration++; - this._actionWidgetVisibilityCount = 0; - this._actionWidgetOwner = auxiliaryWindow; - this._actionWidgetWindow.clear(); - this._actionWidgetOpenOperation = undefined; - } - this._actionWidgetVisibilityCount++; - if (this._actionWidgetWindow.value) { - return Promise.resolve(); - } - if (this._actionWidgetOpenOperation) { - return this._actionWidgetOpenOperation; - } - - const generation = ++this._actionWidgetLayoutGeneration; - const operation = this._openActionWidgetWindow(auxiliaryWindow, surface, anchor, generation, placement); - this._actionWidgetOpenOperation = operation; - return operation.finally(() => { - if (this._actionWidgetOpenOperation === operation) { - this._actionWidgetOpenOperation = undefined; - } - }); - } - - private async _prepareContextPicker(auxiliaryWindow: IAuxiliaryWindow, surface: HTMLElement, contextKeyService: IContextKeyService, widget: ChatWidget): Promise { - this._contextPicker.clear(); - await this._setActionWidgetVisible(auxiliaryWindow, surface, undefined, true, 'above'); - - const actionWidgetWindow = this._actionWidgetWindow.value; - if (!actionWidgetWindow) { - throw new Error('Unable to open the chat input context picker window'); - } - - actionWidgetWindow.window.focus(); - await timeout(0); - - const pickerLayoutService: ILayoutService = { - _serviceBrand: undefined, - onDidLayoutMainContainer: Event.None, - onDidLayoutContainer: Event.None, - onDidLayoutActiveContainer: Event.None, - onDidAddContainer: Event.None, - onDidChangeActiveContainer: Event.None, - get mainContainerDimension() { - return { width: actionWidgetWindow.container.clientWidth, height: actionWidgetWindow.container.clientHeight }; - }, - get activeContainerDimension() { - return this.mainContainerDimension; - }, - mainContainer: actionWidgetWindow.container, - activeContainer: actionWidgetWindow.container, - containers: [actionWidgetWindow.container], - getContainer: () => actionWidgetWindow.container, - whenContainerStylesLoaded: () => actionWidgetWindow.whenStylesHaveLoaded, - mainContainerOffset: { top: 0, quickPickTop: 0 }, - activeContainerOffset: { top: 0, quickPickTop: 0 }, - focus: () => actionWidgetWindow.window.focus(), - }; - const services = new ServiceCollection( - [IContextKeyService, contextKeyService], - [ILayoutService, pickerLayoutService], - ); - const scopedInstantiationService = this.instantiationService.createChild(services); - const store = new DisposableStore(); - store.add(scopedInstantiationService); - store.add(dom.addDisposableListener(actionWidgetWindow.window, dom.EventType.KEY_DOWN, event => { - if (event.key !== 'Escape') { - return; - } - event.preventDefault(); - event.stopImmediatePropagation(); - this._contextPicker.clear(); - }, true)); - const quickInputService = store.add(scopedInstantiationService.createInstance(QuickInputService)); - services.set(IQuickInputService, quickInputService); - - const pendingHide = store.add(new MutableDisposable()); - const pendingLayout = store.add(new MutableDisposable()); - let picker: HTMLElement | undefined; - const anchorPicker = () => { - pendingLayout.value = dom.scheduleAtNextAnimationFrame(actionWidgetWindow.window, () => { - if (picker) { - if (picker.style.top !== 'auto') { - picker.style.top = 'auto'; - } - if (picker.style.bottom !== '0px') { - picker.style.bottom = '0'; - } - } - }); - }; - const pickerObserver = new actionWidgetWindow.window.MutationObserver(mutations => { - for (const mutation of mutations) { - if (dom.isHTMLElement(mutation.target) && mutation.target.classList.contains('quick-input-widget')) { - picker = mutation.target; - } - for (const node of mutation.addedNodes) { - if (dom.isHTMLElement(node) && node.classList.contains('quick-input-widget')) { - picker = node; - } - } - for (const node of mutation.removedNodes) { - if (picker && (node === picker || node.contains(picker))) { - picker = undefined; - } - } - } - anchorPicker(); - }); - pickerObserver.observe(actionWidgetWindow.container, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] }); - store.add(toDisposable(() => pickerObserver.disconnect())); - store.add(quickInputService.onShow(() => { - pendingHide.clear(); - anchorPicker(); - })); - store.add(quickInputService.onHide(() => { - pendingHide.value = disposableTimeout(() => { - if (this._contextPicker.value === store) { - this._contextPicker.clear(); - } - }, CHAT_INPUT_WINDOW_CONTEXT_PICKER_TRANSITION_DELAY); - })); - store.add(toDisposable(() => { - void this._setActionWidgetVisible(auxiliaryWindow, surface, undefined, false, 'above'); - if (this._window === auxiliaryWindow) { - auxiliaryWindow.window.focus(); - widget.focusInput(); - } - })); - this._contextPicker.value = store; - - return quickInputService; - } - - private async _openActionWidgetWindow(auxiliaryWindow: IAuxiliaryWindow, surface: HTMLElement, anchor: HTMLElement | undefined, generation: number, placement: ChatInputActionWidgetPlacement): Promise { - const sourceWindow = auxiliaryWindow.window; - const [cursorScreenPoint, nativeSourceBounds] = await Promise.all([ - this.hostService.getCursorScreenPoint(), - this.hostService.getWindowPosition(sourceWindow), - ]); - const sourceBounds = nativeSourceBounds ?? { - x: sourceWindow.screenX, - y: sourceWindow.screenY, - width: sourceWindow.outerWidth, - height: sourceWindow.outerHeight, - }; - const sourceSurfaceBounds = surface.getBoundingClientRect(); - const sourceTop = sourceBounds.y + sourceSurfaceBounds.top; - const sourceRight = sourceBounds.x + sourceSurfaceBounds.right; - const sourceAnchorBounds = anchor?.getBoundingClientRect(); - const screen = sourceWindow.screen; - const display = cursorScreenPoint?.display ?? { - x: sourceBounds.x, - y: sourceBounds.y, - width: screen.availWidth, - height: screen.availHeight, - }; - const displayBottom = display.y + display.height; - const displayRight = display.x + display.width; - const width = Math.min( - placement === 'right' ? CHAT_INPUT_WINDOW_ACTION_WIDGET_WIDTH : sourceBounds.width, - display.width - ); - const availableAbove = Math.max(1, sourceTop - display.y - CHAT_INPUT_WINDOW_ACTION_WIDGET_MARGIN); - const height = Math.min( - CHAT_INPUT_WINDOW_ACTION_WIDGET_HEIGHT, - placement === 'above' ? availableAbove : display.height - ); - const preferredX = placement === 'right' - ? sourceRight + CHAT_INPUT_WINDOW_ACTION_WIDGET_MARGIN - : sourceBounds.x; - const preferredY = placement === 'right' - ? sourceBounds.y + (sourceAnchorBounds?.top ?? sourceSurfaceBounds.top) - : sourceTop - height - CHAT_INPUT_WINDOW_ACTION_WIDGET_MARGIN; - const x = Math.min(Math.max(display.x, preferredX), displayRight - width); - const y = Math.min(Math.max(display.y, preferredY), displayBottom - height); - const actionWidgetWindow = await this.auxiliaryWindowService.open({ - bounds: { x, y, width, height }, - alwaysOnTop: true, - frameless: true, - transparent: true, - notResizable: true, - disableFullscreen: true, - nativeTitlebar: false, - noBackgroundThrottling: true, - backgroundColor: '#00000000', - }); - await actionWidgetWindow.whenStylesHaveLoaded; - if (generation !== this._actionWidgetLayoutGeneration || this._window !== auxiliaryWindow) { - actionWidgetWindow.dispose(); - return; - } - - actionWidgetWindow.window.document.body.style.setProperty('background-color', 'transparent', 'important'); - actionWidgetWindow.window.document.body.style.setProperty('margin', '0', 'important'); - actionWidgetWindow.container.style.backgroundColor = 'transparent'; - actionWidgetWindow.container.style.overflow = 'hidden'; - this._actionWidgetPlacement = placement; - this._actionWidgetWindowAnchorY = placement === 'right' ? 0 : height; - this._actionWidgetAnchorPosition = placement === 'right' ? AnchorPosition.BELOW : AnchorPosition.ABOVE; - this._actionWidgetWindow.value = actionWidgetWindow; - } - - private _getActionWidgetAnchor(anchor: HTMLElement): IAnchor { - const bounds = anchor.getBoundingClientRect(); - return { - x: this._actionWidgetPlacement === 'right' ? 0 : bounds.left, - y: this._actionWidgetWindowAnchorY, - width: bounds.width, - height: 1, - }; - } - - private _disposeWidget(): void { - this._completePendingVoiceRoute(false); - this._pendingResolvedInteractionCheck.clear(); - this.voiceSessionController.setOmniInputOpen(false); - this.voiceSessionController.setOmniInputActive(false); - this._routingController = undefined; - this._widget = undefined; - this._fitWindowToContent = () => { }; - this._row = undefined; - this._lead = undefined; - this._trail = undefined; - this._activePendingSessionResource = undefined; - this._contextPicker.clear(); - this._actionWidgetVisibilityCount = 0; - this._actionWidgetOwner = undefined; - this._actionWidgetOpenOperation = undefined; - this._actionWidgetWindow.clear(); - this._actionWidgetLayoutGeneration++; - this._modelRef?.dispose(); - this._modelRef = undefined; - } - - private _defaultBounds(): IRectangle { - return this._positionedBounds(this._defaultWidth(), CHAT_INPUT_WINDOW_DEFAULT_HEIGHT); - } - - private _positionedBounds(width: number, height: number): IRectangle { - const offset = this.storageService.getObject( - ChatInputWindowStorageKeys.WindowPositionOffset, - StorageScope.WORKSPACE, - ); - const validOffset = offset && Number.isFinite(offset.x) && Number.isFinite(offset.y) ? offset : undefined; - const bounds = getChatInputWindowBounds(this._invokingWindowBounds, width, height, validOffset); - const screen = this._invokingWindow.screen as Screen & { readonly availLeft?: number; readonly availTop?: number }; - const availableLeft = screen.availLeft; - const availableTop = screen.availTop; - if (typeof availableLeft !== 'number' || typeof availableTop !== 'number' || !Number.isFinite(availableLeft) || !Number.isFinite(availableTop) || screen.availWidth <= 0 || screen.availHeight <= 0) { - return bounds; - } - return { - ...bounds, - x: Math.min(Math.max(bounds.x, availableLeft), availableLeft + Math.max(0, screen.availWidth - width)), - y: Math.min(Math.max(bounds.y, availableTop), availableTop + Math.max(0, screen.availHeight - height)), - }; - } - - private _storeWindowPosition(auxiliaryWindow: IAuxiliaryWindow): void { - const bounds = auxiliaryWindow.createState().bounds; - if (bounds?.x === undefined || bounds.y === undefined) { - return; - } - this.storageService.store( - ChatInputWindowStorageKeys.WindowPositionOffset, - JSON.stringify({ - x: bounds.x - this._invokingWindowBounds.x, - y: bounds.y - this._invokingWindowBounds.y, - } satisfies IChatInputWindowPositionOffset), - StorageScope.WORKSPACE, - StorageTarget.MACHINE, - ); - } - - private _defaultWidth(): number { - const invokingWindowWidth = this._invokingWindowBounds.width > 0 - ? this._invokingWindowBounds.width - : mainWindow.outerWidth; - return Math.round(getQuickInputWidth(invokingWindowWidth) * 1.1); - } - - private _windowBounds(window: Window): IRectangle { - return { - x: window.screenX, - y: window.screenY, - width: window.outerWidth, - height: window.outerHeight, - }; - } - - private _isUsableWindowBounds(bounds: IRectangle | undefined): bounds is IRectangle { - return !!bounds - && Number.isFinite(bounds.x) - && Number.isFinite(bounds.y) - && Number.isFinite(bounds.width) - && Number.isFinite(bounds.height) - && bounds.width > 0 - && bounds.height > 0; - } - - private _isEnabled(): boolean { - return this.configurationService.getValue(OmniChatEnabledSettingId) === true - && !this.chatEntitlementService.sentiment.hidden; - } -} - -registerSingleton(IChatInputWindowService, ChatInputWindowService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css b/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css deleted file mode 100644 index 4157340403ab6b..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/chatInputWindow/media/chatInputWindow.css +++ /dev/null @@ -1,493 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.chat-input-window { - --omni-rail: 20px; - --omni-icon-column: 16px; - --omni-row-gap: 8px; - - box-sizing: border-box; - height: calc(100% - var(--vscode-strokeThickness) - var(--vscode-strokeThickness)) !important; - margin: var(--vscode-strokeThickness); - border-radius: var(--vscode-cornerRadius-xLarge); - overflow: hidden; -} - -.chat-input-window:focus-within { - outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); - outline-offset: calc(-1 * var(--vscode-strokeThickness)); -} - -.chat-input-window .monaco-editor [tabindex]:focus, -.chat-input-window .monaco-editor textarea:focus { - outline: none; -} - -.chat-input-window .interactive-session .interactive-input-part .chat-editor-container .interactive-input-editor .monaco-editor { - background-color: var(--omni-input-editor-background, var(--vscode-input-background)) !important; -} - -.chat-input-window .interactive-session .interactive-input-part .chat-editor-container .interactive-input-editor .monaco-editor .monaco-editor-background { - background-color: var(--omni-input-editor-background, var(--vscode-input-background)) !important; -} - -.chat-input-window-body { - background: transparent !important; - overflow: hidden !important; -} - -.chat-input-window .chat-input-window-row { - display: flex; - align-items: center; - flex: 0 0 auto; - min-height: 44px; - padding: 0 8px; -} - -.chat-input-window .chat-input-window-lead { - display: flex; - align-items: center; - justify-content: center; - flex: 0 0 24px; - align-self: stretch; - color: var(--vscode-icon-foreground); - opacity: .45; - cursor: grab; -} - -.chat-input-window .chat-input-window-lead:active { - cursor: grabbing; -} - -.chat-input-window .chat-input-window-lead .codicon { - font-size: var(--vscode-codiconFontSize-compact); -} - -.chat-input-window .chat-input-window-trail { - display: flex; - align-items: center; - flex: 0 0 auto; - padding-left: 0; -} - -.chat-input-window .chat-input-window-row.has-attachments .chat-input-window-lead, -.chat-input-window .chat-input-window-row.has-attachments .chat-input-window-trail { - align-self: flex-end; - height: 44px; -} - -.chat-input-window .chat-input-window-close { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border-radius: var(--vscode-cornerRadius-small); - color: var(--vscode-icon-foreground); - opacity: .7; - cursor: pointer; - -webkit-app-region: no-drag; -} - -.chat-input-window .chat-input-window-close:hover { - opacity: 1; - background-color: var(--vscode-toolbar-hoverBackground); -} - -.chat-input-window .chat-input-window-close:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - opacity: 1; -} - -.chat-input-window .chat-input-window-close .codicon[class*='codicon-'] { - display: inline-block; - flex: 0 0 var(--vscode-codiconFontSize-compact, 12px); - font-size: var(--vscode-codiconFontSize-compact, 12px); - width: var(--vscode-codiconFontSize-compact, 12px); -} - -.chat-input-window .chat-side-toolbar { - display: none; -} - -.chat-input-window > .debug-toolbar { - display: none !important; -} - -.chat-input-window .interactive-session { - height: auto; - margin: 0; - justify-content: center; -} - -.chat-input-window .interactive-list { - display: none; -} - -.chat-input-window .interactive-input-part.compact { - padding-top: var(--vscode-spacing-sizeNone); -} - -.chat-input-window .interactive-input-part.compact .chat-input-container { - align-items: center; - padding-right: var(--vscode-spacing-size40); -} - -.chat-input-window .chat-input-window-row.has-attachments .interactive-input-and-edit-session { - display: flex; - flex-direction: column; -} - -.chat-input-window .chat-input-window-row.has-attachments .chat-attachments-container { - order: -1; - margin-top: 0; - margin-bottom: var(--vscode-spacing-size80); - padding-inline: var(--vscode-spacing-size60); -} - -.chat-input-window .chat-input-toolbars { - margin-top: 0; -} - -.chat-input-window .chat-session-target-picker-item { - display: none; -} - -.chat-input-window .chat-input-container:not(.working), -.agent-sessions-workbench .chat-input-window .interactive-session .chat-input-container:not(.working) { - border-color: transparent !important; - background: transparent !important; -} - -.chat-input-window .chat-input-window-row > .interactive-session, -.chat-input-window .interactive-session, -.chat-input-window .interactive-input-part, -.chat-input-window .interactive-input-and-edit-session, -.chat-input-window .interactive-input-and-side-toolbar, -.chat-input-window .chat-input-container, -.chat-input-window .chat-editor-container { - min-width: 0; - max-width: 100%; - box-sizing: border-box; -} - -.chat-input-window .chat-input-window-row > .interactive-session { - align-self: stretch; -} - -.chat-input-window .interactive-input-part, -.chat-input-window .interactive-input-and-edit-session, -.chat-input-window .interactive-input-and-side-toolbar, -.chat-input-window .chat-input-container { - width: 100%; -} - -.chat-input-window .chat-editor-container { - flex: 1 1 auto; -} - -.chat-input-window .voice-transcript-overlay-scrollable { - inset: 0; -} - -.chat-input-window .voice-transcript-overlay-scrollable:not(.has-transcript) .voice-transcript-overlay { - display: flex; - align-items: center; - padding-block: 0; -} - -.chat-input-window .chat-routing-badge { - background-color: transparent; - border-top: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, .25)); -} - -.chat-input-window .chat-input-window-pending-panel { - display: none; - flex: 0 0 auto; - min-width: 0; - border-top: 1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, .25)); - background: var(--vscode-editorWidget-background); - -webkit-app-region: no-drag; -} - -.chat-input-window .chat-input-window-pending-panel.shown { - display: block; -} - -.chat-input-window .chat-input-window-pending-header { - display: flex; - align-items: center; - gap: var(--omni-row-gap); - min-height: 36px; - padding: 0 var(--omni-rail); - color: var(--vscode-list-warningForeground); - font-size: var(--vscode-chat-font-size-body-s); - font-weight: var(--vscode-agents-fontWeight-semiBold); -} - -.chat-input-window .chat-input-window-pending-header.hidden { - display: none; -} - -.chat-input-window .chat-input-window-pending-marker { - display: flex; - align-items: center; - opacity: .8; -} - -.chat-input-window .chat-input-window-pending-marker .codicon { - font-size: var(--vscode-codiconFontSize-compact); -} - -.chat-input-window .chat-input-window-pending-label { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chat-input-window .chat-input-window-pending-navigation { - display: flex; - align-items: center; - gap: 2px; -} - -.chat-input-window .chat-input-window-pending-navigation.hidden { - display: none; -} - -.chat-input-window .chat-input-window-pending-navigation-button { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border-radius: var(--vscode-cornerRadius-medium); - color: var(--vscode-icon-foreground); - cursor: pointer; -} - -.chat-input-window .chat-input-window-pending-navigation-button:hover { - background: var(--vscode-toolbar-hoverBackground); -} - -.chat-input-window .chat-input-window-pending-navigation-button:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -.chat-input-window .chat-input-window-pending-navigation-button.disabled { - pointer-events: none; - opacity: .35; -} - -.chat-input-window .chat-input-window-pending-widget { - width: 100%; - min-width: 0; - max-height: 360px; - overflow: auto; -} - -.chat-input-window .chat-input-window-pending-panel.question .chat-input-window-pending-widget { - max-height: none; - overflow: visible !important; -} - -.chat-input-window .chat-input-window-pending-approval-fallback { - display: none; - padding: 10px 16px 16px; -} - -.chat-input-window .chat-input-window-pending-panel.tool-approval-fallback .chat-input-window-pending-approval-fallback { - display: flex; - flex-direction: column; - gap: 8px; -} - -.chat-input-window .chat-input-window-pending-panel.tool-approval-fallback .chat-input-window-pending-widget { - display: none; -} - -.chat-input-window .chat-input-window-pending-ci-fallback { - display: none; - padding: 10px 16px 16px; -} - -.chat-input-window .chat-input-window-pending-panel.ci-failure .chat-input-window-pending-ci-fallback { - display: flex; - flex-direction: column; - gap: 8px; -} - -.chat-input-window .chat-input-window-pending-panel.ci-failure .chat-input-window-pending-widget { - display: none; -} - -.chat-input-window .chat-input-window-pending-ci-title { - font-weight: var(--vscode-fontWeight-semiBold); -} - -.chat-input-window .chat-input-window-pending-ci-detail { - color: var(--vscode-descriptionForeground); -} - -.chat-input-window .chat-input-window-pending-ci-actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; -} - -.chat-input-window .chat-input-window-pending-approval-title { - font-weight: var(--vscode-fontWeight-semiBold); -} - -.chat-input-window .chat-input-window-pending-approval-message, -.chat-input-window .chat-input-window-pending-approval-disclaimer { - white-space: pre-wrap; -} - -.chat-input-window .chat-input-window-pending-approval-disclaimer { - color: var(--vscode-descriptionForeground); - font-size: var(--vscode-fontSize-label1); -} - -.chat-input-window .chat-input-window-pending-approval-command { - padding: 6px 8px; - border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-widget-border)); - border-radius: var(--vscode-cornerRadius-medium); - color: var(--vscode-textPreformat-foreground); - background: var(--vscode-textPreformat-background); - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -.chat-input-window .chat-input-window-pending-approval-actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; -} - -.chat-input-window .chat-input-window-pending-widget .interactive-list { - display: block; -} - -.chat-input-window .chat-input-window-pending-panel.question .chat-input-window-pending-widget .interactive-list { - display: none; -} - -.chat-input-window .chat-input-window-pending-widget .chat-editor-container, -.chat-input-window .chat-input-window-pending-widget .chat-input-toolbars, -.chat-input-window .chat-input-window-pending-widget .chat-attachments-container { - display: none; -} - -.chat-input-window .chat-input-window-pending-widget .interactive-item-container > :not(.value) { - display: none; -} - -.chat-input-window .chat-input-window-pending-widget .interactive-item-container > .value:not(.chat-input-window-confirmation-value) > * { - display: none; -} - -.chat-input-window .chat-input-window-pending-widget .interactive-item-container { - padding: 8px 16px 16px; -} - -.chat-input-window .chat-input-window-pending-widget .interactive-list, -.chat-input-window .chat-input-window-pending-widget .monaco-list, -.chat-input-window .chat-input-window-pending-widget .monaco-scrollable-element, -.chat-input-window .chat-input-window-pending-widget .monaco-list-rows, -.chat-input-window .chat-input-window-pending-widget .monaco-list-row, -.chat-input-window .chat-input-window-pending-widget .monaco-tl-row, -.chat-input-window .chat-input-window-pending-widget .monaco-tl-contents { - overflow: visible !important; -} - -.chat-input-window .chat-input-window-pending-widget .monaco-list-rows { - height: auto !important; -} - -.chat-input-window .chat-input-window-pending-widget .monaco-list-row:not(.chat-input-window-confirmation-row) { - display: none !important; -} - -.chat-input-window .chat-input-window-pending-widget .monaco-list-row.chat-input-window-confirmation-row { - position: relative !important; - top: auto !important; - transform: none !important; - height: auto !important; -} - -.chat-input-window .chat-input-window-pending-widget .monaco-list-row.chat-input-window-confirmation-row .interactive-item-container, -.chat-input-window .chat-input-window-pending-widget .monaco-list-row.chat-input-window-confirmation-row .interactive-item-container > .value { - height: auto !important; -} - -.chat-input-window .chat-input-window-pending-widget .chat-input-window-modified-files-confirmation .chat-query-title-part > small, -.chat-input-window .chat-input-window-pending-widget .chat-input-window-modified-files-confirmation .chat-confirmation-widget-message-scrollable { - display: none; -} - -.chat-input-window .chat-input-window-pending-widget .chat-input-window-modified-files-confirmation .chat-confirmation-widget2 { - min-height: 0; -} - -.chat-input-window .chat-input-window-pending-widget .monaco-tl-row, -.chat-input-window .chat-input-window-pending-widget .monaco-tl-contents { - height: auto !important; - min-height: 100%; - align-items: stretch; -} - -.chat-input-window .chat-input-window-pending-widget .chat-confirmation-widget2 { - display: flex; - flex-direction: column; - flex: none; - height: auto !important; - min-height: 96px; -} - -.chat-input-window .chat-input-window-pending-widget .chat-confirmation-widget-container { - flex: none; - height: max-content; - overflow: visible; -} - -.chat-input-window .chat-input-window-pending-widget .chat-confirmation-widget-message-scrollable, -.chat-input-window .chat-input-window-pending-widget .chat-confirmation-widget-message { - flex: none; - height: auto !important; - overflow: visible !important; -} - -.chat-input-window .chat-input-window-pending-widget .chat-confirmation-widget-buttons { - flex: none; - min-height: 30px; -} - -.chat-input-window .chat-input-window-pending-widget .chat-response-header, -.chat-input-window .chat-input-window-pending-widget .chat-response-actions { - display: none; -} - -.chat-input-window .chat-input-window-pending-panel.question .chat-question-carousel-widget-container { - margin-top: 0; - padding: 0; -} - -.chat-input-window .chat-input-window-pending-panel.question .chat-question-carousel-container { - border: 0; - border-radius: 0; - background: transparent; - min-height: 120px; -} - -.chat-input-window .chat-input-window-pending-panel.question .chat-question-carousel-content { - flex: 1; -} diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 772d744e4a8a40..5d13e13e06ed71 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -72,8 +72,7 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z3_vscodePet', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat], - when: ChatContextKeys.inChatInputWindow.negate(), + locations: [ChatAgentLocation.Chat] }, async () => { chatPetService.toggle(); })); diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts deleted file mode 100644 index a89992ac4c7e31..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingController.ts +++ /dev/null @@ -1,1637 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as dom from '../../../../../base/browser/dom.js'; -import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; -import { renderMarkdown } from '../../../../../base/browser/markdownRenderer.js'; -import { alert as ariaAlert } from '../../../../../base/browser/ui/aria/aria.js'; -import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { KeyCode } from '../../../../../base/common/keyCodes.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../../base/common/observable.js'; -import { basename, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { generateUuid } from '../../../../../base/common/uuid.js'; -import { localize } from '../../../../../nls.js'; -import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; -import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; -import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; -import { ChatRequestQueueKind, ChatSendResult, IChatSendRequestOptions, IChatService } from '../../common/chatService/chatService.js'; -import { IChatSessionHistoryItem, IChatSessionsService } from '../../common/chatSessionsService.js'; -import { getChatSessionType } from '../../common/model/chatUri.js'; -import { heuristicScore, IChatSessionRoutingDispatchResult, IChatSessionRoutingNewSessionTarget, IChatSessionRoutingProvider, IChatSessionRoutingWorkspace, IChatSessionRoutingWorkspaceCatalog, IRoutableSession, isHighConfidenceSessionRoute, ISessionRouteResult, ISessionRouter, ROUTER_FIELD_CLIP_LENGTH } from '../../common/sessionRouter.js'; -import { AgentSessionProviders, AgentSessionTarget } from '../agentSessions/agentSessions.js'; -import { IAgentHostNewSessionFolderService } from '../agentSessions/agentHost/agentHostNewSessionFolderService.js'; -import { IAgentSession, AgentSessionStatus } from '../agentSessions/agentSessionsModel.js'; -import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; -import { IChatWidgetService } from '../chat.js'; -import { ChatWidget } from '../widget/chatWidget.js'; -import { ChatSessionRoutingFolderPicker, IChatSessionRoutingFolderPickerHost } from './chatSessionRoutingFolderPicker.js'; -import { IChatSessionRoutingFolder, parseExplicitNewSessionRequest, resolveMentionedWorkspaceFolder, resolveNewSessionWorkspaceFolder, resolveSessionWorkspaceFolder, ROUTE_ENRICH_MAX_CANDIDATES, selectBestSessionRoute, selectRouterShortlist } from './chatSessionRoutingHelpers.js'; - -import './media/chatSessionRouting.css'; - -/** Maximum number of high-confidence session options shown in the destination picker. */ -const ROUTE_MAX_CHOICES = 6; - -/** - * How long the pending-send badge counts down before auto-dispatching to the - * routed target. Long enough to read the target and intervene, short enough to - * keep a hands-free/voice flow moving. - */ -const ROUTE_AUTOSEND_DELAY_MS = 5000; - -/** Resolved destination for a submitted request: an existing session or a new one. */ -type PendingTarget = - | { readonly kind: 'session'; readonly sessionId: string; readonly label: string; readonly confidence: number } - | NewSessionTarget; - -type NewSessionTarget = { - readonly kind: 'new'; - readonly label: string; - readonly folder?: URI; - readonly providerId?: string; -}; - -type RoutingFolder = IChatSessionRoutingFolder & { - readonly providerId?: string; - readonly workspace?: IChatSessionRoutingWorkspace; -}; - -type SubmissionPhase = 'idle' | 'routing' | 'awaitingChoice' | 'dispatching'; - -interface IDeliveryConfirmation extends IDisposable { - completed: boolean; -} - -interface IResponsePreview { - /** The first meaningful line of the response, stripped of block markdown. */ - readonly text: string; - /** Whether the response continues past the previewed line. */ - readonly hasMore: boolean; -} - -function responsePreview(response: string | undefined): IResponsePreview | undefined { - if (!response) { - return undefined; - } - const lines = response.split(/\r?\n/).map(line => line.trim()); - const firstIndex = lines.findIndex(Boolean); - if (firstIndex === -1) { - return undefined; - } - // Strip leading block markdown (headings, block quotes, list markers) so the - // preview renders as inline text rather than a heading blown up to full size. - const text = lines[firstIndex].replace(/^(#{1,6}|>|[-*+])\s+/, '').trim(); - if (!text) { - return undefined; - } - const hasMore = lines.slice(firstIndex + 1).some(Boolean); - return { text, hasMore }; -} - -function lowercaseFirstLetter(value: string): string { - return value.replace(/\p{L}/u, letter => letter.toLocaleLowerCase()); -} - -function renderCompletedResponse(labelElement: HTMLElement, sessionLabel: string, preview: IResponsePreview): IDisposable { - const prefix = dom.$('span.chat-routing-badge-response-prefix'); - prefix.textContent = localize( - 'chatSessionRouting.completedWithResponse', - "Completed {0}:", - lowercaseFirstLetter(sessionLabel) - ); - // A trailing ellipsis signals that the previewed line is only the start of a - // longer response. Appending it as a text node after the rendered markdown - // keeps it out of the parse, so a bare-URL first line still autolinks to the - // correct target instead of swallowing the ellipsis into the href. - const rendered = renderMarkdown(new MarkdownString(preview.text)); - rendered.element.classList.add('chat-routing-badge-response-preview'); - labelElement.classList.add('chat-routing-badge-completed'); - if (preview.hasMore) { - labelElement.replaceChildren(prefix, rendered.element, labelElement.ownerDocument.createTextNode('\u2026')); - } else { - labelElement.replaceChildren(prefix, rendered.element); - } - return rendered; -} - -function statusToString(status: AgentSessionStatus): string { - switch (status) { - case AgentSessionStatus.Failed: return 'failed'; - case AgentSessionStatus.Completed: return 'idle'; - case AgentSessionStatus.InProgress: return 'working'; - default: return 'unknown'; - } -} - -function isCopilotRoutingProvider(provider: string): boolean { - return provider === AgentSessionProviders.Background - || provider === AgentSessionProviders.Cloud - || provider === AgentSessionProviders.AgentHostCopilot; -} - -/** Flatten a `string | IMarkdownString | undefined` field to plain text. */ -function markdownToText(value: string | IMarkdownString | undefined): string | undefined { - if (!value) { - return undefined; - } - const text = (typeof value === 'string' ? value : value.value).trim(); - return text || undefined; -} - -/** - * Extract plain text from a response history item by concatenating its markdown - * parts. Kept coarse and clipped: the router only needs a gist of the latest - * response, not a faithful render, so non-text parts (tools, trees, etc.) are - * ignored. Returns `undefined` when the response has no textual content. - */ -function historyResponseToText(item: Extract): string | undefined { - let text = ''; - for (const part of item.parts) { - if (part.kind === 'markdownContent') { - text += part.content.value; - // Enough to characterize the response; avoid walking a huge transcript. - if (text.length >= ROUTER_FIELD_CLIP_LENGTH * 2) { - break; - } - } - } - text = text.trim(); - return text || undefined; -} - -/** - * The surface (floating input window, quick chat, …) that hosts a routed chat - * input. Supplies the widget being routed, its own scratch session to exclude - * from candidates, and where the advisory badge should be inserted. - */ -export interface IChatSessionRoutingHost extends IChatSessionRoutingFolderPickerHost { - /** The chat widget whose submission is being routed. */ - readonly widget: ChatWidget; - /** Resource of the host's own scratch session, excluded from routing candidates. */ - getOwnSessionResource(): URI | undefined; - /** Provider-neutral session catalog and operations owned by the host. */ - getRoutingProvider?(): IChatSessionRoutingProvider | undefined; - /** Session whose currently displayed question or approval the voice input answers directly. */ - getPendingReplySessionResource?(): URI | undefined; - /** Session provider selected for a newly created destination. */ - getNewSessionTarget?(): AgentSessionTarget | undefined; - /** Display name of the model selected for a newly created destination. */ - getSelectedModelLabel?(): string | undefined; - /** - * Insert the advisory badge into the host DOM near the input. - * If the host has no surface to place it, leave the badge disconnected and - * the controller will fall back to an immediate dispatch. - */ - placeBadge(badge: HTMLElement): void; - /** Notify the host that a new request will be independently routed. */ - onWillRoute?(): void; - /** Notify the host immediately before sending so stale destination state can be invalidated. */ - onWillDispatchRoute?(resource: URI): void; - /** Roll back pre-dispatch state when the send is rejected, cancelled, or fails. */ - onDidRejectRoute?(resource: URI | undefined, isVoiceModeInput?: boolean): void; - /** Notify the host when a single-target route resolves, or clear it for fan-out. */ - onDidResolveRoute?(resource: URI | undefined, kind?: 'existing_session' | 'new_session', isVoiceModeInput?: boolean, requestId?: string): void; - /** Notify the host when the user dismisses a routed request's delivery and pending-input UI. */ - onDidDismissRoute?(resource: URI, requestId?: string): void; -} - -/** - * Shared routing + advisory-badge behaviour for chat input surfaces. Scores a - * submitted utterance against existing agent sessions, resolves a pending target - * (best match above threshold, else a new session), then shows a ranked panel - * that counts down and auto-sends. The user can change or fan out the selection, - * abort, or keep typing to cancel before it fires. - */ -export class ChatSessionRoutingController extends Disposable { - - /** Transient routing/review badge + auto-send timers; replaced/cleared per submission. */ - private readonly _pendingSend = this._register(new MutableDisposable()); - /** Independently dismissible delivery rows that remain live across later submissions. */ - private readonly _deliveryConfirmations = this._register(new DisposableMap()); - private _deliveryConfirmationId = 0; - /** Cancellation for the in-flight submission; canceled when the host tears down. */ - private readonly _submitCts = this._register(new MutableDisposable()); - private readonly _submitDraftListeners = this._register(new MutableDisposable()); - private _routingProvider: IChatSessionRoutingProvider | undefined; - private _workspaceCatalog: IChatSessionRoutingWorkspaceCatalog | undefined; - - constructor( - private readonly host: IChatSessionRoutingHost, - private readonly debugOwner: string, - @IChatService private readonly chatService: IChatService, - @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, - @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, - @ISessionRouter private readonly sessionRouter: ISessionRouter, - @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - @ILogService private readonly logService: ILogService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IAgentHostNewSessionFolderService private readonly newSessionFolderService: IAgentHostNewSessionFolderService, - @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - ) { - super(); - } - - /** - * Intercept a submission before local execution: score it against existing - * sessions, resolve a pending target, and show the advisory badge. Always - * returns `true` (handled) so the input-only widget never runs the request on - * its own scratch session. - */ - async handleSubmit(query: string, _mode: ChatModeKind, attachedContext?: IChatRequestVariableEntry[], isVoiceModeInput?: boolean): Promise { - const submittedUtterance = query.trim(); - if (!submittedUtterance) { - return false; - } - const explicitNewSessionTask = parseExplicitNewSessionRequest(submittedUtterance); - const utterance = explicitNewSessionTask ?? submittedUtterance; - - // A new submission supersedes any pending badge from a previous one. - this._clearCompletedDeliveryConfirmations(); - this._submitCts.value?.cancel(); - this._submitDraftListeners.clear(); - this._pendingSend.clear(); - this._routingProvider = this.host.getRoutingProvider?.(); - this._workspaceCatalog = undefined; - - // Immediately reflect that the request was accepted so the send button - // greys out while routing runs (it is intercepted off-model, so the - // widget's own submit state never changes). Cleared when the submission - // resolves, is cancelled, or the user edits the draft. - this._setSubmissionPhase('routing'); - ariaAlert(localize('chatSessionRouting.preparingRequest', "Preparing your request.")); - - // The host cancels the in-flight submission on teardown so we never - // dispatch after close. - const cts = new CancellationTokenSource(); - this._submitCts.value = cts; - const token = cts.token; - const submittedAttachmentIds = this._attachmentIds(); - const draftListeners = new DisposableStore(); - const cancelForDraftChange = () => { - cts.cancel(); - this.host.onDidRejectRoute?.(undefined, isVoiceModeInput); - if (this._submitCts.value === cts) { - this._pendingSend.clear(); - this._submitDraftListeners.clear(); - this._setSubmissionPhase('idle'); - } - }; - draftListeners.add(this.host.widget.inputEditor.onDidChangeModelContent(cancelForDraftChange)); - draftListeners.add(this.host.widget.attachmentModel.onDidChange(cancelForDraftChange)); - this._submitDraftListeners.value = draftListeners; - const requestOptions: IChatSendRequestOptions = { - ...this.host.widget.getSelectedModelRequestOptions(), - ...this.host.widget.getModeRequestOptions(), - isVoiceModeInput, - attachedContext: attachedContext?.length ? [...attachedContext] : undefined, - }; - if (explicitNewSessionTask) { - this.host.onWillRoute?.(); - await this._refreshWorkspaceCatalog(token); - if (token.isCancellationRequested) { - return true; - } - const target = this._resolveNewSessionTarget(utterance, attachedContext, [], []); - this._dispatchOrReviewNewSession(target, query, submittedAttachmentIds, utterance, requestOptions, cts); - return true; - } - const followupResource = isVoiceModeInput ? this.host.getPendingReplySessionResource?.() : undefined; - if (followupResource && followupResource.toString() !== this.host.getOwnSessionResource()?.toString()) { - const followupTarget: PendingTarget = { - kind: 'session', - sessionId: followupResource.toString(), - label: this.chatService.getSession(followupResource)?.title || localize('chatSessionRouting.currentSession', "Current session"), - confidence: 1, - }; - this._dispatchImmediately(followupTarget, query, submittedAttachmentIds, utterance, requestOptions, cts); - return true; - } - await this._routeToChat(query, submittedAttachmentIds, utterance, attachedContext, requestOptions, cts); - return true; - } - - private async _routeToChat( - query: string, - submittedAttachmentIds: readonly string[], - utterance: string, - attachedContext: readonly IChatRequestVariableEntry[] | undefined, - requestOptions: IChatSendRequestOptions, - cts: CancellationTokenSource, - ): Promise { - const token = cts.token; - this._setSubmissionPhase('routing'); - ariaAlert(localize('chatSessionRouting.findingDestination', "Finding the best chat for your request.")); - this.host.onWillRoute?.(); - - await this._refreshWorkspaceCatalog(token); - if (token.isCancellationRequested) { - return; - } - const folders = this._getRoutingFolders(); - const mentionedFolder = resolveMentionedWorkspaceFolder(utterance, folders); - const collectedCandidates = await this._collectCandidateSessions(token); - const candidates = mentionedFolder - ? collectedCandidates.filter(candidate => isEqual(resolveSessionWorkspaceFolder(candidate, folders)?.uri, mentionedFolder.uri)) - : collectedCandidates; - this.logService.info( - `[chatSessionRouting] owner=${this.debugOwner} voice=${requestOptions.isVoiceModeInput === true} workspaceFolders=[${folders.map(folder => folder.name).join(', ')}] mentionedFolder=${mentionedFolder?.name ?? ''} candidates=${collectedCandidates.length} filteredCandidates=${candidates.length}` - ); - if (token.isCancellationRequested) { - return; - } - - const preliminaryResults = candidates.length > ROUTE_ENRICH_MAX_CANDIDATES - ? heuristicScore({ utterance, sessions: candidates }) - : []; - if (token.isCancellationRequested) { - return; - } - const shortlist = selectRouterShortlist(candidates, preliminaryResults); - const enriched = shortlist.length ? await this._enrichCandidates(shortlist, token) : []; - if (token.isCancellationRequested) { - return; - } - - const results = enriched.length ? await this._route(enriched, utterance, token) : []; - if (token.isCancellationRequested) { - return; - } - this._setSubmissionPhase('awaitingChoice'); - - const newSessionTarget = this._resolveNewSessionTarget(utterance, attachedContext, results, enriched); - const target = this._resolveTarget(results, enriched, newSessionTarget); - this.logService.info( - `[chatSessionRouting] owner=${this.debugOwner} target=${target.kind} targetId=${target.kind === 'session' ? target.sessionId : target.folder?.toString() ?? ''} topConfidence=${results[0]?.confidence ?? ''}` - ); - const candidateIds = new Set(enriched.map(candidate => candidate.sessionId)); - const hasSessionChoice = results.some(result => candidateIds.has(result.sessionId) && isHighConfidenceSessionRoute(result)); - if (target.kind === 'new' && !hasSessionChoice) { - this._dispatchOrReviewNewSession(target, query, submittedAttachmentIds, utterance, requestOptions, cts); - return; - } - this._beginPendingSend(target, newSessionTarget, results, enriched, query, submittedAttachmentIds, utterance, requestOptions, cts); - } - - private _dispatchOrReviewNewSession(target: NewSessionTarget, submittedInput: string, submittedAttachmentIds: readonly string[], utterance: string, requestOptions: IChatSendRequestOptions, cts: CancellationTokenSource): void { - if (!this._hasWorkspacePickerOptions()) { - this._dispatchImmediately(target, submittedInput, submittedAttachmentIds, utterance, requestOptions, cts); - return; - } - - this._setSubmissionPhase('awaitingChoice'); - this._beginPendingSend(target, target, [], [], submittedInput, submittedAttachmentIds, utterance, requestOptions, cts); - } - - private _dispatchImmediately(target: PendingTarget, submittedInput: string, submittedAttachmentIds: readonly string[], utterance: string, requestOptions: IChatSendRequestOptions, cts: CancellationTokenSource): void { - this._submitDraftListeners.clear(); - this._setSubmissionPhase('dispatching'); - void this._dispatchTo(target, submittedInput, submittedAttachmentIds, utterance, requestOptions, cts.token).then(result => { - if (this._submitCts.value !== cts) { - return; - } - this._setSubmissionPhase('idle'); - if ((result.status === 'sent' || result.status === 'queued') && result.resource) { - this._showDeliveryConfirmation(target.label, result); - } else { - this._showDispatchFailure(target.label, result.reason); - } - }); - } - - /** Cancel any in-flight submission and remove the pending badge. */ - cancelPending(): void { - this._cancelPending(true); - } - - private _cancelPending(resetSubmissionPhase: boolean): void { - this._submitCts.value?.cancel(); - this._submitCts.clear(); - this._submitDraftListeners.clear(); - this._pendingSend.clear(); - if (resetSubmissionPhase) { - this._setSubmissionPhase('idle'); - } - } - - private _setSubmissionPhase(phase: SubmissionPhase): void { - this.host.widget.input.setSubmitPending(phase !== 'idle', phase === 'routing' || phase === 'dispatching'); - } - - /** Run the router, degrading to an empty ranking on failure/cancellation. */ - private async _route(candidates: IRoutableSession[], utterance: string, token: CancellationToken): Promise { - try { - const results = await this.sessionRouter.route({ utterance, sessions: candidates }, token); - const lexicalTieBreak = new Map(heuristicScore({ utterance, sessions: candidates }).map(result => [result.sessionId, result.confidence])); - return [...results].sort((a, b) => - b.confidence - a.confidence - || (lexicalTieBreak.get(b.sessionId) ?? 0) - (lexicalTieBreak.get(a.sessionId) ?? 0)); - } catch (err) { - if (!token.isCancellationRequested) { - this.logService.warn('[chatSessionRouting] session routing failed:', err); - } - return []; - } - } - - /** - * Pick the single pending target the badge pre-selects: the top match if it - * clears the confidence threshold, otherwise a brand-new session. - */ - private _resolveTarget(results: ISessionRouteResult[], candidates: IRoutableSession[], newSessionTarget: NewSessionTarget): PendingTarget { - const labelById = new Map(candidates.map(c => [c.sessionId, c.label])); - const chosen = selectBestSessionRoute(results); - if (!chosen) { - return newSessionTarget; - } - return { - kind: 'session', - sessionId: chosen.sessionId, - label: labelById.get(chosen.sessionId) ?? chosen.sessionId, - confidence: chosen.confidence, - }; - } - - /** - * Snapshot the current routing candidates. Provider-backed hosts own their - * catalog and filtering. Other hosts retain the renderer-local agent session - * catalog and exclude the host's scratch session and local chats. - */ - private async _collectCandidateSessions(token: CancellationToken): Promise { - this._routingProvider = this.host.getRoutingProvider?.(); - if (this._routingProvider) { - try { - const candidates = await this._routingProvider.getCandidateSessions(token); - if (token.isCancellationRequested) { - return []; - } - const accepted = new Map(); - for (const candidate of [...candidates].sort((a, b) => a.sessionId.localeCompare(b.sessionId))) { - if (!accepted.has(candidate.sessionId)) { - accepted.set(candidate.sessionId, candidate); - } - } - return [...accepted.values()]; - } catch (error) { - if (!token.isCancellationRequested) { - this.logService.warn('[chatSessionRouting] collecting provider sessions failed:', error); - } - return []; - } - } - - try { - await this.agentSessionsService.model.resolve(undefined); - } catch (err) { - this.logService.warn('[chatSessionRouting] resolving agent sessions failed:', err); - } - if (token.isCancellationRequested) { - return []; - } - const ownResource = this.host.getOwnSessionResource()?.toString(); - return this.agentSessionsService.model.sessions - .filter(session => session.resource.toString() !== ownResource - && isCopilotRoutingProvider(session.providerType) - && !session.isArchived() - && this.chatSessionsService.getChatSessionContribution(getChatSessionType(session.resource))?.isReadOnly !== true) - .map(session => this._toRoutableSession(session)); - } - - private _toRoutableSession(session: IAgentSession): IRoutableSession { - return { - sessionId: session.resource.toString(), - label: session.label, - status: statusToString(session.status), - lastActivity: session.timing?.lastRequestEnded ?? session.timing?.lastRequestStarted ?? session.timing?.created, - description: markdownToText(session.description), - repo: session.metadata?.repositoryPath, - cwd: session.metadata?.workingDirectoryPath, - }; - } - - private _resolveNewSessionTarget( - utterance: string, - attachedContext: readonly IChatRequestVariableEntry[] | undefined, - results: readonly ISessionRouteResult[], - candidates: readonly IRoutableSession[], - ): NewSessionTarget { - const folders = this._getRoutingFolders(); - const mentionedFolder = resolveMentionedWorkspaceFolder(utterance, folders); - const attachmentFolder = this._folderFromAttachments(attachedContext, folders); - const defaultWorkspace = this._workspaceCatalog?.defaultWorkspace; - const inferredFolderUri = resolveNewSessionWorkspaceFolder( - utterance, - folders, - results, - candidates, - defaultWorkspace?.uri ?? this.newSessionFolderService.getDefaultFolder(), - ); - const selectedFolder = mentionedFolder - ?? attachmentFolder - ?? this._findRoutingFolder(inferredFolderUri, defaultWorkspace?.providerId); - const folder = selectedFolder?.uri ?? inferredFolderUri; - this.logService.info( - `[chatSessionRouting] owner=${this.debugOwner} newSessionFolder=${folder?.toString() ?? ''} providerId=${selectedFolder?.providerId ?? ''} source=${mentionedFolder ? 'mention' : attachmentFolder ? 'attachment' : 'inferred'}` - ); - return { - kind: 'new', - label: folder - ? localize('chatSessionRouting.newSessionInFolder', "New session in {0}", selectedFolder?.name ?? this.workspaceContextService.getWorkspaceFolder(folder)?.name ?? basename(folder)) - : localize('chatSessionRouting.newSession', "New session"), - folder, - providerId: selectedFolder?.providerId, - }; - } - - private _folderFromAttachments(attachedContext: readonly IChatRequestVariableEntry[] | undefined, folders: readonly RoutingFolder[]): RoutingFolder | undefined { - for (const attachment of attachedContext ?? []) { - const resource = IChatRequestVariableEntry.toUri(attachment); - const folder = resource && folders - .filter(candidate => isEqualOrParent(resource, candidate.uri)) - .sort((a, b) => b.uri.path.length - a.uri.path.length)[0]; - if (folder) { - return folder; - } - } - return undefined; - } - - private async _refreshWorkspaceCatalog(token: CancellationToken): Promise { - const provider = this._routingProvider ?? this.host.getRoutingProvider?.(); - this._routingProvider = provider; - if (!provider?.getNewSessionWorkspaceCatalog) { - this._workspaceCatalog = undefined; - return undefined; - } - try { - const catalog = await provider.getNewSessionWorkspaceCatalog(); - if (!token.isCancellationRequested) { - this._workspaceCatalog = catalog; - } - return token.isCancellationRequested ? undefined : catalog; - } catch (error) { - if (!token.isCancellationRequested) { - this.logService.warn('[chatSessionRouting] Failed to load new-session workspaces', error); - this._workspaceCatalog = undefined; - } - return undefined; - } - } - - private _getRoutingFolders(): RoutingFolder[] { - const folders: RoutingFolder[] = []; - const add = (folder: RoutingFolder) => { - if (!folders.some(candidate => isEqual(candidate.uri, folder.uri) && candidate.providerId === folder.providerId)) { - folders.push(folder); - } - }; - for (const workspace of this._workspaceCatalog?.workspaces ?? []) { - add({ - uri: workspace.uri, - name: workspace.label, - aliases: workspace.description ? [workspace.description] : undefined, - providerId: workspace.providerId, - workspace, - }); - } - const defaultWorkspace = this._workspaceCatalog?.defaultWorkspace; - if (defaultWorkspace) { - add({ - uri: defaultWorkspace.uri, - name: defaultWorkspace.label, - aliases: defaultWorkspace.description ? [defaultWorkspace.description] : undefined, - providerId: defaultWorkspace.providerId, - workspace: defaultWorkspace, - }); - } - for (const folder of this.workspaceContextService.getWorkspace().folders) { - add(folder); - } - return folders; - } - - private _findRoutingFolder(folderUri: URI | undefined, preferredProviderId?: string): RoutingFolder | undefined { - if (!folderUri) { - return undefined; - } - const folders = this._getRoutingFolders().filter(folder => isEqual(folder.uri, folderUri)); - return folders.find(folder => folder.providerId === preferredProviderId) ?? folders[0]; - } - - private _hasWorkspacePickerOptions(): boolean { - if (this._workspaceCatalog) { - return this._workspaceCatalog.workspaces.length > 0 || this._workspaceCatalog.browseActions.length > 0; - } - return this.workspaceContextService.getWorkspace().folders.length > 1; - } - - /** - * Enrich the shortlisted candidates with conversation content (first - * request, most recent request, and a truncated most recent response) so the - * final score can match on what a session is actually about rather than just - * its title. Each fetch degrades independently: a session whose content can't - * be resolved is kept as-is on its metadata. - */ - private async _enrichCandidates(candidates: IRoutableSession[], token: CancellationToken): Promise { - return Promise.all(candidates.map(candidate => this._enrichCandidate(candidate, token))); - } - - private async _enrichCandidate(candidate: IRoutableSession, token: CancellationToken): Promise { - if (this._routingProvider) { - return candidate; - } - let resource: URI; - try { - resource = URI.parse(candidate.sessionId); - } catch { - return candidate; - } - try { - const history = await this.chatSessionsService.getChatSessionHistory?.(resource, token); - if (token.isCancellationRequested) { - return candidate; - } - return history ? this._applyHistory(candidate, history) : candidate; - } catch (err) { - if (!token.isCancellationRequested) { - this.logService.trace('[chatSessionRouting] enriching candidate failed, using metadata only:', candidate.sessionId, err); - } - return candidate; - } - } - - /** Fold the first/most-recent request and most-recent response into a candidate. */ - private _applyHistory(candidate: IRoutableSession, history: readonly IChatSessionHistoryItem[]): IRoutableSession { - let firstRequest: string | undefined; - let lastRequest: string | undefined; - let lastResponse: string | undefined; - for (const item of history) { - if (item.type === 'request') { - const prompt = item.prompt.trim(); - if (prompt) { - firstRequest ??= prompt; - lastRequest = prompt; - } - } else { - const text = historyResponseToText(item); - if (text) { - lastResponse = text; - } - } - } - if (!firstRequest && !lastRequest && !lastResponse) { - return candidate; - } - return { ...candidate, firstRequest, lastRequest, lastResponse }; - } - - /** - * Show the advisory destination picker. The selected destination counts down - * and auto-sends unless the user begins changing the selection. - */ - private _beginPendingSend( - target: PendingTarget, - newSessionTarget: NewSessionTarget, - results: ISessionRouteResult[], - candidates: IRoutableSession[], - submittedInput: string, - submittedAttachmentIds: readonly string[], - utterance: string, - requestOptions: IChatSendRequestOptions, - cts: CancellationTokenSource, - ): void { - const badge = dom.$('.chat-routing-badge'); - this.host.placeBadge(badge); - if (!badge.parentElement) { - this.logService.warn('[chatSessionRouting] no surface available for destination review; preserving draft'); - cts.cancel(); - this.host.onDidRejectRoute?.(undefined, requestOptions.isVoiceModeInput); - this._submitDraftListeners.clear(); - this._setSubmissionPhase('idle'); - return; - } - - const store = new DisposableStore(); - store.add(toDisposable(() => badge.remove())); - store.add(toDisposable(() => { - if (this._submitCts.value === cts) { - this._submitDraftListeners.clear(); - } - })); - this._pendingSend.value = store; - - this._renderCountdownBadge(badge, store, target, newSessionTarget, results, candidates, submittedInput, submittedAttachmentIds, utterance, requestOptions, cts); - } - - /** - * Confident-match badge: names the routed session and counts down, then - * auto-sends. The user can select another destination, choose several, - * abort, or keep typing to cancel before it fires. - */ - private _renderCountdownBadge( - badge: HTMLElement, - store: DisposableStore, - target: PendingTarget, - newSessionTarget: NewSessionTarget, - results: ISessionRouteResult[], - candidates: IRoutableSession[], - submittedInput: string, - submittedAttachmentIds: readonly string[], - utterance: string, - requestOptions: IChatSendRequestOptions, - cts: CancellationTokenSource, - ): void { - const targetWindow = dom.getWindow(badge); - const routeAutosendDelay = ROUTE_AUTOSEND_DELAY_MS; - badge.classList.add('chat-routing-badge-ranked'); - - const labelById = new Map(candidates.map(candidate => [candidate.sessionId, candidate.label])); - const ranked = results - .filter(result => labelById.has(result.sessionId) && isHighConfidenceSessionRoute(result)) - .sort((a, b) => b.confidence - a.confidence) - .slice(0, ROUTE_MAX_CHOICES) - .map(result => ({ - kind: 'session' as const, - sessionId: result.sessionId, - label: labelById.get(result.sessionId) ?? result.sessionId, - confidence: result.confidence, - })); - const options: PendingTarget[] = [ - ...ranked, - newSessionTarget, - ]; - const preselected = Math.max(0, options.findIndex(option => - target.kind === 'session' - ? option.kind === 'session' && option.sessionId === target.sessionId - : option.kind === 'new')); - const selection = new Set([preselected]); - - const head = dom.append(badge, dom.$('.chat-routing-badge-head')); - const headLabel = dom.append(head, dom.$('span.chat-routing-badge-title')); - const countdownEl = dom.append(head, dom.$('span.chat-routing-badge-countdown')); - const list = dom.append(badge, dom.$('.chat-routing-badge-list', { role: 'listbox', 'aria-label': localize('chatSessionRouting.sendTo', "Send to"), 'aria-multiselectable': 'true' })); - let folderPicker: ChatSessionRoutingFolderPicker | undefined; - let disposed = false; - let focusedIndex = preselected; - const rows = options.map((option, index) => { - const row = dom.append(list, dom.$('.chat-routing-badge-row', { role: 'option', tabindex: '0' })); - const mark = dom.append(row, dom.$('span.chat-routing-badge-mark')); - mark.appendChild(renderIcon(Codicon.pass)); - const label = dom.append(row, dom.$('span.chat-routing-badge-name')); - label.textContent = option.label; - const score = dom.append(row, dom.$('span.chat-routing-badge-score')); - score.textContent = option.kind === 'session' - ? index === 0 - ? localize('chatSessionRouting.bestMatchSessionModel', "Best Match · Session model") - : localize('chatSessionRouting.highConfidenceSessionModel', "High Confidence · Session model") - : requestOptions.userSelectedModelId - ? this.host.getSelectedModelLabel?.() ?? requestOptions.userSelectedModelId - : ''; - if (option.kind === 'new' && this._hasWorkspacePickerOptions()) { - const selectedFolderName = option.folder - ? this._findRoutingFolder(option.folder, option.providerId)?.name ?? this.workspaceContextService.getWorkspaceFolder(option.folder)?.name ?? basename(option.folder) - : undefined; - folderPicker = store.add(new ChatSessionRoutingFolderPicker( - row, - this.host, - { uri: option.folder, providerId: option.providerId, label: selectedFolderName }, - this.actionWidgetService, - this.workspaceContextService, - this.logService, - this.instantiationService, - )); - store.add(dom.addDisposableListener(folderPicker.element, dom.EventType.CLICK, async event => { - event.preventDefault(); - event.stopPropagation(); - selection.clear(); - selection.add(index); - renderSelection(); - countdownTimer.clear(); - countdownEl.textContent = localize('chatSessionRouting.waiting', "waiting for you"); - const selected = await folderPicker!.pick({ - provider: this._routingProvider, - getCatalog: token => this._refreshWorkspaceCatalog(token), - token: cts.token, - }); - if (selected && !disposed && !cts.token.isCancellationRequested && !didDispatch && options[index].kind === 'new') { - const name = selected.label ?? basename(selected.uri!); - const updatedTarget: NewSessionTarget = { - kind: 'new', - label: localize('chatSessionRouting.newSessionInFolder', "New session in {0}", name), - folder: selected.uri, - providerId: selected.providerId, - }; - options[index] = updatedTarget; - label.textContent = updatedTarget.label; - folderPicker!.setTarget(selected); - ariaAlert(localize('chatSessionRouting.targetFolderChanged', "New session will use folder {0}.", name)); - } - if (!disposed && !cts.token.isCancellationRequested && !didDispatch) { - startCountdown(); - } - })); - } - store.add(dom.addDisposableListener(row, dom.EventType.CLICK, event => { - focusedIndex = index; - if (event.ctrlKey || event.metaKey) { - if (selection.has(index) && selection.size > 1) { - selection.delete(index); - } else { - selection.add(index); - } - countdownTimer.clear(); - countdownEl.textContent = localize('chatSessionRouting.waiting', "waiting for you"); - renderSelection(); - return; - } - selection.clear(); - selection.add(index); - renderSelection(); - send(); - })); - return row; - }); - - const foot = dom.append(badge, dom.$('.chat-routing-badge-foot')); - const changeHint = dom.append(foot, dom.$('span')); - changeHint.textContent = localize('chatSessionRouting.changeHint', "Tab to choose · Arrow keys move · Space selects several · Escape cancels"); - const sendHint = dom.append(foot, dom.$('span.chat-routing-badge-foot-end')); - - const renderSelection = () => { - rows.forEach((row, index) => { - const selected = selection.has(index); - row.classList.toggle('selected', selected); - row.setAttribute('aria-selected', String(selected)); - row.tabIndex = focusedIndex === index ? 0 : -1; - }); - list.classList.toggle('multiple', selection.size > 1); - headLabel.textContent = selection.size > 1 - ? localize('chatSessionRouting.sendToMany', "Send to {0} sessions", selection.size) - : localize('chatSessionRouting.sendTo', "Send to"); - sendHint.textContent = selection.size > 1 - ? localize('chatSessionRouting.sendAllHint', "Enter to send to all") - : localize('chatSessionRouting.sendNowHint', "Enter to send now"); - }; - renderSelection(); - const initialTarget = options[preselected]; - ariaAlert(initialTarget.kind === 'session' - ? localize('chatSessionRouting.sendingToIn', "Sending to {0} in {1} seconds. Press Escape to cancel.", initialTarget.label, Math.ceil(routeAutosendDelay / 1000)) - : localize('chatSessionRouting.confirmNewSession', "No confident match. Choose a destination before sending.")); - - let remainingSeconds = Math.ceil(routeAutosendDelay / 1000); - const renderCountdown = () => { - countdownEl.textContent = localize('chatSessionRouting.sendingIn', "sending in {0}s", remainingSeconds); - }; - - let didDispatch = false; - const send = () => { - if (didDispatch) { - return; - } - didDispatch = true; - countdownTimer.clear(); - this._submitDraftListeners.clear(); - this._setSubmissionPhase('dispatching'); - badge.classList.remove('chat-routing-badge-ranked'); - badge.replaceChildren(); - const progress = dom.append(badge, dom.$('span.chat-routing-badge-sent-mark')); - progress.appendChild(renderIcon(Codicon.loading)); - const progressLabel = dom.append(badge, dom.$('span.chat-routing-badge-label')); - progressLabel.textContent = localize('chatSessionRouting.dispatching', "Sending request…"); - const sent = [...selection].sort((a, b) => a - b).map(index => options[index]); - if (!sent.length) { - this.host.onDidRejectRoute?.(undefined, requestOptions.isVoiceModeInput); - this._setSubmissionPhase('idle'); - return; - } - if (sent.length > 1) { - this.host.onDidResolveRoute?.(undefined, undefined, requestOptions.isVoiceModeInput); - } - const dispatches = sent.map(selected => - this._dispatchTo(selected, submittedInput, submittedAttachmentIds, utterance, requestOptions, cts.token, sent.length === 1) - ); - if (sent.length > 1) { - void Promise.all(dispatches).then(results => { - if (this._submitCts.value === cts) { - this._setSubmissionPhase('idle'); - this._showFanoutOutcomes(sent, results); - } - }); - return; - } - void dispatches[0].then(result => { - if (this._submitCts.value !== cts) { - return; - } - this._setSubmissionPhase('idle'); - const selected = sent[0]; - if ((result.status === 'sent' || result.status === 'queued') && result.resource) { - this._showDeliveryConfirmation(selected.label, result); - } else { - this._showDispatchFailure(selected.label, result.reason); - } - }); - }; - - const countdownTimer = store.add(new MutableDisposable()); - const startCountdown = () => { - renderCountdown(); - const handle = targetWindow.setInterval(() => { - remainingSeconds--; - if (remainingSeconds <= 0) { - send(); - return; - } - renderCountdown(); - }, 1000); - countdownTimer.value = toDisposable(() => targetWindow.clearInterval(handle)); - }; - - const cancel = () => { - cts.cancel(); - this.host.onDidRejectRoute?.(undefined, requestOptions.isVoiceModeInput); - this._pendingSend.clear(); - this._setSubmissionPhase('idle'); - }; - - store.add(dom.addDisposableListener(targetWindow, dom.EventType.KEY_DOWN, event => { - if (folderPicker?.isActive || (dom.isHTMLElement(event.target) && event.target.classList.contains('chat-routing-badge-folder-action'))) { - return; - } - const keyboardEvent = new StandardKeyboardEvent(event); - if (keyboardEvent.equals(KeyCode.Escape)) { - keyboardEvent.preventDefault(); - keyboardEvent.stopPropagation(); - cancel(); - return; - } - const isRoutingInteraction = dom.isHTMLElement(event.target) && badge.contains(event.target); - const isListInteraction = isRoutingInteraction && !!event.target.closest('.chat-routing-badge-row'); - if (isListInteraction && (keyboardEvent.equals(KeyCode.UpArrow) || keyboardEvent.equals(KeyCode.DownArrow) || keyboardEvent.equals(KeyCode.Home) || keyboardEvent.equals(KeyCode.End))) { - keyboardEvent.preventDefault(); - if (keyboardEvent.equals(KeyCode.Home)) { - focusedIndex = 0; - } else if (keyboardEvent.equals(KeyCode.End)) { - focusedIndex = rows.length - 1; - } else { - const delta = keyboardEvent.equals(KeyCode.UpArrow) ? -1 : 1; - focusedIndex = (focusedIndex + delta + rows.length) % rows.length; - } - renderSelection(); - rows[focusedIndex].focus(); - countdownTimer.clear(); - countdownEl.textContent = localize('chatSessionRouting.waiting', "waiting for you"); - } else if (isListInteraction && keyboardEvent.equals(KeyCode.Space)) { - keyboardEvent.preventDefault(); - if (selection.has(focusedIndex) && selection.size > 1) { - selection.delete(focusedIndex); - } else { - selection.add(focusedIndex); - } - renderSelection(); - countdownTimer.clear(); - countdownEl.textContent = localize('chatSessionRouting.waiting', "waiting for you"); - } else if (isListInteraction && keyboardEvent.equals(KeyCode.Enter)) { - keyboardEvent.preventDefault(); - keyboardEvent.stopPropagation(); - send(); - } - }, true)); - - store.add(toDisposable(() => { - disposed = true; - })); - - startCountdown(); - } - - private _showDeliveryConfirmation(label: string, result: IChatSessionRoutingDispatchResult): void { - const resource = result.resource; - if (!resource) { - this._showDispatchFailure(label); - return; - } - this._pendingSend.clear(); - const badge = dom.$('.chat-routing-badge'); - const mark = dom.append(badge, dom.$('span.chat-routing-badge-sent-mark')); - mark.appendChild(renderIcon(result.status === 'queued' ? Codicon.clock : Codicon.pass)); - const labelEl = dom.append(badge, dom.$('span.chat-routing-badge-label')); - labelEl.textContent = result.status === 'queued' - ? localize('chatSessionRouting.queuedFor', "Queued for {0}", label) - : localize('chatSessionRouting.sentTo', "Sent to {0}", label); - this.host.placeBadge(badge); - if (!badge.parentElement) { - return; - } - - const deliveryId = ++this._deliveryConfirmationId; - const store = new DisposableStore(); - store.add(toDisposable(() => badge.remove())); - const delivery: IDeliveryConfirmation = { - completed: false, - dispose: () => store.dispose(), - }; - const reveal = result.reveal ?? (() => this.chatWidgetService.openSession(resource)); - this._addActionLink(store, badge, localize('chatSessionRouting.open', "Open"), () => void reveal()); - this._addActionLink(store, badge, localize('chatSessionRouting.dismiss', "Dismiss"), () => { - this.host.onDidDismissRoute?.(resource, result.requestId); - this._deliveryConfirmations.deleteAndDispose(deliveryId); - }); - this._deliveryConfirmations.set(deliveryId, delivery); - const announcement = result.status === 'queued' - ? localize('chatSessionRouting.queuedFor', "Queued for {0}", label) - : localize('chatSessionRouting.sentTo', "Sent to {0}", label); - ariaAlert(announcement); - let trackingActivity = false; - const trackActivity = () => { - if (!trackingActivity) { - trackingActivity = true; - this._trackDeliveryActivity(store, resource, label, mark, labelEl, result.status === 'queued', result.activityBaseline, completed => delivery.completed = completed); - } - }; - const routingProvider = this._routingProvider ?? this.host.getRoutingProvider?.(); - if (!result.reveal || routingProvider?.getSessionSnapshot) { - trackActivity(); - } - - if (result.completion) { - void result.completion.then(completion => { - if (this._deliveryConfirmations.get(deliveryId) !== delivery) { - return; - } - if (completion.status === 'sent') { - mark.replaceChildren(renderIcon(Codicon.pass)); - labelEl.textContent = localize('chatSessionRouting.sentTo', "Sent to {0}", label); - ariaAlert(labelEl.textContent); - trackActivity(); - } else { - mark.replaceChildren(renderIcon(completion.reasonCode === 'providerRemoved' ? Codicon.circleSlash : Codicon.error)); - labelEl.textContent = completion.reasonCode === 'providerRemoved' - ? localize('chatSessionRouting.noLongerQueued', "Request is no longer queued for {0}", label) - : completion.reasonCode === 'cancelled' - ? localize('chatSessionRouting.queueCancelled', "Queued request to {0} was cancelled", label) - : localize('chatSessionRouting.queuedNotSent', "Queued request to {0} was not sent", label); - ariaAlert(labelEl.textContent); - } - }); - } - } - - private _clearCompletedDeliveryConfirmations(): void { - for (const deliveryId of [...this._deliveryConfirmations.keys()]) { - if (this._deliveryConfirmations.get(deliveryId)?.completed) { - this._deliveryConfirmations.deleteAndDispose(deliveryId); - } - } - } - - private _trackDeliveryActivity(store: DisposableStore, resource: URI, label: string, mark: HTMLElement, labelElement: HTMLElement, waitForActivity: boolean, activityBaseline: number | undefined, setCompleted: (completed: boolean) => void): void { - const routingProvider = this._routingProvider ?? this.host.getRoutingProvider?.(); - if (routingProvider?.getSessionSnapshot) { - this._trackProviderDeliveryActivity(store, routingProvider, resource, label, mark, labelElement, activityBaseline, setCompleted); - return; - } - const model = this.chatService.getSession(resource); - const renderedPreview = store.add(new MutableDisposable()); - let lastAnnouncement = labelElement.textContent; - let observedActivity = !waitForActivity; - const update = (requestInProgress = model?.requestInProgress.get() ?? false, needsInput = !!model?.requestNeedsInput.get()) => { - const session = this.agentSessionsService.model.getSession(resource); - const sessionLabel = session?.label || label; - const sessionStatus = session?.status; - let icon = waitForActivity && !observedActivity ? Codicon.clock : Codicon.pass; - let statusLabel = localize('chatSessionRouting.sentTo', "Sent to {0}", sessionLabel); - let isCompleted = false; - if (needsInput || sessionStatus === AgentSessionStatus.NeedsInput) { - observedActivity = true; - icon = Codicon.question; - statusLabel = localize('chatSessionRouting.needsInputIn', "{0} needs your input", sessionLabel); - } else if (requestInProgress || sessionStatus === AgentSessionStatus.InProgress) { - observedActivity = true; - icon = Codicon.loading; - statusLabel = localize('chatSessionRouting.inProgress', "In progress: {0}", sessionLabel); - } else if (sessionStatus === AgentSessionStatus.Failed) { - observedActivity = true; - icon = Codicon.error; - statusLabel = localize('chatSessionRouting.failedIn', "Failed in {0}", sessionLabel); - } else if (observedActivity && (sessionStatus === AgentSessionStatus.Completed || model?.hasRequests)) { - statusLabel = localize('chatSessionRouting.completed', "Completed {0}", lowercaseFirstLetter(sessionLabel)); - isCompleted = true; - } - setCompleted(isCompleted); - const response = model?.lastRequest?.response; - const preview = isCompleted && response?.isComplete - ? responsePreview(response.response.getMarkdown()) - : undefined; - if (preview) { - renderedPreview.value = renderCompletedResponse(labelElement, sessionLabel, preview); - } else { - renderedPreview.clear(); - labelElement.classList.remove('chat-routing-badge-completed'); - labelElement.textContent = statusLabel; - } - mark.replaceChildren(renderIcon(icon)); - if (statusLabel !== lastAnnouncement) { - lastAnnouncement = statusLabel; - ariaAlert(lastAnnouncement); - } - }; - if (model) { - store.add(autorun(reader => update(model.requestInProgress.read(reader), !!model.requestNeedsInput.read(reader)))); - if (model.lastRequest?.response) { - store.add(model.lastRequest.response.onDidChange(() => update())); - } - } else { - update(); - } - store.add(this.agentSessionsService.model.onDidChangeSessions(() => update())); - } - - private _trackProviderDeliveryActivity( - store: DisposableStore, - provider: IChatSessionRoutingProvider, - resource: URI, - label: string, - mark: HTMLElement, - labelElement: HTMLElement, - activityBaseline: number | undefined, - setCompleted: (completed: boolean) => void, - ): void { - const cts = new CancellationTokenSource(); - store.add(toDisposable(() => cts.dispose(true))); - const renderedPreview = store.add(new MutableDisposable()); - let updateSequence = 0; - let previous: IRoutableSession | undefined; - let observedActivity = false; - let lastAnnouncement = labelElement.textContent; - const update = async () => { - const sequence = ++updateSequence; - let session: IRoutableSession | undefined; - try { - session = await provider.getSessionSnapshot!(resource, cts.token); - } catch (error) { - if (!cts.token.isCancellationRequested) { - this.logService.warn('[chatSessionRouting] tracking provider delivery failed:', error); - } - return; - } - if (cts.token.isCancellationRequested || sequence !== updateSequence || !session) { - return; - } - const changedSincePrevious = previous !== undefined && ( - session.label !== previous.label - || session.status !== previous.status - || session.lastActivity !== previous.lastActivity - || session.lastResponse !== previous.lastResponse - ); - observedActivity = observedActivity - || changedSincePrevious - || session.label !== label - || (activityBaseline !== undefined && session.lastActivity !== activityBaseline) - || session.status === 'working' - || session.status === 'needsInput' - || session.status === 'failed'; - previous = session; - - let icon = Codicon.pass; - let statusLabel = localize('chatSessionRouting.sentTo', "Sent to {0}", session.label); - let isCompleted = false; - if (session.status === 'needsInput') { - icon = Codicon.question; - statusLabel = localize('chatSessionRouting.needsInputIn', "{0} needs your input", session.label); - } else if (session.status === 'working') { - icon = Codicon.loading; - statusLabel = localize('chatSessionRouting.inProgress', "In progress: {0}", session.label); - } else if (session.status === 'failed') { - icon = Codicon.error; - statusLabel = localize('chatSessionRouting.failedIn', "Failed in {0}", session.label); - } else if (observedActivity && session.status === 'idle') { - statusLabel = localize('chatSessionRouting.completed', "Completed {0}", lowercaseFirstLetter(session.label)); - isCompleted = true; - } - setCompleted(isCompleted); - - const preview = isCompleted ? responsePreview(session.lastResponse) : undefined; - if (preview) { - renderedPreview.value = renderCompletedResponse(labelElement, session.label, preview); - } else { - renderedPreview.clear(); - labelElement.classList.remove('chat-routing-badge-completed'); - labelElement.textContent = statusLabel; - } - mark.replaceChildren(renderIcon(icon)); - if (statusLabel !== lastAnnouncement) { - lastAnnouncement = statusLabel; - ariaAlert(lastAnnouncement); - } - }; - void update(); - if (provider.watchSession) { - store.add(provider.watchSession(resource, () => void update())); - } else if (provider.onDidChangeSessions) { - store.add(provider.onDidChangeSessions(() => void update())); - } - } - - private _showFanoutOutcomes(targets: readonly PendingTarget[], results: readonly IChatSessionRoutingDispatchResult[]): void { - const badge = dom.$('.chat-routing-badge'); - badge.classList.add('chat-routing-badge-outcomes'); - const store = new DisposableStore(); - store.add(toDisposable(() => badge.remove())); - const heading = dom.append(badge, dom.$('span.chat-routing-badge-label')); - heading.textContent = localize('chatSessionRouting.deliveryResults', "Delivery results"); - const list = dom.append(badge, dom.$('.chat-routing-outcome-list')); - results.forEach((result, index) => { - const target = targets[index]; - const row = dom.append(list, dom.$('.chat-routing-outcome-row')); - const icon = dom.append(row, dom.$('span.chat-routing-badge-sent-mark')); - icon.appendChild(renderIcon(result.status === 'rejected' ? Codicon.error : result.status === 'queued' ? Codicon.clock : Codicon.pass)); - const text = dom.append(row, dom.$('span.chat-routing-badge-label')); - text.textContent = result.status === 'rejected' - ? localize('chatSessionRouting.targetFailed', "{0}: failed", target.label) - : result.status === 'queued' - ? localize('chatSessionRouting.targetQueued', "{0}: queued", target.label) - : localize('chatSessionRouting.targetSent', "{0}: sent", target.label); - const resource = result.resource; - if (resource) { - const reveal = result.reveal ?? (() => this.chatWidgetService.openSession(resource)); - this._addActionLink(store, row, localize('chatSessionRouting.open', "Open"), () => void reveal()); - } - if (result.completion) { - void result.completion.then(completion => { - icon.replaceChildren(renderIcon(completion.status === 'sent' - ? Codicon.pass - : completion.reasonCode === 'providerRemoved' ? Codicon.circleSlash : Codicon.error)); - text.textContent = completion.status === 'sent' - ? localize('chatSessionRouting.targetSent', "{0}: sent", target.label) - : completion.reasonCode === 'providerRemoved' - ? localize('chatSessionRouting.targetNoLongerQueued', "{0}: no longer queued", target.label) - : completion.reasonCode === 'cancelled' - ? localize('chatSessionRouting.targetCancelled', "{0}: cancelled", target.label) - : localize('chatSessionRouting.targetFailed', "{0}: failed", target.label); - }); - } - }); - this.host.placeBadge(badge); - if (!badge.parentElement) { - return; - } - - this._addActionLink(store, badge, localize('chatSessionRouting.dismiss', "Dismiss"), () => this._pendingSend.clear()); - this._pendingSend.value = store; - const sent = results.filter(result => result.status === 'sent').length; - const queued = results.filter(result => result.status === 'queued').length; - const failed = results.length - sent - queued; - ariaAlert(localize('chatSessionRouting.fanoutResult', "{0} sent, {1} queued, {2} failed.", sent, queued, failed)); - } - - private _showDispatchFailure(label?: string, reason?: string): void { - const badge = dom.$('.chat-routing-badge'); - const mark = dom.append(badge, dom.$('span.chat-routing-badge-sent-mark')); - mark.appendChild(renderIcon(Codicon.error)); - const message = dom.append(badge, dom.$('span.chat-routing-badge-label')); - message.textContent = label && reason - ? localize('chatSessionRouting.sendFailedToWithReason', "Could not send to {0}: {1} Your draft was preserved.", label, reason) - : label - ? localize('chatSessionRouting.sendFailedTo', "Could not send to {0}. Your draft was preserved.", label) - : localize('chatSessionRouting.sendFailed', "Could not send the request. Your draft was preserved."); - this.host.placeBadge(badge); - if (!badge.parentElement) { - return; - } - const store = new DisposableStore(); - store.add(toDisposable(() => badge.remove())); - this._addActionLink(store, badge, localize('chatSessionRouting.dismiss', "Dismiss"), () => this._pendingSend.clear()); - this._pendingSend.value = store; - ariaAlert(message.textContent); - } - - /** Append an accessible link-style action to the badge. */ - private _addActionLink(store: DisposableStore, badge: HTMLElement, text: string, run: () => void): HTMLElement { - const el = dom.append(badge, dom.$('a.chat-routing-badge-action', { role: 'button', tabindex: '0' })); - el.textContent = text; - store.add(dom.addDisposableListener(el, dom.EventType.CLICK, run)); - store.add(dom.addStandardDisposableListener(el, dom.EventType.KEY_DOWN, e => { - if (e.equals(KeyCode.Enter) || e.equals(KeyCode.Space)) { - e.preventDefault(); - run(); - } - })); - return el; - } - - /** Dispatch a resolved pending target. */ - private async _dispatchTo(target: PendingTarget, submittedInput: string, submittedAttachmentIds: readonly string[], utterance: string, requestOptions: IChatSendRequestOptions, token: CancellationToken, notifyRoute = true): Promise { - if (target.kind === 'new') { - return this._dispatchToNewSession(submittedInput, submittedAttachmentIds, utterance, requestOptions, token, notifyRoute, target); - } - return this._dispatchToSession(target.sessionId, submittedInput, submittedAttachmentIds, utterance, requestOptions, token, notifyRoute); - } - - private async _dispatchToSession(sessionId: string, submittedInput: string, submittedAttachmentIds: readonly string[], utterance: string, requestOptions: IChatSendRequestOptions, token: CancellationToken, notifyRoute: boolean): Promise { - const routingProvider = this._routingProvider ?? this.host.getRoutingProvider?.(); - if (routingProvider) { - return this._dispatchToProviderSession(routingProvider, sessionId, submittedInput, submittedAttachmentIds, utterance, requestOptions, token, notifyRoute); - } - - let target: URI; - try { - target = URI.parse(sessionId); - } catch (err) { - if (notifyRoute) { - this.host.onDidRejectRoute?.(undefined, requestOptions.isVoiceModeInput); - } - this.logService.warn('[chatSessionRouting] invalid session id for routing:', sessionId, err); - return { status: 'rejected' }; - } - - try { - const ref = await this.chatService.acquireOrLoadSession(target, ChatAgentLocation.Chat, token, `${this.debugOwner}-route`); - if (token.isCancellationRequested) { - ref?.dispose(); - if (notifyRoute) { - this.host.onDidRejectRoute?.(target, requestOptions.isVoiceModeInput); - } - return { status: 'rejected' }; - } - if (!ref) { - if (notifyRoute) { - this.host.onDidRejectRoute?.(target, requestOptions.isVoiceModeInput); - } - this.logService.warn('[chatSessionRouting] could not load routed session:', sessionId); - return { status: 'rejected' }; - } - let result: IChatSessionRoutingDispatchResult; - let requestId: string | undefined; - let disposeReference = true; - try { - if (notifyRoute) { - this.host.onWillDispatchRoute?.(target); - } - result = await this._sendRequest(target, utterance, { - ...requestOptions, - // Existing Agent Host queues retain their session model. Their - // remote queue protocol has no per-request model override. - userSelectedModelId: undefined, - agentIdSilent: getChatSessionType(target), - queue: ChatRequestQueueKind.Queued, - }); - if (result.status === 'queued' && result.completion) { - disposeReference = false; - result = { - ...result, - completion: result.completion.finally(() => ref.dispose()), - }; - } - requestId = result.requestId ?? (result.status === 'sent' ? ref.object.lastRequest?.id : undefined); - } finally { - if (disposeReference) { - ref.dispose(); - } - } - if (result.status === 'rejected') { - if (notifyRoute) { - this.host.onDidRejectRoute?.(target, requestOptions.isVoiceModeInput); - } - this.logService.warn('[chatSessionRouting] routed session rejected the request:', sessionId); - return result; - } - if (notifyRoute && result.resource) { - this.host.onDidResolveRoute?.(result.resource, 'existing_session', requestOptions.isVoiceModeInput, requestId); - } - this._clearInputIfUnchanged(submittedInput, submittedAttachmentIds); - return result; - } catch (err) { - if (notifyRoute) { - this.host.onDidRejectRoute?.(target, requestOptions.isVoiceModeInput); - } - if (token.isCancellationRequested) { - return { status: 'rejected' }; - } - this.logService.warn('[chatSessionRouting] error dispatching to routed session:', err); - return { status: 'rejected' }; - } - } - - private async _dispatchToProviderSession(routingProvider: IChatSessionRoutingProvider, sessionId: string, submittedInput: string, submittedAttachmentIds: readonly string[], utterance: string, requestOptions: IChatSendRequestOptions, token: CancellationToken, notifyRoute: boolean): Promise { - const target = routingProvider.resolveSessionResource(sessionId); - try { - if (notifyRoute && target) { - this.host.onWillDispatchRoute?.(target); - } - const result = await routingProvider.dispatchToSession(sessionId, utterance, requestOptions, token); - const resource = result.resource ?? target; - if (result.status === 'rejected' || !resource) { - if (notifyRoute) { - this.host.onDidRejectRoute?.(resource, requestOptions.isVoiceModeInput); - } - return result.status === 'rejected' ? result : { status: 'rejected', reasonCode: 'providerRemoved' }; - } - const requestId = result.requestId ?? this.chatService.getSession(resource)?.lastRequest?.id; - if (notifyRoute) { - this.host.onDidResolveRoute?.(resource, 'existing_session', requestOptions.isVoiceModeInput, requestId); - } - this._clearInputIfUnchanged(submittedInput, submittedAttachmentIds); - return { - ...result, - resource, - requestId, - reveal: () => routingProvider.revealSession(resource), - }; - } catch (error) { - if (notifyRoute) { - this.host.onDidRejectRoute?.(target, requestOptions.isVoiceModeInput); - } - if (!token.isCancellationRequested) { - this.logService.warn('[chatSessionRouting] error dispatching to provider session:', error); - } - return { status: 'rejected', resource: target, reasonCode: token.isCancellationRequested ? 'cancelled' : undefined }; - } - } - - private async _dispatchToNewSession(submittedInput: string, submittedAttachmentIds: readonly string[], utterance: string, requestOptions: IChatSendRequestOptions, token: CancellationToken, notifyRoute: boolean, target?: IChatSessionRoutingNewSessionTarget): Promise { - const routingProvider = this._routingProvider ?? this.host.getRoutingProvider?.(); - if (routingProvider) { - return this._dispatchToProviderNewSession(routingProvider, submittedInput, submittedAttachmentIds, utterance, requestOptions, token, notifyRoute, target); - } - - let routeResource: URI | undefined; - try { - let folder = target?.folder; - const sessionTarget = this.host.getNewSessionTarget?.() ?? AgentSessionProviders.Local; - const ref = sessionTarget === AgentSessionProviders.Local - ? this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { debugOwner: `${this.debugOwner}-new` }) - : await this.chatService.acquireOrLoadSession( - URI.from({ scheme: sessionTarget, path: `/untitled-${generateUuid()}` }), - ChatAgentLocation.Chat, - token, - `${this.debugOwner}-new`, - ); - if (!ref) { - if (notifyRoute) { - this.host.onDidRejectRoute?.(undefined, requestOptions.isVoiceModeInput); - } - this.logService.warn(`[chatSessionRouting] unable to create a new ${sessionTarget} session`); - return { status: 'rejected' }; - } - routeResource = ref.object.sessionResource; - if (token.isCancellationRequested) { - ref.dispose(); - if (notifyRoute) { - this.host.onDidRejectRoute?.(routeResource, requestOptions.isVoiceModeInput); - } - return { status: 'rejected' }; - } - folder ??= this._resolveNewSessionTarget(utterance, requestOptions.attachedContext, [], []).folder; - if (folder) { - this.newSessionFolderService.setFolder(ref.object.sessionResource, folder); - } - let result: IChatSessionRoutingDispatchResult; - let requestId: string | undefined; - try { - if (notifyRoute) { - this.host.onWillDispatchRoute?.(ref.object.sessionResource); - } - result = await this._sendRequest(ref.object.sessionResource, utterance, { - ...requestOptions, - agentIdSilent: sessionTarget === AgentSessionProviders.Local ? undefined : sessionTarget, - }); - requestId = result.requestId ?? (result.status === 'sent' ? ref.object.lastRequest?.id : undefined); - } finally { - ref.dispose(); - } - if (result.status === 'rejected') { - if (notifyRoute) { - this.host.onDidRejectRoute?.(ref.object.sessionResource, requestOptions.isVoiceModeInput); - } - this.logService.warn('[chatSessionRouting] new session rejected the request'); - return result; - } - if (notifyRoute && result.resource) { - this.host.onDidResolveRoute?.(result.resource, 'new_session', requestOptions.isVoiceModeInput, requestId); - } - this._clearInputIfUnchanged(submittedInput, submittedAttachmentIds); - return result; - } catch (err) { - if (notifyRoute) { - this.host.onDidRejectRoute?.(routeResource, requestOptions.isVoiceModeInput); - } - if (token.isCancellationRequested) { - return { status: 'rejected' }; - } - this.logService.warn('[chatSessionRouting] error starting a new session:', err); - return { status: 'rejected' }; - } - } - - private async _dispatchToProviderNewSession(routingProvider: IChatSessionRoutingProvider, submittedInput: string, submittedAttachmentIds: readonly string[], utterance: string, requestOptions: IChatSendRequestOptions, token: CancellationToken, notifyRoute: boolean, target?: IChatSessionRoutingNewSessionTarget): Promise { - try { - const resolvedTarget = target ?? this._resolveNewSessionTarget(utterance, requestOptions.attachedContext, [], []); - const result = await routingProvider.dispatchToNewSession({ - folder: resolvedTarget.folder, - providerId: resolvedTarget.providerId, - }, utterance, requestOptions, token); - const resource = result.resource; - if (result.status === 'rejected' || !resource) { - if (notifyRoute) { - this.host.onDidRejectRoute?.(resource, requestOptions.isVoiceModeInput); - } - return result.status === 'rejected' ? result : { status: 'rejected', reasonCode: 'providerRemoved' }; - } - const requestId = result.requestId ?? this.chatService.getSession(resource)?.lastRequest?.id; - if (notifyRoute) { - this.host.onDidResolveRoute?.(resource, 'new_session', requestOptions.isVoiceModeInput, requestId); - } - this._clearInputIfUnchanged(submittedInput, submittedAttachmentIds); - return { - ...result, - resource, - requestId, - reveal: () => routingProvider.revealSession(resource), - }; - } catch (error) { - if (notifyRoute) { - this.host.onDidRejectRoute?.(undefined, requestOptions.isVoiceModeInput); - } - if (!token.isCancellationRequested) { - this.logService.warn('[chatSessionRouting] error dispatching to provider new session:', error); - } - return { status: 'rejected', reasonCode: token.isCancellationRequested ? 'cancelled' : undefined }; - } - } - - private async _sendRequest(resource: URI, utterance: string, options: IChatSendRequestOptions): Promise { - const result = await this.chatService.sendRequest(resource, utterance, options); - if (result.kind === 'rejected') { - return { status: 'rejected', reason: result.reason, reasonCode: result.reasonCode }; - } - if (result.kind === 'queued') { - return { - status: 'queued', - resource, - requestId: result.requestId, - completion: this._resolveQueuedCompletion(resource, result.deferred), - }; - } - // A sent result does not carry the request id directly, and reading - // `model.lastRequest` here races request creation (especially when an - // untitled agent session is replaced by its durable resource). The response - // model is the authoritative owner of the stable request id and is created - // independently of response completion, so wait only for that model. - const response = await result.data.responseCreatedPromise; - return { status: 'sent', resource: result.newSessionResource ?? resource, requestId: response.requestId }; - } - - private async _resolveQueuedCompletion(resource: URI, deferred: Promise): Promise { - try { - let result = await deferred; - while (result.kind === 'queued') { - result = await result.deferred; - } - return result.kind === 'sent' - ? { status: 'sent', resource: result.newSessionResource ?? resource } - : { status: 'rejected', resource: result.newSessionResource ?? resource, reason: result.reason, reasonCode: result.reasonCode }; - } catch (error) { - this.logService.warn('[chatSessionRouting] queued request failed:', error); - return { status: 'rejected', resource, reason: error instanceof Error ? error.message : String(error) }; - } - } - - /** - * Clear the input (and its explicit attachments) only if the editor still - * holds exactly what was submitted, so a newer draft typed while the request - * was in flight is preserved. - */ - private _attachmentIds(): string[] { - return this.host.widget.attachmentModel.attachments.map(attachment => attachment.id); - } - - private _clearInputIfUnchanged(submittedInput: string, submittedAttachmentIds: readonly string[]): void { - const editor = this.host.widget.inputEditor; - const currentAttachmentIds = this._attachmentIds(); - const attachmentsUnchanged = currentAttachmentIds.length === submittedAttachmentIds.length - && currentAttachmentIds.every((id, index) => id === submittedAttachmentIds[index]); - if (editor.getValue() === submittedInput && attachmentsUnchanged) { - this._submitDraftListeners.clear(); - editor.setValue(''); - this.host.widget.attachmentModel.clear(); - } - } - - override dispose(): void { - // The host widget can be disposed before this controller by a shared - // disposable store, so teardown must cancel without touching its UI. - this._cancelPending(false); - super.dispose(); - } -} diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingFolderPicker.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingFolderPicker.ts deleted file mode 100644 index 49bbbf217210be..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingFolderPicker.ts +++ /dev/null @@ -1,459 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as dom from '../../../../../base/browser/dom.js'; -import { IAnchor } from '../../../../../base/browser/ui/contextview/contextview.js'; -import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -import { AnchorPosition } from '../../../../../base/common/layout.js'; -import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; -import { basename, isEqual } from '../../../../../base/common/resources.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { localize } from '../../../../../nls.js'; -import { ActionListItemKind, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; -import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; -import { TabbedActionListWidget } from '../../../../../platform/actionWidget/browser/tabbedActionListWidget.js'; -import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; -import { IChatSessionRoutingProvider, IChatSessionRoutingWorkspace, IChatSessionRoutingWorkspaceBrowseAction, IChatSessionRoutingWorkspaceCatalog } from '../../common/sessionRouter.js'; -import { withChatInputPickerMotion } from '../widget/input/chatInputPickerActionItem.js'; - -export interface IChatSessionRoutingFolderPickerHost { - /** Prepare or release a host-owned action-widget surface. */ - onDidChangeActionWidgetVisibility?(visible: boolean, anchor?: HTMLElement): void | Promise; - /** Container used to render action widgets, when the host owns a separate surface. */ - getActionWidgetContainer?(): HTMLElement | undefined; - /** Translate an element anchor into the host's action-widget coordinate space. */ - getActionWidgetAnchor?(anchor: HTMLElement): HTMLElement | IAnchor; - /** Override the action-widget direction when the host renders it on a separate surface. */ - getActionWidgetAnchorPosition?(): AnchorPosition; - /** Open the host's native folder picker for a standalone working directory. */ - pickFolder?(defaultUri: URI | undefined): Promise; -} - -export interface IChatSessionRoutingFolderPickerTarget { - readonly uri?: URI; - readonly providerId?: string; - readonly label?: string; -} - -export interface IChatSessionRoutingFolderPickerOptions { - readonly provider: IChatSessionRoutingProvider | undefined; - readonly getCatalog: (token: CancellationToken) => Promise; - readonly token: CancellationToken; -} - -type FolderPickerItem = - | { readonly id: string; readonly kind: 'workspace'; readonly folder: IWorkspaceFolder } - | { readonly id: string; readonly kind: 'providerWorkspace'; readonly workspace: IChatSessionRoutingWorkspace } - | { readonly id: string; readonly kind: 'providerBrowse'; readonly action: IChatSessionRoutingWorkspaceBrowseAction } - | { readonly id: 'choose-folder'; readonly kind: 'choose' }; - -interface IActiveFolderPicker { - readonly id: number; - readonly options: IChatSessionRoutingFolderPickerOptions; - readonly resolve: (target: IChatSessionRoutingFolderPickerTarget | undefined) => void; - readonly store: DisposableStore; - browsing: boolean; - surfaceOpen: boolean; - shown: 'flat' | 'tabbed' | undefined; -} - -/** - * Renders and owns the Change Folder action plus its action-widget picker. - * Callers pause their own countdown while {@link pick} directly resolves the - * selected provider-neutral workspace target. - */ -export class ChatSessionRoutingFolderPicker extends Disposable { - - readonly element: HTMLButtonElement; - - private readonly _tabbedFolderPicker: TabbedActionListWidget | undefined; - private _target: IChatSessionRoutingFolderPickerTarget; - private _active: IActiveFolderPicker | undefined; - private _requestId = 0; - private _isDisposed = false; - - get isActive(): boolean { - return !!this._active; - } - - constructor( - parent: HTMLElement, - private readonly host: IChatSessionRoutingFolderPickerHost, - initialTarget: IChatSessionRoutingFolderPickerTarget, - private readonly actionWidgetService: IActionWidgetService, - private readonly workspaceContextService: IWorkspaceContextService, - private readonly logService: ILogService, - instantiationService: IInstantiationService, - ) { - super(); - this._target = initialTarget; - this.element = dom.append(parent, dom.$('button.chat-routing-badge-folder-action', { - type: 'button', - 'aria-label': localize('chatSessionRouting.changeTargetFolderAria', "Change target folder for new session"), - 'aria-haspopup': 'menu', - 'aria-expanded': 'false', - })) as HTMLButtonElement; - this._tabbedFolderPicker = this._register(instantiationService.createInstance(TabbedActionListWidget)); - this._render(false); - } - - setTarget(target: IChatSessionRoutingFolderPickerTarget): void { - this._target = target; - this._render(this.isActive); - } - - async pick(options: IChatSessionRoutingFolderPickerOptions): Promise { - if (options.token.isCancellationRequested || this._isDisposed) { - return undefined; - } - if (this._active) { - this._finish(this._active, undefined); - return undefined; - } - - let resolve!: (target: IChatSessionRoutingFolderPickerTarget | undefined) => void; - const result = new Promise(r => resolve = r); - const active: IActiveFolderPicker = { - id: ++this._requestId, - options, - resolve, - store: new DisposableStore(), - browsing: false, - surfaceOpen: false, - shown: undefined, - }; - this._active = active; - active.store.add(options.token.onCancellationRequested(() => this._finish(active, undefined, false))); - this._render(true); - void this._open(active); - return result; - } - - private async _open(active: IActiveFolderPicker): Promise { - try { - const catalog = await active.options.getCatalog(active.options.token); - if (!this._isCurrent(active)) { - return; - } - active.surfaceOpen = true; - await this.host.onDidChangeActionWidgetVisibility?.(true, this.element); - if (!this._isCurrent(active)) { - return; - } - this._show(active, catalog); - } catch (error) { - if (this._isCurrent(active)) { - this.logService.error('[chatSessionRouting] Failed to show folder picker', error); - this._finish(active, undefined); - } - } - } - - private _show(active: IActiveFolderPicker, catalog: IChatSessionRoutingWorkspaceCatalog | undefined): void { - const groups = catalog?.groups ?? []; - const selectedWorkspace = catalog?.workspaces.find(workspace => this._isSelectedWorkspace(workspace)) ?? catalog?.defaultWorkspace; - const initialGroup = selectedWorkspace?.group && groups.some(group => group.id === selectedWorkspace.group) - ? selectedWorkspace.group - : groups[0]?.id; - const anchor = this.host.getActionWidgetAnchor?.(this.element) ?? this.element; - const container = this.host.getActionWidgetContainer?.(); - const getItems = (group?: string) => this._getItems(catalog, group); - const accessibilityProvider = { - getAriaLabel: (item: IActionListItem) => item.item - ? item.item.kind === 'workspace' - ? localize('chatSessionRouting.folderPickerItem', "{0}, {1}", item.item.folder.name, item.item.folder.uri.fsPath) - : item.item.kind === 'providerWorkspace' - ? localize('chatSessionRouting.folderPickerItem', "{0}, {1}", item.item.workspace.label, item.item.workspace.description ?? item.item.workspace.uri.path) - : item.label ?? '' - : '', - getWidgetAriaLabel: () => localize('chatSessionRouting.selectTargetFolder', "Select the folder for the new session"), - getWidgetRole: () => 'menu' as const, - getRole: (item: IActionListItem) => item.item?.kind === 'providerBrowse' || item.item?.kind === 'choose' ? 'menuitem' as const : 'menuitemradio' as const, - isChecked: (item: IActionListItem) => item.item?.kind === 'workspace' - ? isEqual(item.item.folder.uri, this._target.uri) - : item.item?.kind === 'providerWorkspace' - ? this._isSelectedWorkspace(item.item.workspace) - : undefined, - }; - const listOptions = (items: readonly IActionListItem[]) => withChatInputPickerMotion({ - className: 'chat-folder-picker-dropdown', - anchorPosition: this.host.getActionWidgetAnchorPosition?.() ?? AnchorPosition.ABOVE, - minWidth: groups.length > 1 ? 360 : 280, - maxWidth: 420, - showFilter: catalog - ? items.filter(item => item.kind === ActionListItemKind.Action).length > 10 - : true, - filterPlaceholder: catalog - ? localize('chatSessionRouting.searchWorkspaces', "Search workspaces") - : localize('chatSessionRouting.searchFolders', "Search folders"), - focusFilterOnOpen: true, - initialFocusItemId: this._target.providerId && this._target.uri - ? `${this._target.providerId}:${this._target.uri.toString()}` - : this._target.uri?.toString(), - inlineDescription: true, - showGroupTitleOnFirstItem: true, - hideDefaultKeybindingTooltip: true, - }); - const delegate = { - onSelect: (item: FolderPickerItem) => this._select(active, item), - onHide: () => this._onHide(active), - }; - - if (groups.length > 1 && initialGroup && this._tabbedFolderPicker) { - active.shown = 'tabbed'; - this._tabbedFolderPicker.show({ - user: 'chat-folder-picker', - anchor, - container, - tabs: groups, - initialTab: initialGroup, - createActionList: group => { - const items = getItems(group); - return { items, listOptions: listOptions(items) }; - }, - delegate, - accessibilityProvider, - width: 360, - }); - return; - } - - const items = getItems(); - active.shown = 'flat'; - this.actionWidgetService.show( - 'chat-folder-picker', - false, - items, - delegate, - anchor, - container, - undefined, - accessibilityProvider, - listOptions(items), - ); - } - - private _getItems(catalog: IChatSessionRoutingWorkspaceCatalog | undefined, group?: string): IActionListItem[] { - if (!catalog) { - const items: IActionListItem[] = this.workspaceContextService.getWorkspace().folders.map(folder => ({ - kind: ActionListItemKind.Action, - item: { id: folder.uri.toString(), kind: 'workspace', folder }, - group: { - title: '', - icon: isEqual(folder.uri, this._target.uri) ? Codicon.check : Codicon.folder, - }, - label: folder.name, - description: folder.uri.fsPath, - })); - if (this.host.pickFolder) { - items.push({ - kind: ActionListItemKind.Action, - item: { id: 'choose-folder', kind: 'choose' }, - group: { title: '', icon: Codicon.folderOpened }, - label: localize('chatSessionRouting.chooseExternalFolder', "Choose Folder…"), - }); - } - return items; - } - - const workspaces = catalog.workspaces.filter(workspace => !group || workspace.group === group); - const browseActions = catalog.browseActions.filter(action => !group || action.group === group); - const items: IActionListItem[] = workspaces.map(workspace => ({ - kind: ActionListItemKind.Action, - item: { id: `${workspace.providerId}:${workspace.uri.toString()}`, kind: 'providerWorkspace', workspace }, - group: { title: '', icon: this._isSelectedWorkspace(workspace) ? Codicon.check : workspace.icon ?? Codicon.folder }, - label: workspace.label, - description: workspace.description, - disabled: workspace.disabled, - })); - if (items.length && browseActions.length) { - items.push({ kind: ActionListItemKind.Separator, label: '' }); - } - for (const action of browseActions) { - items.push({ - kind: ActionListItemKind.Action, - item: { id: action.id, kind: 'providerBrowse', action }, - group: { title: '', icon: action.icon ?? Codicon.folderOpened }, - label: action.label, - description: action.description, - disabled: action.disabled, - }); - } - return items; - } - - private _select(active: IActiveFolderPicker, item: FolderPickerItem): void { - if (!this._isCurrent(active)) { - return; - } - switch (item.kind) { - case 'workspace': - this._finish(active, { uri: item.folder.uri, label: item.folder.name }); - return; - case 'providerWorkspace': - void this._selectProviderWorkspace(active, item.workspace); - return; - case 'providerBrowse': - void this._browseProviderWorkspace(active, item.action); - return; - case 'choose': - void this._browseLocalFolder(active); - } - } - - private async _selectProviderWorkspace(active: IActiveFolderPicker, workspace: IChatSessionRoutingWorkspace): Promise { - this._beginBrowsing(active); - try { - await active.options.provider?.selectNewSessionWorkspace?.(workspace); - if (this._isCurrent(active)) { - this._finish(active, { uri: workspace.uri, providerId: workspace.providerId, label: workspace.label }); - } - } catch (error) { - if (this._isCurrent(active)) { - this.logService.error('[chatSessionRouting] Failed to select workspace', error); - this._finish(active, undefined); - } - } - } - - private async _browseProviderWorkspace(active: IActiveFolderPicker, action: IChatSessionRoutingWorkspaceBrowseAction): Promise { - this._beginBrowsing(active); - try { - const workspace = await active.options.provider?.browseNewSessionWorkspace?.(action.id, active.options.token); - if (!workspace || !this._isCurrent(active)) { - if (this._isCurrent(active)) { - this._finish(active, undefined); - } - return; - } - await active.options.provider?.selectNewSessionWorkspace?.(workspace); - if (this._isCurrent(active)) { - this._finish(active, { uri: workspace.uri, providerId: workspace.providerId, label: workspace.label }); - } - } catch (error) { - if (this._isCurrent(active)) { - this.logService.error('[chatSessionRouting] Failed to browse for workspace', error); - this._finish(active, undefined); - } - } - } - - private async _browseLocalFolder(active: IActiveFolderPicker): Promise { - const pickFolder = this.host.pickFolder; - if (!pickFolder) { - return; - } - this._beginBrowsing(active); - try { - const folder = await pickFolder(this._target.uri); - if (folder && this._isCurrent(active)) { - this._finish(active, { uri: folder, label: basename(folder) }); - } else if (this._isCurrent(active)) { - this._finish(active, undefined); - } - } catch (error) { - if (this._isCurrent(active)) { - this.logService.error('[chatSessionRouting] Failed to choose folder', error); - this._finish(active, undefined); - } - } - } - - private _beginBrowsing(active: IActiveFolderPicker): void { - active.browsing = true; - this._hideWidget(active); - this._closeSurface(active); - } - - private _onHide(active: IActiveFolderPicker): void { - if (!this._isCurrent(active)) { - return; - } - active.shown = undefined; - this._closeSurface(active); - if (!active.browsing) { - this._finish(active, undefined, true, false); - } - } - - private _finish( - active: IActiveFolderPicker, - target: IChatSessionRoutingFolderPickerTarget | undefined, - focus = true, - hideWidget = true, - ): void { - if (!this._isCurrent(active)) { - return; - } - this._active = undefined; - this._requestId++; - if (hideWidget) { - this._hideWidget(active); - } - this._closeSurface(active); - active.store.dispose(); - this._render(false); - if (focus && !this._isDisposed && !active.options.token.isCancellationRequested) { - this.element.focus(); - } - active.resolve(target); - } - - private _hideWidget(active: IActiveFolderPicker): void { - const shown = active.shown; - active.shown = undefined; - if (shown === 'tabbed' && this._tabbedFolderPicker?.isVisible) { - this._tabbedFolderPicker.hide(); - } else if (shown === 'flat') { - this.actionWidgetService.hide(true); - } - } - - private _closeSurface(active: IActiveFolderPicker): void { - if (active.surfaceOpen) { - active.surfaceOpen = false; - void this.host.onDidChangeActionWidgetVisibility?.(false); - } - } - - private _isSelectedWorkspace(workspace: IChatSessionRoutingWorkspace): boolean { - return isEqual(workspace.uri, this._target.uri) - && (!this._target.providerId || workspace.providerId === this._target.providerId); - } - - private _isCurrent(active: IActiveFolderPicker): boolean { - return this._active === active - && active.id <= this._requestId - && !this._isDisposed - && !active.options.token.isCancellationRequested; - } - - private _render(expanded: boolean): void { - this.element.replaceChildren(); - const folderIcon = dom.append(this.element, renderIcon(Codicon.folder)); - folderIcon.setAttribute('aria-hidden', 'true'); - const label = dom.append(this.element, dom.$('span.chat-routing-badge-folder-action-label')); - label.textContent = this._target.label ?? localize('chatSessionRouting.chooseFolder', "Choose Folder"); - const chevron = dom.append(this.element, renderIcon(expanded ? Codicon.chevronLeft : Codicon.chevronRight)); - chevron.setAttribute('aria-hidden', 'true'); - this.element.title = this._target.label - ? localize('chatSessionRouting.changeTargetFolderWithName', "Change target folder ({0})", this._target.label) - : localize('chatSessionRouting.changeTargetFolder', "Choose Folder"); - this.element.setAttribute('aria-label', this.element.title); - this.element.setAttribute('aria-expanded', String(expanded)); - } - - override dispose(): void { - if (this._active) { - this._finish(this._active, undefined, false); - } - this._isDisposed = true; - super.dispose(); - } -} diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingHelpers.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingHelpers.ts deleted file mode 100644 index d2e7210926c46c..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingHelpers.ts +++ /dev/null @@ -1,186 +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 { URI } from '../../../../../base/common/uri.js'; -import { IRoutableSession, isHighConfidenceSessionRoute, ISessionRouteResult } from '../../common/sessionRouter.js'; - -/** Number of top-ranked candidates whose conversation content should be resolved. */ -export const ROUTE_ENRICH_MAX_CANDIDATES = 12; -const RELATED_SESSION_FOLDER_CONFIDENCE = 0.35; - -export interface IChatSessionRoutingFolder { - readonly uri: URI; - readonly name: string; - readonly aliases?: readonly string[]; -} - -/** - * Extracts the task from an explicit request to start a new session. - * Requests that merely mention creating another resource are deliberately ignored. - */ -export function parseExplicitNewSessionRequest(utterance: string): string | undefined { - const match = /^(?:please\s+)?(?:create|start|open)\s+(?:a\s+)?new\s+(?:chat\s+)?session(?:\s+(?:to|for|and)\s+|\s*[:,-]\s*)(.+)$/i.exec(utterance.trim()); - const task = match?.[1]?.trim(); - return task || undefined; -} - -/** - * Chooses the workspace folder for a newly routed session. Explicit folder - * mentions win, followed by a sufficiently related session's folder and then - * the caller's default. - */ -export function resolveNewSessionWorkspaceFolder( - utterance: string, - folders: readonly IChatSessionRoutingFolder[], - results: readonly ISessionRouteResult[], - candidates: readonly IRoutableSession[], - defaultFolder: URI | undefined, -): URI | undefined { - return resolveMentionedWorkspaceFolder(utterance, folders)?.uri - ?? folderFromRelatedSession(results, candidates, folders) - ?? defaultFolder - ?? folders[0]?.uri; -} - -/** Resolves an explicitly mentioned workspace folder name or path basename. */ -export function resolveMentionedWorkspaceFolder(utterance: string, folders: readonly T[]): T | undefined { - const normalizedUtterance = normalizeFolderMentionText(utterance); - let best: { folder: T; length: number } | undefined; - for (const folder of folders) { - const names = new Set([ - folder.name, - folder.uri.path.split('/').filter(Boolean).at(-1), - folder.uri.path, - folder.uri.fsPath, - ...folder.aliases ?? [], - ]); - for (const name of names) { - if (!name || name.length < 3) { - continue; - } - const normalizedName = normalizeFolderMentionText(name).trim(); - let start = normalizedUtterance.indexOf(normalizedName); - while (start >= 0) { - if (isWordBoundary(normalizedUtterance[start - 1]) - && isWordBoundary(normalizedUtterance[start + normalizedName.length])) { - if (!best || normalizedName.length > best.length) { - best = { folder, length: normalizedName.length }; - } - break; - } - start = normalizedUtterance.indexOf(normalizedName, start + normalizedName.length); - } - } - } - return best?.folder; -} - -function normalizeFolderMentionText(value: string): string { - return value - .toLowerCase() - .replace(/\bvs\s+code\b/gu, 'vscode') - .replace(/[\s._-]+/gu, ' '); -} - -/** Returns the workspace folder represented by a routed session's working-directory metadata. */ -export function resolveSessionWorkspaceFolder(candidate: IRoutableSession, folders: readonly T[]): T | undefined { - return folderForSessionMetadata(candidate, folders); -} - -/** - * Bounds transcript enrichment while preserving the model's preliminary order. - * Any remaining slots favor active and recently updated sessions. - */ -export function selectRouterShortlist( - candidates: readonly IRoutableSession[], - preliminaryResults: readonly ISessionRouteResult[], - limit: number = ROUTE_ENRICH_MAX_CANDIDATES, -): IRoutableSession[] { - if (candidates.length <= limit) { - return [...candidates]; - } - - const candidatesById = new Map(candidates.map(candidate => [candidate.sessionId, candidate])); - const selectedIds = new Set(); - const shortlist: IRoutableSession[] = []; - for (const result of preliminaryResults) { - if (result.confidence <= 0) { - continue; - } - const candidate = candidatesById.get(result.sessionId); - if (candidate && !selectedIds.has(candidate.sessionId)) { - selectedIds.add(candidate.sessionId); - shortlist.push(candidate); - if (shortlist.length === limit) { - return shortlist; - } - } - } - - const fallback = candidates - .filter(candidate => !selectedIds.has(candidate.sessionId)) - .sort((a, b) => - sessionStatusPriority(b.status) - sessionStatusPriority(a.status) - || (b.lastActivity ?? 0) - (a.lastActivity ?? 0) - || a.label.localeCompare(b.label) - || a.sessionId.localeCompare(b.sessionId)); - shortlist.push(...fallback.slice(0, limit - shortlist.length)); - return shortlist; -} - -/** Selects the top result only when it clears the shared confidence threshold. */ -export function selectBestSessionRoute(results: readonly ISessionRouteResult[]): ISessionRouteResult | undefined { - const top = results[0]; - return top && isHighConfidenceSessionRoute(top) ? top : undefined; -} - -function sessionStatusPriority(status: string | undefined): number { - return status === 'working' ? 2 : status === 'idle' ? 1 : 0; -} - -function folderFromRelatedSession( - results: readonly ISessionRouteResult[], - candidates: readonly IRoutableSession[], - folders: readonly IChatSessionRoutingFolder[], -): URI | undefined { - const candidateById = new Map(candidates.map(candidate => [candidate.sessionId, candidate])); - for (const result of results) { - if (result.confidence < RELATED_SESSION_FOLDER_CONFIDENCE) { - continue; - } - const candidate = candidateById.get(result.sessionId); - const folder = candidate && folderForSessionMetadata(candidate, folders); - if (folder) { - return folder.uri; - } - } - return undefined; -} - -function folderForSessionMetadata(candidate: IRoutableSession, folders: readonly T[]): T | undefined { - for (const path of [candidate.cwd, candidate.repo]) { - if (!path) { - continue; - } - const normalizedPath = path.replaceAll('\\', '/').replace(/\/+$/, '').replace(/^([a-zA-Z]:\/)/, '/$1').toLowerCase(); - const match = folders - .filter(folder => { - const folderPath = folder.uri.path.replace(/\/+$/, '').toLowerCase(); - return normalizedPath === folderPath - || normalizedPath.startsWith(`${folderPath}/`) - || normalizedPath.endsWith(`/${folder.name.toLowerCase()}`) - || normalizedPath.endsWith(`/${folder.name.toLowerCase()}.git`); - }) - .sort((a, b) => b.uri.path.length - a.uri.path.length)[0]; - if (match) { - return match; - } - } - return undefined; -} - -function isWordBoundary(value: string | undefined): boolean { - return value === undefined || !/[\p{L}\p{N}_-]/u.test(value); -} diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingProviderService.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingProviderService.ts deleted file mode 100644 index 8918309d7eb788..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/chatSessionRoutingProviderService.ts +++ /dev/null @@ -1,33 +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 { toDisposable } from '../../../../../base/common/lifecycle.js'; -import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; -import { IChatSessionRoutingProvider, IChatSessionRoutingProviderService } from '../../common/sessionRouter.js'; - -class ChatSessionRoutingProviderService implements IChatSessionRoutingProviderService { - - declare readonly _serviceBrand: undefined; - - private provider: IChatSessionRoutingProvider | undefined; - - registerProvider(provider: IChatSessionRoutingProvider) { - if (this.provider) { - throw new Error('A chat session routing provider is already registered'); - } - this.provider = provider; - return toDisposable(() => { - if (this.provider === provider) { - this.provider = undefined; - } - }); - } - - getProvider(): IChatSessionRoutingProvider | undefined { - return this.provider; - } -} - -registerSingleton(IChatSessionRoutingProviderService, ChatSessionRoutingProviderService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.css b/src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.css deleted file mode 100644 index 688d530df22bdf..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/media/chatSessionRouting.css +++ /dev/null @@ -1,290 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.chat-routing-badge { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; - box-sizing: border-box; - padding: 2px 10px; - overflow: hidden; - font-size: 12px; - line-height: 20px; - color: var(--vscode-foreground); - background-color: var(--vscode-editorWidget-background); - border-top: 1px solid var(--vscode-editorWidget-border, transparent); -} - -.chat-routing-badge .chat-routing-badge-label { - flex: 1 1 auto; - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.chat-routing-badge .chat-routing-badge-label.chat-routing-badge-completed { - display: flex; - align-items: baseline; - gap: var(--vscode-spacing-size20); -} - -.chat-routing-badge-response-prefix { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; -} - -.chat-routing-badge-response-preview { - display: block; - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; -} - -.chat-routing-badge-response-preview > * { - display: inline; - margin: 0; - white-space: inherit; -} - -.chat-routing-badge .chat-routing-badge-countdown { - color: var(--vscode-descriptionForeground); - font-variant-numeric: tabular-nums; -} - -.chat-routing-badge .chat-routing-badge-action { - flex-shrink: 0; - cursor: pointer; - color: var(--vscode-textLink-foreground); - /* Frameless-window drag regions swallow clicks; keep actions interactive. */ - -webkit-app-region: no-drag; -} - -.chat-routing-badge .chat-routing-badge-action:hover { - color: var(--vscode-textLink-activeForeground); - text-decoration: underline; -} - -.chat-routing-badge .chat-routing-badge-action:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 2px; - border-radius: var(--vscode-cornerRadius-small); -} - -.chat-routing-badge.chat-routing-badge-ranked { - display: flex; - flex-direction: column; - align-items: stretch; - gap: 0; - padding: 6px 0 4px; - line-height: normal; -} - -.chat-routing-badge-head { - display: flex; - align-items: baseline; - gap: 8px; - padding: 0 var(--omni-rail, 14px) 4px; -} - -.chat-routing-badge-title { - flex: 1 1 auto; - font-size: 11px; - letter-spacing: .06em; - text-transform: uppercase; - opacity: .5; -} - -.chat-routing-badge-list { - display: flex; - flex-direction: column; - padding: 0 calc(var(--omni-rail, 14px) - 8px); -} - -.chat-routing-badge-row { - display: flex; - align-items: center; - gap: var(--omni-row-gap, 8px); - padding: 4px 8px; - border-radius: var(--vscode-cornerRadius-medium); - cursor: pointer; - -webkit-app-region: no-drag; -} - -.chat-routing-badge-row:hover { - background-color: var(--vscode-list-hoverBackground); -} - -.chat-routing-badge-row:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -.chat-routing-badge-row.selected { - position: relative; - background-color: var(--vscode-list-activeSelectionBackground); - color: var(--vscode-list-activeSelectionForeground); -} - -.chat-routing-badge-row.selected::before { - content: ''; - position: absolute; - left: 0; - top: 4px; - bottom: 4px; - width: 2px; - border-radius: var(--vscode-cornerRadius-circle); - background-color: var(--vscode-focusBorder); -} - -.chat-routing-badge-row.selected .chat-routing-badge-score { - color: inherit; -} - -.chat-routing-badge-list.multiple .chat-routing-badge-row.selected::before { - display: none; -} - -.chat-routing-badge-mark { - flex: 0 0 auto; - width: var(--omni-icon-column, 16px); - display: flex; - align-items: center; - justify-content: center; - visibility: hidden; -} - -.chat-routing-badge-row.selected .chat-routing-badge-mark { - visibility: visible; -} - -.chat-routing-badge-mark .codicon[class*='codicon-'] { - font-size: var(--vscode-codiconFontSize-compact); -} - -.chat-routing-badge-name { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chat-routing-badge-score { - flex: 0 0 auto; - min-width: 4ch; - text-align: right; - font-variant-numeric: tabular-nums; - color: var(--vscode-descriptionForeground); -} - -.chat-routing-badge-folder-action { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - gap: 4px; - max-width: 180px; - height: 22px; - padding: 0 6px; - color: inherit; - background: transparent; - border: 1px solid transparent; - border-radius: var(--vscode-cornerRadius-medium); - font: inherit; - cursor: pointer; -} - -.chat-routing-badge-folder-action:hover, -.chat-routing-badge-folder-action[aria-expanded='true'] { - background-color: var(--vscode-toolbar-hoverBackground); -} - -.chat-routing-badge-folder-action:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -.chat-routing-badge-folder-action-label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.chat-routing-badge-folder-action .codicon { - flex: 0 0 auto; - font-size: var(--vscode-codiconFontSize-compact); -} - -.chat-routing-badge-folder-action:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 2px; - border-radius: var(--vscode-cornerRadius-small); -} - -.chat-routing-badge-meter { - flex: 0 0 auto; - width: 44px; - height: 3px; - border-radius: var(--vscode-cornerRadius-circle); - background-color: color-mix(in srgb, var(--vscode-foreground) 16%, transparent); - overflow: hidden; -} - -.chat-routing-badge-meter > span { - display: block; - height: 100%; - border-radius: inherit; - background-color: color-mix(in srgb, var(--vscode-foreground) 55%, transparent); -} - -.chat-routing-badge-row.selected .chat-routing-badge-meter > span { - background-color: var(--vscode-foreground); -} - -.chat-routing-badge-foot { - display: flex; - align-items: center; - gap: 8px; - padding: 5px var(--omni-rail, 14px) 0; - font-size: 11px; - opacity: .5; -} - -.chat-routing-badge-foot-end { - margin-left: auto; -} - -.chat-routing-badge-sent-mark { - flex: 0 0 auto; - display: flex; - align-items: center; - color: var(--vscode-foreground); -} - -.chat-routing-badge-sent-mark .codicon[class*='codicon-'] { - font-size: var(--vscode-codiconFontSize-compact); -} - -.chat-routing-badge.chat-routing-badge-outcomes { - align-items: stretch; - flex-direction: column; - padding-block: 6px; -} - -.chat-routing-outcome-list { - display: flex; - flex-direction: column; -} - -.chat-routing-outcome-row { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} diff --git a/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts b/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts deleted file mode 100644 index e4577f077fb6ba..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/sessionRouter/sessionRouterService.ts +++ /dev/null @@ -1,79 +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 { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { CancellationError } from '../../../../../base/common/errors.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { ChatMessageRole, getTextResponseFromStream, IChatMessage, ILanguageModelsService } from '../../common/languageModels.js'; -import { buildRouterMessages, ISessionRouteRequest, ISessionRouteResult, ISessionRouter, parseRouterResponse } from '../../common/sessionRouter.js'; - -/** - * Default {@link ISessionRouter}. Scores candidate sessions with a renderer - * language model (Copilot/CAPI under the hood). - * - * The prompt/parse logic lives in `../../common/sessionRouter.ts` so the scoring - * backend can later be swapped for the agent-host CAPI utility completion or a - * local model without changing this service's contract. - */ -export class SessionRouterService implements ISessionRouter { - - declare readonly _serviceBrand: undefined; - - constructor( - @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, - @ILogService private readonly logService: ILogService, - ) { } - - async route(request: ISessionRouteRequest, token: CancellationToken): Promise { - if (!request.sessions.length) { - return []; - } - const scored = await this.requestModel( - buildRouterMessages(request), - token, - text => parseRouterResponse(text, new Set(request.sessions.map(session => session.sessionId))), - ); - return scored ?? []; - } - - private async requestModel( - routerMessages: readonly { role: 'system' | 'user'; content: string }[], - token: CancellationToken, - parse: (text: string) => T | undefined, - ): Promise { - let modelId: string | undefined; - try { - // Use the small utility model for this background scoring task, matching - // other internal utility features (e.g. chatGoalSummaryService, - // chatToolRiskAssessmentService) rather than consuming a premium model. - const models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-utility-small' }); - modelId = models.at(0); - } catch (err) { - this.logService.trace('[SessionRouter] model selection failed, falling back from model routing', err); - } - if (!modelId) { - return undefined; - } - - const messages: IChatMessage[] = routerMessages.map(message => ({ - role: message.role === 'system' ? ChatMessageRole.System : ChatMessageRole.User, - content: [{ type: 'text', value: message.content }] - })); - - try { - const response = await this.languageModelsService.sendChatRequest(modelId, undefined, messages, {}, token); - const text = await getTextResponseFromStream(response); - return parse(text); - } catch (err) { - // Preserve cancellation semantics: a canceled token must reject so the - // caller can abort routing, rather than silently degrading to the heuristic. - if (token.isCancellationRequested) { - throw new CancellationError(); - } - this.logService.trace('[SessionRouter] scoring request failed, falling back from model routing', err); - return undefined; - } - } -} diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts index 3b871a02c4c18d..b96f4af93da37f 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts @@ -798,14 +798,9 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic } } - sendToolResult(callId: string, result: string | IVoiceDispatchResult, codingSessionId?: string): void { + sendToolResult(callId: string, result: string | IVoiceDispatchResult): void { if (this._ws?.readyState === WebSocket.OPEN) { - this._ws.send(JSON.stringify({ - type: 'tool_result', - call_id: callId, - result, - ...(codingSessionId ? { coding_session_id: codingSessionId } : {}), - })); + this._ws.send(JSON.stringify({ type: 'tool_result', call_id: callId, result })); } } @@ -820,13 +815,12 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic } } - requestNarration(codingSessionId: string, kind: VoiceNarrationKind, text: string, narrationId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata, confirmationType?: VoiceConfirmationType, pending?: { pendingId: string }, prepareToReceiveAudio?: () => void): string | undefined { + requestNarration(codingSessionId: string, kind: VoiceNarrationKind, text: string, narrationId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata, confirmationType?: VoiceConfirmationType, pending?: { pendingId: string }): string | undefined { // Gate on session_context having been sent: the WS preserves send order, // so the backend processes start_session/resume_session before any // request_narration. Pre-session this returns undefined, so _narrate queues // a retry that onSessionInit replays once the session exists. if (this._ws?.readyState === WebSocket.OPEN && this._sessionStartedOnSocket) { - prepareToReceiveAudio?.(); // Reuse a caller-supplied id (a `busy` retry) so the backend dedups; else mint one. const id = narrationId ?? generateUuid(); this._ws.send(JSON.stringify({ diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputDecorations.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputDecorations.ts index 233d09e72680ed..8fc0dacbcc0f0d 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputDecorations.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceInputDecorations.ts @@ -43,7 +43,7 @@ export interface IVoiceInputDecorationsOptions { readonly isActive: IObservable; /** Current text in the input. Voice placeholders are hidden while it is non-empty. */ readonly inputValue?: IObservable; - /** Explicit ownership for surfaces such as omni that do not yet have a resource. */ + /** Explicit ownership for surfaces that do not yet have a resource. */ readonly isOwner?: IObservable; /** Surface resource, compared with the voice target to avoid misrouting. */ readonly getCurrentResource?: () => URI | undefined; @@ -76,10 +76,6 @@ export function setupVoiceInputDecorations(services: IVoiceInputDecorationsServi }; const store = new DisposableStore(); - const getPushToTalkKeybindingLabel = () => ( - keybindingService.lookupKeybinding('workbench.action.chat.voiceInputMode.holdToTalk') - ?? keybindingService.lookupKeybinding('agentsVoice.pushToTalk') - )?.getLabel(); inputContainerEl.style.position = 'relative'; @@ -193,7 +189,8 @@ export function setupVoiceInputDecorations(services: IVoiceInputDecorationsServi transcriptOverlayNode.classList.remove('has-transcript'); transcriptOverlay.replaceChildren(); const hint = dom.$('span.partial'); - const kbLabel = getPushToTalkKeybindingLabel(); + const kb = keybindingService.lookupKeybinding('agentsVoice.pushToTalk'); + const kbLabel = kb?.getLabel(); hint.textContent = kbLabel ? localize('voiceMode.bargeInHint', "Speak or use {0}", kbLabel) : localize('voiceMode.bargeInHintNoKb', "Speak to barge in"); @@ -204,7 +201,8 @@ export function setupVoiceInputDecorations(services: IVoiceInputDecorationsServi transcriptOverlayNode.classList.remove('has-transcript'); transcriptOverlay.replaceChildren(); const hint = dom.$('span.partial'); - const kbLabel = getPushToTalkKeybindingLabel(); + const kb = keybindingService.lookupKeybinding('agentsVoice.pushToTalk'); + const kbLabel = kb?.getLabel(); hint.textContent = kbLabel ? localize('voiceMode.pttOrBargeInHint', "Press {0} to talk or barge in", kbLabel) : localize('voiceMode.clickMicOrBargeInHint', "Click voice mode to talk or barge in"); diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index 3a1bb6e1eeef2b..8f26164883139a 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -23,7 +23,7 @@ import { CommandsRegistry, ICommandService } from '../../../../../platform/comma import { ILogService } from '../../../../../platform/log/common/log.js'; import { IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; import { IVoiceTranscriptEntryMetadata, IVoiceTranscriptStore, IVoiceTranscriptTurn, VoiceTranscriptKind } from '../../../agentsVoice/common/voiceTranscriptStore.js'; -import { IVoiceAudioResponse, IVoiceBargeIn, IVoiceCheckpointNarrationMetadata, IVoiceClientService, IVoiceFatalDisconnect, IVoicePriorTimelineEntry, IVoiceSessionContext, IVoiceFeedbackPayload, IVoiceFeedbackTranscriptTurn, IVoiceTranscription, IVoiceTurnAutoEnded, IVoiceNarrationAck, IVoiceNarrationSignal, isVoiceCheckpointId, VoiceCheckpointId, VoiceConfirmationType, VoiceNarrationKind, IVoiceSessionPending, IVoicePendingQuestion, derivePendingId, getVoiceToolApprovalCommand, isPendingIdResolved, VOICE_AGENT_PROGRESS_SETTING } from '../../common/voiceClient/voiceClientService.js'; +import { IVoiceAudioResponse, IVoiceBargeIn, IVoiceCheckpointNarrationMetadata, IVoiceClientService, IVoiceFatalDisconnect, IVoicePriorTimelineEntry, IVoiceSessionContext, IVoiceFeedbackPayload, IVoiceFeedbackTranscriptTurn, IVoiceTranscription, IVoiceTurnAutoEnded, IVoiceNarrationAck, IVoiceNarrationSignal, isVoiceCheckpointId, VoiceCheckpointId, VoiceConfirmationType, VoiceNarrationKind, IVoiceSessionPending, IVoicePendingQuestion, derivePendingId, getVoiceToolApprovalCommand, isPendingIdResolved, restoreResolvedPendingId, VOICE_AGENT_PROGRESS_SETTING } from '../../common/voiceClient/voiceClientService.js'; import { voiceCloseCodeInfo, VoiceCloseCode } from '../../common/voiceClient/voiceCloseCodes.js'; import { getVoiceConfirmationType, isPendingVoiceQuestionnaireInvocation, isVoiceQuestionnaireInvocation } from '../../common/voiceClient/voiceConfirmation.js'; import { IMicCaptureService, IPttDiagnostic, isMicrophonePermissionDeniedError } from './micCaptureService.js'; @@ -46,7 +46,6 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { INotificationService, IPromptChoice, Severity } from '../../../../../platform/notification/common/notification.js'; -import { CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID, CHAT_INPUT_WINDOW_SET_VOICE_TARGET_COMMAND_ID } from '../../common/chatInputWindow.js'; import { SESSION_META_EHCLI_ADOPTABLE_KEY } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { ChatEntitlement, IChatEntitlementService, isProUser } from '../../../../services/chat/common/chatEntitlementService.js'; @@ -126,22 +125,6 @@ interface IVoiceNarratable { readonly confirmationType?: VoiceConfirmationType; } -interface IOmniQueuedNarration extends IVoiceNarratable { - readonly sessionId: string; - readonly ordinal: number; -} - -interface IRoutedVoiceRequest { - readonly requestId: string | undefined; - /** The durable request id exposed by a rehydrated model when it differs from the transient send id. */ - modelRequestId?: string; - /** The model tail before dispatch, or `null` when the session had no prior request. */ - previousRequestId?: string | null; - /** The routed request was matched against the resident model while that model was still available. */ - hasMatchedModelRequest?: boolean; - readonly phase: 'queued' | 'running' | 'waiting'; -} - interface IPlaybackNarration { readonly kind: VoiceNarrationKind; readonly checkpoint?: IVoiceCheckpointNarrationMetadata; @@ -220,18 +203,12 @@ export interface IVoiceSessionController { readonly targetSession: IObservable; /** Whether Voice Mode is explicitly owned by a new-session draft. */ readonly hasDraftTarget: IObservable; - /** Whether the floating omni input, rather than a standard chat input, owns Voice Mode. */ - readonly omniInputActive: IObservable; - /** Whether the floating omni input is open and therefore owns Voice Mode visuals. */ - readonly omniInputOpen: IObservable; /** Session that produced the response most recently spoken to the user. */ getLastSpokenResponseSession(): URI | undefined; connect(window: Window & typeof globalThis): Promise; /** Update the OS window whose focus controls capture and hands-free listening. */ setActiveWindow(window: Window & typeof globalThis): void; - /** Release omni ownership after an in-progress voice turn reaches idle. */ - releaseOmniInputOnBlur(): void; disconnect(source?: 'explicit' | 'internal'): void; pttDown(source?: 'explicit' | 'auto' | 'connect', forceNewTurn?: boolean): void; @@ -300,27 +277,9 @@ export interface IVoiceSessionController { * Set the target session for transcription. When set, transcriptions are * sent to this session instead of the currently active one. */ - setTargetSession(resource: URI | undefined, omniRoute?: 'existing_session' | 'new_session'): void; - /** Release the previous destination and cancel its stale response before routing a new request. */ - prepareForRoutingRequest(): void; - /** Mark a newly routed request so the session's previous idle response is stale. */ - markRoutedRequestPending(resource: URI, requestId?: string): void; - /** Clear request-aware narration state after a routed send is rejected. */ - clearRoutedRequest(resource: URI): void; + setTargetSession(resource: URI | undefined): void; /** Bind Voice Mode to the currently shown new-session draft. */ setDraftTarget(): void; - /** Atomically transfer capture and target ownership from omni to a chat session. */ - takeSessionInputOwnership(resource: URI, window: Window & typeof globalThis): void; - /** Atomically transfer capture ownership from omni to a new-session draft. */ - takeDraftInputOwnership(window: Window & typeof globalThis): void; - /** Atomically transfer capture ownership to omni and start with its draft. */ - takeOmniInputOwnership(window: Window & typeof globalThis): void; - /** Keep an Omni-owned target pinned when a global barge-in keybinding starts. */ - retainOmniInputOwnershipForBargeIn(window: Window & typeof globalThis): boolean; - /** Transfer Voice Mode surface ownership to or from the floating omni input. */ - setOmniInputActive(active: boolean): void; - /** Report whether the floating omni surface exists, independently of focus. */ - setOmniInputOpen(open: boolean): void; /** * Create a new chat session and set it as the target for transcription. @@ -346,10 +305,6 @@ export interface IVoiceSessionController { * view-model change event fired. */ activateSession(resource: URI): void; - /** Narrate the current actionable item for a session owned by the visible Omni inbox. */ - announceSessionInOmni(resource: URI): void; - /** Immediately synchronize a pending item resolved directly in the Omni UI. */ - notifyPendingItemResolved(resource: URI): void; /** * Submit user feedback along with full diagnostic data (transcript history, @@ -415,16 +370,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private readonly _targetSession = observableValue(this, undefined); readonly targetSession: IObservable = this._targetSession; - private _targetOmniRoute: 'existing_session' | 'new_session' | undefined; private readonly _hasDraftTarget = observableValue(this, false); readonly hasDraftTarget: IObservable = this._hasDraftTarget; - private readonly _omniInputActive = observableValue(this, false); - readonly omniInputActive: IObservable = this._omniInputActive; - private readonly _omniInputOpen = observableValue(this, false); - readonly omniInputOpen: IObservable = this._omniInputOpen; - private _omniOpenedAt = 0; - private readonly _omniCompletionEndedAtBySession = new Map(); - private readonly _omniCompletedResponseIds = new Set(); // --- Internal state --- private _pttHeld = false; @@ -478,7 +425,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private readonly _voiceEventDisposables = this._register(new DisposableStore()); private readonly _windowFocusDisposables = this._register(new DisposableStore()); private readonly _voiceAutorunDisposable = this._register(new MutableDisposable()); - private readonly _omniBlurRelease = this._register(new MutableDisposable()); /** * Holds the model reference for a session created by {@link newSessionAsTarget} * until a host adopts it. `ChatService` deletes empty untitled local sessions @@ -559,8 +505,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // transcript on every chunk, so a late chunk from the old turn would have // incorrectly cleared the flag. private _suppressIncomingAudio = false; - private _pendingOmniDispatchAcknowledgement: { sessionKey?: string } | undefined; - private readonly _pendingAfterOmniDispatchAcknowledgement = new Map(); /** Turn/response ids whose playback was cancelled by barge-in. */ private readonly _interruptedAudioIds = new Set(); @@ -655,7 +599,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC */ private readonly _responseRoutes = new Map(); private readonly _responseSessionIds = new Map(); - private readonly _responseIdsWithAudio = new Set(); private readonly _ownershipDroppedResponseIds = new Set(); /** @@ -715,9 +658,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // are no-ops on the BE because the merge-patch detects no field changes. private readonly _confirmationFlushWatchdogs = new Map>(); private static readonly _CONFIRMATION_FLUSH_DELAY_MS = 1500; - private readonly _staleContextNarrationRetryTimers = new Map>(); - private readonly _staleContextRetriedPending = new Map(); - private static readonly _STALE_CONTEXT_NARRATION_RETRY_DELAY_MS = 500; /** * Latest state change per session, buffered and flushed once after a short @@ -769,22 +709,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * session starts a new turn (``thinking``) so a stale reply is never narrated. */ private readonly _lastResponseSummaryById = new Map(); - /** Request-aware narration state for omni routes, retained across queue/run/prompt transitions. */ - private readonly _routedRequests = new Map(); - /** Omni-routed turns whose voice delivery was abandoned when the surface closed. */ - private readonly _abandonedRoutedRequests = new Set(); - /** FIFO of session-aware narrations waiting for the user or earlier inbox audio. */ - private readonly _omniNarrationQueue: IOmniQueuedNarration[] = []; - /** Direct response audio buffered behind user speech or earlier omni inbox work. */ - private readonly _omniDeferredSessionKeys = new Set(); - /** Arrival position of each session's first buffered direct response. */ - private readonly _omniDeferredSessionOrdinals = new Map(); - private _omniInboxOrdinal = 0; - /** Session items claimed while omni was open; retained as tombstones after close. */ - private readonly _omniClaimedPendingIds = new Map(); - private readonly _omniClaimedResponseSummaries = new Map(); - /** Narration ids issued by the global omni inbox, used to abandon them on close. */ - private readonly _omniNarrationIds = new Set(); /** * The exact text last narrated per session, used to de-duplicate narration @@ -912,11 +836,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, ) { super(); - this._register(CommandsRegistry.registerCommand(CHAT_INPUT_WINDOW_SET_VOICE_TARGET_COMMAND_ID, (_accessor, resource: string | undefined, kind?: 'existing_session' | 'new_session') => { - if (this._isConnected.get() || this._isConnecting.get()) { - this.setTargetSession(resource ? URI.parse(resource) : undefined, kind); - } - })); this._register(this.chatEntitlementService.onDidChangeEntitlement(() => { if (this._entitlementCheckScheduled) { @@ -1304,18 +1223,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (finishedTranscript) { this._lastNarratedText.set(spokenSessionKey, finishedTranscript); } - if (finishedNarration?.kind === 'response') { - this._clearPendingResponse(spokenSessionKey); - this._completeRoutedResponse(spokenSessionId); - } else if (this._routedRequests.has(spokenSessionKey) || this._isOmniVoiceInboxActive()) { - // An untagged voice-backend reply can be a short acknowledgement - // after an approval, not the chat task's final response. Keep - // ownership until the model's completed summary is heard. If that - // summary arrived while this audio was playing, resume it now. - this._resumePendingResponseAfterPlayback(spokenSessionId); - } else { - this._clearPendingResponse(spokenSessionKey); - } + this._clearPendingResponse(spokenSessionKey); } } // The response actually played to the end: mark it heard (set the @@ -1324,7 +1232,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // that it was queued or received. if (finishedResponseId) { this._markNarrationHeard(finishedResponseId); - this._omniNarrationIds.delete(finishedResponseId); } } else if (finishedResponseId && wasInterrupted) { // Interrupted before finishing: DON'T mark heard - leave the pending @@ -1365,7 +1272,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._scheduleAutoListen(); } } - queueMicrotask(() => this._drainOmniInbox()); } })); @@ -1552,11 +1458,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // _sessionsAwaitingResponseSummary), so an old summary surfacing // from a rehydrated dormant model isn't mistaken for a new reply. const isResponseSummaryTransition = !isStateTransition && prev !== undefined && currentState === 'idle' && !!normalizedSummary && normalizedSummary !== prev.lastResponseSummary && this._sessionsAwaitingResponseSummary.has(sessionId); - const isOmniResponseCompletion = this._claimOmniCompletedResponse(model, currentState, normalizedSummary); - if (isOmniResponseCompletion) { - this._pendingResponseSummaries.set(this._sessionKey(sessionId), normalizedSummary); - } - const isTransition = isStateTransition || isDetailTransition || isResponseSummaryTransition || isOmniResponseCompletion; + const isTransition = isStateTransition || isDetailTransition || isResponseSummaryTransition; if (isTransition) { this.logService.trace(`[voice] autorun transition id=${sessionId.slice(-32)} ${prev?.state}→${currentState} detailChanged=${isDetailTransition} summaryChanged=${isResponseSummaryTransition} hasDetail=${!!detail}`); // A new turn supersedes prior narration; clear dedup here (before coalescing collapses a fast idle→thinking→idle to net-zero), skipping eager-reload wobble. Arm the awaiting-summary marker so this run's completion (whenever its summary lands) is recognized as new. @@ -1661,16 +1563,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // and let the autorun re-fire with the summary once it resolves // (do not record the idle state yet so the transition is still // detected after the model loads). - const completedWhileOmniVisible = currentState === 'idle' - && this._claimFreshOmniCompletion(sessionId, s.timing.lastRequestEnded); - if ((isStateTransition || completedWhileOmniVisible) && currentState === 'idle') { + if (isStateTransition && currentState === 'idle') { const cachedSummary = this._lastResponseSummaryById.get(sessionId); if (!cachedSummary) { this._deferIdleNarrationUntilModelLoaded(s.resource); continue; } this._sessionsAwaitingResponseSummary.delete(sessionId); - this._pendingResponseSummaries.set(this._sessionKey(sessionId), cachedSummary); if (!this._userCancelledSessions.has(sessionId)) { stateChanges.push({ sessionId, currentState, label: s.label || 'Untitled session', lastResponseSummary: cachedSummary, fromState: prev?.state ?? currentState, fromDetail: prev?.detail ?? '', fromConfirmationType: prev?.confirmationType, fromResponseSummary: prev?.lastResponseSummary ?? '', pendingId: '', fromPendingId: prev?.pendingId ?? '' }); } @@ -1770,7 +1669,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } // Release eagerly-loaded model refs for sessions no longer awaiting input - this._releaseUnusedEagerModelRefs(stillWaiting); + for (const id of [...this._eagerModelRefs.keys()]) { + if (!stillWaiting.has(id)) { + this._eagerModelRefs.get(id)!.dispose(); + this._eagerModelRefs.delete(id); + } + } }); // Periodic fallback: check session state changes every 5s // to catch transitions missed when the chat model isn't loaded @@ -1898,19 +1802,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (this._isInterruptedAudio(e)) { return; } - const matchedSolicitedNarration = e.responseId - ? this._pendingSolicitedNarrations.has(e.responseId) ? [e.responseId, this._pendingSolicitedNarrations.get(e.responseId)!] as const : undefined - : this._matchUntaggedSolicitedNarration(e.codingSessionId, e.narrationKind); - const responseId = e.responseId ?? matchedSolicitedNarration?.[0]; - const solicitedNarration = matchedSolicitedNarration?.[1]; - const isCorrelatedSolicitedNarration = !!e.responseId || !!e.narrationKind; - if (!isCorrelatedSolicitedNarration && this._isPendingOmniDispatchAcknowledgement(e.codingSessionId)) { - if (e.isFinal) { - this._completeOmniDispatchAcknowledgement(); - } - this.logService.trace(`[voice] dropping Omni dispatch acknowledgement isFinal=${e.isFinal}`); - return; - } + const solicitedNarration = e.responseId ? this._pendingSolicitedNarrations.get(e.responseId) : undefined; const echoedCheckpoint: IVoiceCheckpointNarrationMetadata | undefined = e.requestId && e.checkpointId && e.sequence !== undefined ? { requestId: e.requestId, checkpointId: e.checkpointId, sequence: e.sequence } : undefined; @@ -1943,50 +1835,15 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // deferred response's buffer key matches the resource we flush on focus // (otherwise it is stranded and never read). Untagged / non-agent-host // ids pass through unchanged. - const codingSessionId = this._canonicalSessionId(e.codingSessionId ?? solicitedNarration?.sessionId ?? (responseId ? this._responseSessionIds.get(responseId) : undefined)); - if (responseId && codingSessionId) { - this._responseSessionIds.set(responseId, codingSessionId); + const codingSessionId = this._canonicalSessionId(e.codingSessionId ?? solicitedNarration?.sessionId ?? (e.responseId ? this._responseSessionIds.get(e.responseId) : undefined)); + if (e.responseId && codingSessionId) { + this._responseSessionIds.set(e.responseId, codingSessionId); } - if (responseId && e.audio) { - this._responseIdsWithAudio.add(responseId); - } - const responseHasAudio = !!responseId && this._responseIdsWithAudio.has(responseId); - if (e.isFinal && responseId) { - this._responseIdsWithAudio.delete(responseId); - } - if (responseId && this._isOmniVoiceInboxSession(codingSessionId)) { - this._omniNarrationIds.add(responseId); - } - if (codingSessionId && !isCheckpointNarration && this._isOmniVoiceInboxSession(codingSessionId)) { - this._omniClaimedResponseSummaries.set(this._sessionKey(codingSessionId), e.transcript ?? ''); - } - const routedRequest = codingSessionId ? this._routedRequests.get(this._sessionKey(codingSessionId)) : undefined; - if (codingSessionId && this._abandonedRoutedRequests.has(this._sessionKey(codingSessionId))) { - if (responseId) { - this._rememberInterruptedAudioId(responseId); - this._responseSessionIds.delete(responseId); - this._responseRoutes.delete(responseId); - } - this.logService.trace(`[voice] dropping audio for closed omni route session=${codingSessionId.slice(-32)}`); - return; - } - if (codingSessionId && routedRequest && routedRequest.phase !== 'running' && !solicitedNarration) { - if (responseId && !e.isFinal) { - this._ownershipDroppedResponseIds.add(responseId); - } - if (responseId && e.isFinal) { - this._ownershipDroppedResponseIds.delete(responseId); - this._responseSessionIds.delete(responseId); - this._responseRoutes.delete(responseId); - } - this.logService.trace(`[voice] dropping stale response while routed request is ${routedRequest.phase} session=${codingSessionId.slice(-32)}`); - return; - } - if (responseId && this._ownershipDroppedResponseIds.has(responseId)) { + if (e.responseId && this._ownershipDroppedResponseIds.has(e.responseId)) { if (e.isFinal) { - this._ownershipDroppedResponseIds.delete(responseId); - this._responseSessionIds.delete(responseId); - this._responseRoutes.delete(responseId); + this._ownershipDroppedResponseIds.delete(e.responseId); + this._responseSessionIds.delete(e.responseId); + this._responseRoutes.delete(e.responseId); } return; } @@ -1995,20 +1852,20 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // approval narration so it isn't read aloud after the fact. Matched // by narration id, so the agent's real reply (a different id) is // unaffected. Clear the id once its final chunk has passed. - if (responseId !== undefined && this._cancelledPendingNarrationIds.has(responseId)) { + if (e.responseId !== undefined && this._cancelledPendingNarrationIds.has(e.responseId)) { if (e.isFinal) { - this._cancelledPendingNarrationIds.delete(responseId); + this._cancelledPendingNarrationIds.delete(e.responseId); } return; } if (e.audio) { - this._markSolicitedNarrationAudioStarted(responseId); + this._markSolicitedNarrationAudioStarted(e.responseId); } if (isCheckpointNarration && solicitedNarration && e.isFinal && !e.audio && !solicitedNarration.hasReceivedAudio) { - if (responseId) { - this._clearPendingSolicitedNarration(responseId, solicitedNarration); - this._solicitedNarrationIds.delete(responseId); - this._responseRoutes.delete(responseId); + if (e.responseId) { + this._clearPendingSolicitedNarration(e.responseId, solicitedNarration); + this._solicitedNarrationIds.delete(e.responseId); + this._responseRoutes.delete(e.responseId); } return; } @@ -2022,72 +1879,47 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // duplicate of a reply we already read is dropped outright rather than // buffered and replayed when its session is later focused. Awaited // replies bypass this inside _isRenarration. - const isRenarration = this._isRenarration(responseId, codingSessionId, e.transcript, e.isFirstChunk, e.isFinal); + const isRenarration = this._isRenarration(e.responseId, codingSessionId, e.transcript, e.isFirstChunk, e.isFinal); const targetSessionId = this._targetSession.get()?.toString(); - const belongsToVoiceSession = this._isOmniVoiceInboxSession(codingSessionId) - || !codingSessionId - || (!this._hasDraftTarget.get() && (!targetSessionId || this._isSameSession(codingSessionId, targetSessionId))); + const belongsToVoiceSession = !codingSessionId || (!this._hasDraftTarget.get() && (!targetSessionId || this._isSameSession(codingSessionId, targetSessionId))); if (!belongsToVoiceSession) { - if (responseId && !e.isFinal) { - this._ownershipDroppedResponseIds.add(responseId); + if (e.responseId && !e.isFinal) { + this._ownershipDroppedResponseIds.add(e.responseId); } - if (responseId) { - const pending = this._pendingSolicitedNarrations.get(responseId); + if (e.responseId) { + const pending = this._pendingSolicitedNarrations.get(e.responseId); if (pending) { if (pending.kind === 'response') { const key = this._sessionKey(pending.sessionId); this._pendingResponseSummaries.set(key, pending.text); this._markPendingResponse(key, true); } - this._deferInterruptedNarration(responseId, pending); + this._deferInterruptedNarration(e.responseId, pending); } } - if (responseId && e.isFinal) { - this._responseSessionIds.delete(responseId); - this._responseRoutes.delete(responseId); + if (e.responseId && e.isFinal) { + this._responseSessionIds.delete(e.responseId); + this._responseRoutes.delete(e.responseId); } this.logService.trace(`[voice] dropping audio for non-target session=${codingSessionId} target=${targetSessionId}`); return; } - const deferForOmniInbox = !isRenarration && !!codingSessionId && this._isOmniVoiceInboxSession(codingSessionId) - && (this._isUserActivelySpeaking() - || this._omniNarrationQueue.length > 0 - || this._omniDeferredSessionKeys.size > 0 - // Only narrations still awaiting their first audio chunk force a - // defer. A narration whose audio has already arrived (including - // THIS response's own solicited narration) is not in-flight work: - // treating it as such would defer its own audio, and since the - // user may have already stopped speaking nothing would trigger a - // drain - stranding the confirmation/response until the session is - // focused. The audio queue still serializes playback order. - || [...this._pendingSolicitedNarrations.values()].some(pending => pending.kind !== 'checkpoint' && !pending.hasReceivedAudio) - || this._deferredNarrations.size > 0); - if (deferForOmniInbox && responseId) { - this._responseRoutes.set(responseId, 'deferred'); - } - const defer = isRenarration ? false : deferForOmniInbox || this._shouldDeferResponseStream(responseId, codingSessionId, e.isFirstChunk); + const defer = isRenarration ? false : this._shouldDeferResponseStream(e.responseId, codingSessionId, e.isFirstChunk); if (e.isFirstChunk || e.isFinal) { - this.logService.trace(`[voice] audio_response codingSessionId=${codingSessionId ?? ''} responseId=${responseId?.slice(0, 8) ?? ''} shown=${this._shownSessionId() ?? ''} focused=${this._getFocusedSessionId() ?? ''} external=${this._activeSessionShown ?? ''} awaiting=${this._awaitingReplyForSession ?? ''} isFirstChunk=${e.isFirstChunk} isFinal=${e.isFinal} suppress=${this._suppressIncomingAudio} renarration=${isRenarration} defer=${defer}`); + this.logService.trace(`[voice] audio_response codingSessionId=${codingSessionId ?? ''} responseId=${e.responseId?.slice(0, 8) ?? ''} shown=${this._shownSessionId() ?? ''} focused=${this._getFocusedSessionId() ?? ''} external=${this._activeSessionShown ?? ''} awaiting=${this._awaitingReplyForSession ?? ''} isFirstChunk=${e.isFirstChunk} isFinal=${e.isFinal} suppress=${this._suppressIncomingAudio} renarration=${isRenarration} defer=${defer}`); } if (isRenarration) { // Backend re-narrated a reply we already read for this session // (matched by content). Drop it so the user never hears it twice. - this.logService.trace(`[voice] dropping re-narration for session=${codingSessionId} responseId=${responseId?.slice(0, 8) ?? ''} isFirstChunk=${e.isFirstChunk} isFinal=${e.isFinal}`); + this.logService.trace(`[voice] dropping re-narration for session=${codingSessionId} responseId=${e.responseId?.slice(0, 8) ?? ''} isFirstChunk=${e.isFirstChunk} isFinal=${e.isFinal}`); } else if (defer && isCheckpointNarration) { - if (responseId && solicitedNarration) { - this._clearPendingSolicitedNarration(responseId, solicitedNarration); - this._solicitedNarrationIds.delete(responseId); + if (e.responseId && solicitedNarration) { + this._clearPendingSolicitedNarration(e.responseId, solicitedNarration); + this._solicitedNarrationIds.delete(e.responseId); } return; } else if (defer) { - if (deferForOmniInbox) { - const sessionKey = this._sessionKey(codingSessionId!); - this._omniDeferredSessionKeys.add(sessionKey); - if (!this._omniDeferredSessionOrdinals.has(sessionKey)) { - this._omniDeferredSessionOrdinals.set(sessionKey, ++this._omniInboxOrdinal); - } - } - this._deferResponse(codingSessionId!, e.audio, e.isFirstChunk, e.isFinal, e.transcript, responseId, e.turnId); + this._deferResponse(codingSessionId!, e.audio, e.isFirstChunk, e.isFinal, e.transcript, e.responseId, e.turnId); } else { if (e.audio && !isCheckpointNarration) { this._preemptCheckpointPlayback(); @@ -2099,10 +1931,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // response being promoted from deferred to live (same id) isn't // flushed-and-replayed as if it were a different, older response. if (e.isFirstChunk && codingSessionId && this._deferredResponses.has(codingSessionId) - && !this._deferredBufferHasResponse(codingSessionId, responseId)) { + && !this._deferredBufferHasResponse(codingSessionId, e.responseId)) { this._flushDeferredResponse(codingSessionId); } - this._enqueueAudio(codingSessionId, e.audio, e.isFirstChunk, e.isFinal, e.transcript, responseId, playbackNarration); + this._enqueueAudio(codingSessionId, e.audio, e.isFirstChunk, e.isFinal, e.transcript, e.responseId, playbackNarration); if (e.isFinal) { this._liveReplyKeys.delete(codingSessionId ?? ''); // Record this heard reply so an immediate backend re-narration @@ -2116,7 +1948,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // (dropping its next reply / misrouting this one). See // _reconcileConfirmationIndicators for the same caveat. const heardSessionId = codingSessionId ?? this._awaitingReplyForSession ?? this._shownSessionId(); - if (!isCheckpointNarration && responseHasAudio && heardSessionId && e.transcript) { + if (!isCheckpointNarration && heardSessionId && e.transcript) { const heard = this._normalizeTranscript(e.transcript); if (heard) { const heardKey = this._sessionKey(heardSessionId); @@ -2130,20 +1962,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (!isCheckpointNarration && e.isFinal && e.transcript) { this._persistTurn('assistant', e.transcript); } - if (e.isFinal - && responseId - && codingSessionId - && e.transcript - && !responseHasAudio - && this._isOmniVoiceInboxSession(codingSessionId) - && this.configurationService.getValue('agents.voice.speakResponses') !== false) { - const sessionKey = this._sessionKey(codingSessionId); - this._pendingResponseSummaries.set(sessionKey, e.transcript); - this._omniClaimedResponseSummaries.set(sessionKey, e.transcript); - if (!solicitedNarration) { - this._narrate(codingSessionId, 'response', e.transcript); - } - } // NOTE: a reply is marked "heard" (dedup set, pending indicator cleared) // only when its audio finishes PLAYING - see onPlaybackStopped and the // speech-disabled branch of _playChunk, keyed by responseId. Final-chunk @@ -2152,9 +1970,9 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // not mark it heard at this point. // Retire the per-response route once its stream ends. Done last so the // route stays inspectable for the whole handler (defer/dedupe/enqueue). - if (e.isFinal && responseId) { - this._responseSessionIds.delete(responseId); - this._responseRoutes.delete(responseId); + if (e.isFinal && e.responseId) { + this._responseSessionIds.delete(e.responseId); + this._responseRoutes.delete(e.responseId); } })); @@ -2191,8 +2009,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC e.args['text'] = text; } if (e.args?.['new_session'] === true) { - // Pin this submission to the new target so it outranks both - // a focus-change pin and a focused Omni input. + // Pin this submission to the new target so it outranks any + // stale focus-change pin. this._setPinnedSubmitSession(undefined); this.newSessionAsTarget(); if (text.trim()) { @@ -2204,49 +2022,32 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC toolName: e.name, toolArgs: e.args, }); - this._setAwaitingReply(); - this._completeOmniDispatchAcknowledgement(); - this._pendingOmniDispatchAcknowledgement = this._omniInputOpen.get() && !!text.trim() ? {} : undefined; - const sendPromise = text.trim() - ? this._sendTranscriptionToChat(text) - : Promise.resolve(undefined); - const settle = (): void => { + const shouldSend = text.trim().length > 0; + if (shouldSend) { + this._setAwaitingReply(); + } + const finishSend = (result: 'ok' | 'error'): void => { this._voiceState.set(this._awaitingReplyAudio ? 'processing' : 'idle', undefined); this._statusText.set(this._awaitingReplyAudio ? 'Waiting for response...' : 'Hold to speak...', undefined); this._sendContext(); + this.voiceClientService.sendToolResult(e.callId, result); }; - sendPromise.then(resource => { - if (resource === false) { + const sendPromise = shouldSend + ? this._sendTranscriptionToChat(text) + : Promise.resolve(false); + sendPromise.then(sent => { + if (!sent) { this._clearAwaitingReply(); - this._completeOmniDispatchAcknowledgement(); - this.voiceClientService.sendToolResult(e.callId, { ok: false, reason: 'no_session' }); - settle(); - return; - } - const backendResource = resource ? (toAgentHostBackendSessionUri(resource) ?? resource).toString() : undefined; - if (this._pendingOmniDispatchAcknowledgement && resource) { - this._pendingOmniDispatchAcknowledgement.sessionKey = this._sessionKey(resource.toString()); } - this.voiceClientService.sendToolResult(e.callId, 'ok', backendResource); - settle(); - }, error => { - this.logService.error('[voice] send_to_chat failed', error); + finishSend(sent ? 'ok' : 'error'); + }, err => { + this.logService.warn('[voice] send_to_chat delivery failed:', err); this._clearAwaitingReply(); - this._completeOmniDispatchAcknowledgement(); - this.voiceClientService.sendToolResult(e.callId, { ok: false, reason: 'no_session' }); - settle(); + finishSend('error'); }); return; } if (allowedTools.includes(e.name)) { - const codingSessionId = e.args?.['coding_session_id']; - if (typeof codingSessionId === 'string') { - const uiSessionId = this._sessionKey(codingSessionId); - if (uiSessionId !== codingSessionId && e.args) { - e.args['coding_session_id'] = uiSessionId; - this.logService.trace(`[voice] resolved backend tool target ${codingSessionId.slice(-32)} -> ${uiSessionId.slice(-32)}`); - } - } // Answer read-only backend queries without touching PTT/state, so the backend's connect-time probe can't end a just-started auto-listen. const passiveTools = ['get_session_info', 'get_session_changes', 'get_session_thread']; if (passiveTools.includes(e.name)) { @@ -2269,7 +2070,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._finishPtt(); } this._suppressIncomingAudio = false; - this._completeOmniDispatchAcknowledgement(); this._setAwaitingReply(); const settle = (): void => { this._voiceState.set('idle', undefined); @@ -2513,10 +2313,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Terminal disconnect: drop the routing target and pending-confirmation // snapshot (and suppress the tracker) so a later reconnect can't re-pin // voice to the old session or repopulate its stale confirmation. - this._targetOmniRoute = undefined; this._setTargetSession(undefined); this._hasDraftTarget.set(false, undefined); - this._omniInputActive.set(false, undefined); this._suppressPendingConfirmationsUntilConnect(); this._pendingToolConfirmations.set([], undefined); // Terminal disconnect: drop embedder-driven active-session state too, so a @@ -2535,9 +2333,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._userCancelledSessions.clear(); for (const t of this._confirmationFlushWatchdogs.values()) { clearTimeout(t); } this._confirmationFlushWatchdogs.clear(); - for (const t of this._staleContextNarrationRetryTimers.values()) { clearTimeout(t); } - this._staleContextNarrationRetryTimers.clear(); - this._staleContextRetriedPending.clear(); if (this._stateChangeEmitTimer) { clearTimeout(this._stateChangeEmitTimer); this._stateChangeEmitTimer = undefined; } this._pendingStateChanges.clear(); for (const ref of this._eagerModelRefs.values()) { ref.dispose(); } @@ -2546,15 +2341,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._pendingIdleNarration.clear(); this._sessionsAwaitingResponseSummary.clear(); this._lastResponseSummaryById.clear(); - this._routedRequests.clear(); - this._abandonedRoutedRequests.clear(); - this._omniNarrationQueue.length = 0; - this._omniDeferredSessionKeys.clear(); - this._omniDeferredSessionOrdinals.clear(); - this._omniInboxOrdinal = 0; - this._omniClaimedPendingIds.clear(); - this._omniClaimedResponseSummaries.clear(); - this._omniNarrationIds.clear(); this._lastNarratedText.clear(); this._pendingNarrationRetries.clear(); this._voiceProgressListeners.clearAndDisposeAll(); @@ -2668,10 +2454,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Terminal disconnect (no reconnect): drop the routing target and // pending-confirmation snapshot, and suppress the tracker so connect() // isn't re-pinned to this evicted session (see disconnect()). - this._targetOmniRoute = undefined; this._setTargetSession(undefined); this._hasDraftTarget.set(false, undefined); - this._omniInputActive.set(false, undefined); this._suppressPendingConfirmationsUntilConnect(); this._pendingToolConfirmations.set([], undefined); transaction(tx => { @@ -2954,7 +2738,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // A fresh user press starts a new turn — no longer suppress send_to_chat // from a previously discarded turn, nor pin it to a prior session. this._suppressSendToChatUntil = 0; - this._completeOmniDispatchAcknowledgement(); this._setPinnedSubmitSession(undefined); // Toggle mode: second tap finishes recording. A forced new turn (e.g. @@ -3292,7 +3075,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } else if (this.accessibilityService.isScreenReaderOptimized()) { this._playRecordingStoppedSignal(false); } - queueMicrotask(() => this._drainOmniInbox()); } private _playRecordingStoppedSignal(userGesture: boolean): void { @@ -3313,79 +3095,9 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._userCancelledSessions.set(sessionId, expiry); } - setTargetSession(resource: URI | undefined, omniRoute?: 'existing_session' | 'new_session'): void { - if (resource) { - this._recordSessionAlias(resource); - } - this._targetOmniRoute = resource ? omniRoute : undefined; + setTargetSession(resource: URI | undefined): void { this._hasDraftTarget.set(false, undefined); this._setTargetSession(resource); - if (this._isConnected.get() || this._isConnecting.get()) { - this.logService.trace(`[voice] synchronizing target session id=${resource?.toString().slice(-32) ?? ''} route=${omniRoute ?? ''}`); - this._sendContext(); - this.voiceClientService.flushSessionContext(); - } - } - - prepareForRoutingRequest(): void { - const resource = this._targetSession.get(); - if (resource) { - this._discardResponsesSupersededByPending(this._sessionKey(resource.toString())); - } - this.setTargetSession(undefined); - } - - markRoutedRequestPending(resource: URI, requestId?: string): void { - this._recordSessionAlias(resource); - const sessionKey = this._sessionKey(resource.toString()); - this._abandonedRoutedRequests.delete(sessionKey); - const existing = this._routedRequests.get(sessionKey); - const wasAlreadyMarked = existing !== undefined; - // Some providers acknowledge a queued send before the request has been - // added to the model, so the router cannot return its id yet. Remember the - // current tail as a baseline and adopt the first different request below. - // Preserve that baseline across the onWillDispatch/onDidResolve pair. - const hasPreviousRequestBaseline = existing ? hasOwn(existing, 'previousRequestId') : !requestId; - const previousRequestId = existing && hasOwn(existing, 'previousRequestId') - ? existing.previousRequestId - : !requestId ? (this.chatService.getSession(resource)?.getRequests().at(-1)?.id ?? null) : undefined; - const routedRequest: IRoutedVoiceRequest = { - requestId: requestId ?? existing?.requestId, - ...(existing?.modelRequestId ? { modelRequestId: existing.modelRequestId } : {}), - ...(existing?.hasMatchedModelRequest ? { hasMatchedModelRequest: true } : {}), - ...(hasPreviousRequestBaseline ? { previousRequestId } : {}), - phase: 'queued' as const, - }; - this._routedRequests.set(sessionKey, routedRequest); - this.logService.trace(`[voice] routed request pending session=${sessionKey.slice(-32)} request=${routedRequest.requestId ?? ''} model=${routedRequest.modelRequestId ?? ''} previous=${previousRequestId ?? ''}`); - if (!wasAlreadyMarked) { - this._discardResponsesSupersededByPending(sessionKey); - } - - const model = this.chatService.getSession(resource); - if (this._isOmniVoiceInboxActive()) { - // The provider's send path can leave a model transiently resident while - // dispatch resolves, then release its final reference. Acquire our own - // reference even when the model is currently visible so the completed - // response remains observable for narration. - this._ensureModelLoaded(resource, true); - } - if (model && this._isCurrentRoutedRequest(resource.toString(), routedRequest)) { - routedRequest.hasMatchedModelRequest = true; - const state = this._getAgentStateInfo(model); - if (state.state === 'thinking') { - this._routedRequests.set(sessionKey, { ...routedRequest, phase: 'running' }); - } else if (state.state === 'waiting_for_confirmation') { - this._routedRequests.set(sessionKey, { ...routedRequest, phase: 'waiting' }); - } - } - } - - clearRoutedRequest(resource: URI): void { - const sessionKey = this._sessionKey(resource.toString()); - this._routedRequests.delete(sessionKey); - this._abandonedRoutedRequests.delete(sessionKey); - this._releaseEagerModelRef(sessionKey); } getLastSpokenResponseSession(): URI | undefined { @@ -3400,170 +3112,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } setDraftTarget(): void { - this._targetOmniRoute = undefined; this._setTargetSession(undefined); this._hasDraftTarget.set(true, undefined); } - takeSessionInputOwnership(resource: URI, window: Window & typeof globalThis): void { - this._omniBlurRelease.clear(); - this.setActiveWindow(window); - transaction(tx => { - this._targetOmniRoute = undefined; - this._omniInputActive.set(false, tx); - this._hasDraftTarget.set(false, tx); - this._setTargetSession(resource, tx); - }); - this.activateSession(resource); - } - - takeDraftInputOwnership(window: Window & typeof globalThis): void { - this._omniBlurRelease.clear(); - this.setActiveWindow(window); - transaction(tx => { - this._targetOmniRoute = undefined; - this._omniInputActive.set(false, tx); - this._setTargetSession(undefined, tx); - this._hasDraftTarget.set(true, tx); - }); - } - - takeOmniInputOwnership(window: Window & typeof globalThis): void { - this._omniBlurRelease.clear(); - this.setActiveWindow(window); - transaction(tx => { - this._targetOmniRoute = undefined; - this._setTargetSession(undefined, tx); - this._hasDraftTarget.set(true, tx); - this._omniInputActive.set(true, tx); - }); - } - - retainOmniInputOwnershipForBargeIn(window: Window & typeof globalThis): boolean { - if (!this._omniInputActive.get() || this._window !== window) { - return false; - } - return true; - } - - setOmniInputActive(active: boolean): void { - this._omniBlurRelease.clear(); - if (this._omniInputActive.get() === active) { - return; - } - this._omniInputActive.set(active, undefined); - if (!active) { - this._targetOmniRoute = undefined; - this._setTargetSession(undefined); - this._hasDraftTarget.set(false, undefined); - } - } - - setOmniInputOpen(open: boolean): void { - if (this._omniInputOpen.get() === open) { - return; - } - this._omniInputOpen.set(open, undefined); - this.logService.trace(`[voice] omni inbox ${open ? 'opened' : 'closed'}`); - if (open) { - this._omniOpenedAt = Date.now(); - for (const model of this.chatService.chatModels.get()) { - this._rememberOmniCompletedResponse(model); - } - // Omni is the visible owner while connected. Hide any voice-only list - // indicators; their underlying state remains available in omni itself. - for (const key of this._pendingVoiceIndicatorKeys()) { - if (this._isOmniInboxEligibleSession(key)) { - this._markPendingResponse(key, false); - } - } - // A panel narration can have been deferred while the backend was busy. - // Revalidate it before draining Omni: a stale entry otherwise blocks - // every new global narration while also waiting for an unblock signal - // that may already have fired before Omni opened. - for (const sessionKey of [...this._deferredNarrations.keys()]) { - this._retryDeferredNarration(sessionKey); - } - this._drainOmniInbox(); - } else { - // Return unheard work to normal panel ownership. The active Omni - // playback is stopped, but refocusing a session can narrate it. - this._releaseOmniInboxToPanel(); - } - } - - private _claimFreshOmniCompletion(sessionId: string, endedAt: number | undefined): boolean { - if (!endedAt - || endedAt < this._omniOpenedAt - || !this._isOmniVoiceInboxSession(sessionId) - || endedAt <= (this._omniCompletionEndedAtBySession.get(sessionId) ?? 0)) { - return false; - } - this._omniCompletionEndedAtBySession.set(sessionId, endedAt); - return true; - } - - private _rememberOmniCompletedResponse(model: IChatModel): void { - const response = model.lastRequest?.response; - if (response?.isComplete && !response.isCanceled) { - this._rememberOmniCompletedResponseId(`${this._sessionKey(model.sessionResource.toString())}\0${response.id}`); - } - } - - private _rememberOmniCompletedResponseId(id: string): void { - if (this._omniCompletedResponseIds.has(id)) { - return; - } - while (this._omniCompletedResponseIds.size >= 256) { - const oldest = this._omniCompletedResponseIds.values().next().value; - if (oldest === undefined) { - break; - } - this._omniCompletedResponseIds.delete(oldest); - } - this._omniCompletedResponseIds.add(id); - } - - private _claimOmniCompletedResponse(model: IChatModel, state: string, summary: string): boolean { - const response = model.lastRequest?.response; - if (state !== 'idle' - || !summary - || !response?.isComplete - || response.isCanceled - || !this._isOmniVoiceInboxSession(model.sessionResource.toString())) { - return false; - } - const id = `${this._sessionKey(model.sessionResource.toString())}\0${response.id}`; - if (this._omniCompletedResponseIds.has(id)) { - return false; - } - this._rememberOmniCompletedResponseId(id); - return true; - } - - releaseOmniInputOnBlur(): void { - this._clearAutoListenTimer(); - if (!this._omniInputActive.get()) { - return; - } - if (this._voiceState.get() === 'idle' || this._voiceState.get() === 'error') { - this.setOmniInputActive(false); - return; - } - this._omniBlurRelease.value = autorun(reader => { - const state = this._voiceState.read(reader); - if (state !== 'idle' && state !== 'error') { - return; - } - Promise.resolve().then(() => { - const currentState = this._voiceState.read(undefined); - if (currentState === 'idle' || currentState === 'error') { - this.setOmniInputActive(false); - } - }); - }); - } - promoteDraftTarget(resource: URI): void { if (!this._hasDraftTarget.get()) { return; @@ -3582,7 +3134,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // untitled local session is deleted as soon as its last reference goes // away, which would strand `_targetSession` on a dead resource. this._newSessionRef.value = ref; - this._targetOmniRoute = undefined; this._hasDraftTarget.set(false, undefined); this._setTargetSession(resource); // Try to switch the view to the new session (works if chat pane is open) @@ -3680,14 +3231,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * Whether this controller's window currently has OS focus. In multi-window * setups (e.g. an editor window + the agents window) each window has its own * controller/WebSocket, so without this gate every open window would re-arm - * hands-free auto-listen and reply simultaneously. An explicitly active omni - * window remains the voice surface while visible even when focus moves away; - * otherwise only the focused window listens (#8507). + * hands-free auto-listen and reply simultaneously. Only the focused window + * should keep listening (#8507). */ private _isWindowFocused(): boolean { - if (this._omniInputActive.get()) { - return true; - } try { return this._window?.document.hasFocus() ?? false; } catch { @@ -3695,13 +3242,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } - /** Called when this controller's window loses OS focus. Ordinary chat - * surfaces abort passive capture so the newly focused window can take over; - * an explicitly active omni surface remains available while visible. */ + /** Called when this controller's window loses OS focus. Aborts any open + * passive turn so the background window stops recording while the newly + * focused window can take over hands-free listening (#8507). */ private _onWindowBlur(): void { - if (this._omniInputActive.get()) { - return; - } if (this._pttHeld && this._pttCurrentTurnPassive) { this.logService.trace('[voice] window blur: aborting passive turn (multi-window hands-free #8507)'); this._finishPtt('discard', 'internal'); @@ -3853,13 +3397,18 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } - private _acceptVoiceInput(text: string, sessionResource: URI): void { - this.commandService.executeCommand('_chat.voice.acceptInput', text).then(response => { + private async _acceptVoiceInput(text: string, sessionResource: URI): Promise { + try { + const response = await this.commandService.executeCommand('_chat.voice.acceptInput', text); this.logService.info(`[voice] acceptInput completed session=${sessionResource.toString()} response=${response?.id ?? 'none'} connected=${this._isConnected.get()}`); if (response && this._isConnected.get()) { this._watchVoiceProgress(sessionResource, response); } - }).catch(err => this.logService.warn('[voice] acceptInput failed:', err)); + return true; + } catch (err) { + this.logService.warn('[voice] acceptInput failed:', err); + return false; + } } private async _sendVoiceRequest(sessionResource: URI, text: string): Promise { @@ -4016,23 +3565,15 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC /** * Send transcription text to the target session or active chat. + * + * Returns `true` once the text has been accepted by a chat surface or send + * request, and `false` when delivery was rejected or no send occurred. */ - private async _sendTranscriptionToChat(text: string): Promise { + private async _sendTranscriptionToChat(text: string): Promise { // A focus-change submit pins routing to the session the user was // dictating into, so it takes priority over whichever surface has focus // by the time the backend finalizes the turn. - const pinnedTarget = this._consumePinnedSubmitSession(); - const acceptedByOmni = !pinnedTarget && await this.commandService.executeCommand(CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID, text).catch(() => false); - if (acceptedByOmni) { - return URI.isUri(acceptedByOmni) ? acceptedByOmni : this._targetSession.get(); - } - // A focused Omni request that was cancelled, rejected, or timed out must - // not fall through and send the same transcription to the panel session. - if (!pinnedTarget && this._omniInputActive.get()) { - return false; - } - - const target = pinnedTarget ?? this._targetSession.get(); + const target = this._consumePinnedSubmitSession() ?? this._targetSession.get(); if (target) { // Check if target is the currently visible session const currentSession = await this.commandService.executeCommand('_chat.voice.getCurrentSession').catch(() => undefined); @@ -4040,7 +3581,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (isTargetVisible) { // Target is visible — send via the chat pane directly - this._acceptVoiceInput(text, target); + return this._acceptVoiceInput(text, target); } else { // Target is NOT visible — ensure session is loaded, then send const cts = new CancellationTokenSource(); @@ -4055,12 +3596,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const switched = await this.commandService.executeCommand('_chat.voice.switchToSession', target.toString()).catch(() => false); if (switched) { await new Promise(resolve => setTimeout(resolve, 200)); - this._acceptVoiceInput(text, target); + return this._acceptVoiceInput(text, target); } - return; + return false; } const result = await this._sendVoiceRequest(target, text); - if (result && result.kind !== 'rejected') { + const accepted = !!result && !ChatSendResult.isRejected(result); + if (accepted) { // Surface response in floating window this._watchResponseForFloatingWindow(target); // Open the floating window so user can see the response @@ -4086,18 +3628,15 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } else { ref.dispose(); } + return accepted; } - return target; } else { - // Ensure the chat view is visible so the user sees the response. - this.commandService.executeCommand('workbench.panel.chat.view.copilot.focus').catch(() => { /* ignore */ }); // Use the currently focused chat session if available const currentSession = await this.commandService.executeCommand('_chat.voice.getCurrentSession').catch(() => undefined); + let accepted = false; if (currentSession) { // There's an active chat widget — send to it - const resource = URI.parse(currentSession); - this._acceptVoiceInput(text, resource); - return resource; + accepted = await this._acceptVoiceInput(text, URI.parse(currentSession)); } else { // No focused chat session — find the most recent existing session // instead of creating a new one, so voice continues the conversation. @@ -4110,12 +3649,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const switched = await this.commandService.executeCommand('_chat.voice.switchToSession', sessionResource.toString()).catch(() => false); if (switched) { await new Promise(resolve => setTimeout(resolve, 200)); - this._acceptVoiceInput(text, sessionResource); + accepted = await this._acceptVoiceInput(text, sessionResource); } else { // Direct send as fallback - await this._sendVoiceRequest(sessionResource, text); + const result = await this._sendVoiceRequest(sessionResource, text); + accepted = !!result && !ChatSendResult.isRejected(result); } - return sessionResource; } else { // Truly no sessions exist — create one const ref = this.chatService.startNewLocalSession(ChatAgentLocation.Chat); @@ -4123,10 +3662,14 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC ref.dispose(); // Switch to the new session so the user sees the response this.commandService.executeCommand('_chat.voice.switchToSession', resource.toString()).catch(() => { /* pane may not exist */ }); - await this._sendVoiceRequest(resource, text); - return resource; + const result = await this._sendVoiceRequest(resource, text); + accepted = !!result && !ChatSendResult.isRejected(result); } } + + // Ensure the chat view is visible so the user sees/hears the response + this.commandService.executeCommand('workbench.panel.chat.view.copilot.focus').catch(() => { /* ignore */ }); + return accepted; } } @@ -4487,13 +4030,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC rekeyMap(this._pendingNarrationRetries); rekeyMap(this._deferredNarrations); rekeyMap(this._narratedPending); - rekeyMap(this._routedRequests); - rekeyMap(this._omniClaimedPendingIds); - rekeyMap(this._omniClaimedResponseSummaries); - rekeyMap(this._omniDeferredSessionOrdinals); rekeySet(this._confirmationPendingSessions); - rekeySet(this._abandonedRoutedRequests); - rekeySet(this._omniDeferredSessionKeys); rekeySet(this._liveReplyKeys); rekeySet(this._sessionsAwaitingResponseSummary); rekeySet(this._pendingIdleNarration); @@ -4553,7 +4090,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const focused = this._getFocusedSessionId(); if (focused) { const resource = URI.parse(focused); - if (!this._omniInputOpen.get() && (this._isConnected.get() || this._isConnecting.get())) { + if (this._isConnected.get() || this._isConnecting.get()) { this.setTargetSession(resource); } this._activateShownSession(resource); @@ -4798,7 +4335,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } /** Ask the backend to narrate a session's pending item, de-duped by the exact text last spoken for it ({@link _lastNarratedText}) and by any in-flight request for the same text ({@link _pendingSolicitedNarrations}); the single narration trigger for both live and on-focus paths. Returns `true` when a request was actually SENT - NOT that the reply was heard (the audio may still be dropped/deferred/never arrive). The reply is marked narrated and its pending indicator cleared only once its audio finalizes (see {@link _markNarrationHeard}). */ - private _narrate(sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata, confirmationType?: VoiceConfirmationType, pending?: { pendingId: string }, fromOmniQueue = false): boolean { + private _narrate(sessionId: string, kind: VoiceNarrationKind, text: string, reuseId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata, confirmationType?: VoiceConfirmationType, pending?: { pendingId: string }): boolean { if (!text) { return false; } @@ -4829,19 +4366,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC return false; } } - if (kind !== 'response' && kind !== 'checkpoint' && this._pendingOmniDispatchAcknowledgement && this._isOmniVoiceInboxSession(sessionId)) { - this._pendingAfterOmniDispatchAcknowledgement.set(sessionKey, { kind, text, confirmationType, ...(pending ? { pending } : {}) }); - this.logService.trace(`[voice] deferring ${kind} until Omni dispatch acknowledgement completes session=${sessionKey.slice(-32)}`); - return false; - } - if (!fromOmniQueue && kind !== 'checkpoint' && this._isOmniVoiceInboxSession(sessionId) && this._shouldQueueOmniNarration()) { - this._queueOmniNarration({ sessionId, kind, text, confirmationType, ...(pending ? { pending } : {}) }); - return false; - } if (kind !== 'response' && kind !== 'checkpoint') { // The pending UI can update in place while an older narration is still // queued or speaking. Retire that stale occurrence before requesting - // the replacement so omni only reads the current actionable content. + // the replacement so only the current actionable content is read. this._stopPendingNarration(sessionId); } // A response only supersedes checkpoint playback once non-empty response audio arrives. @@ -4853,10 +4381,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this.voiceClientService.flushSessionContext(); } this.logService.trace(`[voice] narrate kind=${kind} id=${sessionId.slice(-32)}`); - const narrationSessionId = toAgentHostBackendSessionUri(URI.parse(sessionId))?.toString() ?? sessionId; - const narrationId = this.voiceClientService.requestNarration(narrationSessionId, kind, text, reuseId, checkpoint, confirmationType, pending, () => { - this._prepareForPlayback(); - }); + const narrationId = this.voiceClientService.requestNarration(sessionId, kind, text, reuseId, checkpoint, confirmationType, pending); if (!narrationId) { if (kind === 'checkpoint') { return false; @@ -4871,6 +4396,14 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (kind === 'checkpoint') { this.logService.trace(`[voice][checkpoint] requested narration_id=${narrationId} request_id=${checkpoint?.requestId ?? ''} phase=${checkpoint?.checkpointId ?? ''} sequence=${checkpoint?.sequence ?? 0} seed=${JSON.stringify(text)}`); } + // The narration audio is now inbound. Get out of listening/auto-listen so + // the echoed audio isn't suppressed (or captured as the user's own turn) + // while PTT/mic capture is active. Done here so every narration path + // (live, on-focus, on-reconnect retry) is prepared, not just focus - but + // only once a request is actually in flight. A held deliberate press + // leaves the slot untouched (see _prepareForPlayback); its narration is + // NACK'd busy and retried on release, so the ignored return is expected. + this._prepareForPlayback(); this._pendingNarrationRetries.delete(sessionId); // This newer request supersedes any older busy/interrupted entry deferred // for this session (latest-wins per session). Without this, a later @@ -4889,9 +4422,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } this._solicitedNarrationIds.add(narrationId); - if (this._isOmniVoiceInboxSession(sessionId) || fromOmniQueue) { - this._omniNarrationIds.add(narrationId); - } // Do NOT mark the reply narrated / clear its pending indicator yet - a // request being accepted is not the reply being heard. Wait for the // backend to start returning audio: if it never does, the watchdog below @@ -4915,137 +4445,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC return true; } - private _shouldQueueOmniNarration(): boolean { - return this._isUserActivelySpeaking() - || this.ttsPlaybackService.isPlaying - || this._currentPlaybackSessionId !== null - || this._audioQueue.length > 0 - || this._omniDeferredSessionKeys.size > 0 - || [...this._pendingSolicitedNarrations.values()].some(pending => pending.kind !== 'checkpoint') - || this._deferredNarrations.size > 0 - || this._omniNarrationQueue.length > 0; - } - - private _queueOmniNarration(item: Omit): void { - const sessionKey = this._sessionKey(item.sessionId); - const identity = this._narratableIdentity(item); - if (item.kind !== 'response') { - // Only the newest actionable occurrence for a session remains valid. - for (let index = this._omniNarrationQueue.length - 1; index >= 0; index--) { - const queued = this._omniNarrationQueue[index]; - if (queued.kind !== 'response' && this._sessionKey(queued.sessionId) === sessionKey) { - this._omniNarrationQueue.splice(index, 1); - } - } - } - if (this._omniNarrationQueue.some(queued => - queued.kind === item.kind - && this._sessionKey(queued.sessionId) === sessionKey - && this._narratableIdentity(queued) === identity)) { - return; - } - this._omniNarrationQueue.push({ ...item, ordinal: ++this._omniInboxOrdinal }); - this.logService.trace(`[voice] omni inbox queued kind=${item.kind} session=${sessionKey.slice(-32)} depth=${this._omniNarrationQueue.length}`); - } - - private _drainOmniInbox(): void { - if (!this._isOmniVoiceInboxActive() || this._isUserActivelySpeaking()) { - return; - } - if (this.ttsPlaybackService.isPlaying - || this._currentPlaybackSessionId !== null - || this._audioQueue.length > 0 - // A solicited narration only blocks the drain while it is still waiting - // for its first audio chunk. Once its audio has arrived it is either - // actively playing (caught by the playback/queue guards above) or was - // deferred/buffered because the user was speaking - and THAT buffered - // audio is exactly what the drain must flush. Blocking on it here would - // deadlock: the narration can only clear once the drain plays it, but - // the drain would never run while it stays pending. - || [...this._pendingSolicitedNarrations.values()].some(pending => pending.kind !== 'checkpoint' && !pending.hasReceivedAudio) - || this._deferredNarrations.size > 0) { - return; - } - while (this._omniNarrationQueue.length > 0 || this._omniDeferredSessionKeys.size > 0) { - const nextNarration = this._omniNarrationQueue[0]; - const nextDeferredSession = [...this._omniDeferredSessionKeys] - .map(sessionKey => ({ sessionKey, ordinal: this._omniDeferredSessionOrdinals.get(sessionKey) ?? Number.MAX_SAFE_INTEGER })) - .sort((a, b) => a.ordinal - b.ordinal)[0]; - if (nextDeferredSession && (!nextNarration || nextDeferredSession.ordinal < nextNarration.ordinal)) { - if (!this._isOmniVoiceInboxSession(nextDeferredSession.sessionKey)) { - this._omniDeferredSessionKeys.delete(nextDeferredSession.sessionKey); - this._omniDeferredSessionOrdinals.delete(nextDeferredSession.sessionKey); - this._omniClaimedResponseSummaries.delete(nextDeferredSession.sessionKey); - this._markPendingResponse(nextDeferredSession.sessionKey, true); - continue; - } - const result = this._flushDeferredResponse(nextDeferredSession.sessionKey); - if (!result.retained) { - this._omniDeferredSessionKeys.delete(nextDeferredSession.sessionKey); - this._omniDeferredSessionOrdinals.delete(nextDeferredSession.sessionKey); - } - if (result.retained || this.ttsPlaybackService.isPlaying || this._currentPlaybackSessionId !== null || this._audioQueue.length > 0) { - return; - } - continue; - } - const item = this._omniNarrationQueue.shift()!; - let resource: URI | undefined; - try { - resource = URI.parse(item.sessionId); - } catch { - resource = undefined; - } - const current = resource ? this._currentNarratable(resource) : undefined; - const itemIdentity = this._narratableIdentity(item); - const sessionKey = this._sessionKey(item.sessionId); - if (!this._isOmniVoiceInboxSession(item.sessionId)) { - if (item.kind === 'response') { - this._pendingResponseSummaries.set(sessionKey, item.text); - this._omniClaimedResponseSummaries.delete(sessionKey); - } else { - this._confirmationPendingSessions.add(sessionKey); - this._omniClaimedPendingIds.delete(sessionKey); - } - this._markPendingResponse(sessionKey, true); - continue; - } - const cachedResponseStillCurrent = item.kind === 'response' - && this._omniClaimedResponseSummaries.get(sessionKey) === item.text; - const cachedPendingStillCurrent = item.kind !== 'response' - && this._omniClaimedPendingIds.get(sessionKey) === itemIdentity; - if ((!current || current.kind !== item.kind || this._narratableIdentity(current) !== itemIdentity) && !cachedResponseStillCurrent && !cachedPendingStillCurrent) { - this.logService.trace(`[voice] omni inbox dropped stale kind=${item.kind} session=${this._sessionKey(item.sessionId).slice(-32)}`); - continue; - } - const sent = this._narrate(item.sessionId, item.kind, item.text, undefined, undefined, item.confirmationType, item.pending, true); - if (sent || this._pendingNarrationRetries.has(item.sessionId)) { - return; - } - } - } - - /** Associate legacy audio that omits the narration id with the one compatible - * in-flight request. Keeping the match unique prevents an unrelated direct - * reply from consuming queued narration state. */ - private _matchUntaggedSolicitedNarration(codingSessionId: string | undefined, narrationKind: VoiceNarrationKind | undefined): readonly [string, IPendingSolicitedNarration] | undefined { - let match: readonly [string, IPendingSolicitedNarration] | undefined; - for (const entry of this._pendingSolicitedNarrations) { - const pending = entry[1]; - if (narrationKind && pending.kind !== narrationKind) { - continue; - } - if (codingSessionId && !this._isSameSession(codingSessionId, pending.sessionId)) { - continue; - } - if (match) { - return undefined; - } - match = entry; - } - return match; - } - private _markSolicitedNarrationAudioStarted(narrationId: string | undefined): void { if (!narrationId) { return; @@ -5068,7 +4467,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } this._pendingSolicitedNarrations.delete(narrationId); this._solicitedNarrationIds.delete(narrationId); - this._omniNarrationIds.delete(narrationId); // Only restore state when this was the last thing we were waiting on. If a // direct chat reply is still expected (`_awaitingReplyAudio`) or another // solicited narration is still waiting for its audio to start, restoring @@ -5080,7 +4478,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } this.logService.trace(`[voice] solicited narration ${narrationId.slice(0, 8)} timed out waiting for audio start; restoring idle state`); this._restoreVoiceStateAfterNarrationTimeout(); - queueMicrotask(() => this._drainOmniInbox()); } /** True while any tracked solicited narration is still waiting for its audio @@ -5127,7 +4524,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC return; } this._clearPendingSolicitedNarration(narrationId, solicited); - this._omniNarrationIds.delete(narrationId); // Only responses populate the persistent text dedup (and own the pending // indicator). A confirmation is transient actionable state that must be // re-narratable, so heard confirmations leave _lastNarratedText untouched. @@ -5135,7 +4531,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (solicited.kind === 'response') { this._lastNarratedText.set(sessionKey, solicited.text); this._clearPendingResponse(sessionKey); - this._completeRoutedResponse(solicited.sessionId); } else if (solicited.kind !== 'checkpoint') { // Actionable item heard: mark THIS occurrence spoken so a mere refocus // while it is still pending doesn't re-narrate it (see @@ -5146,30 +4541,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._narratedPending.set(sessionKey, this._narratableIdentity(solicited)); this.logService.trace(`[voice] pending item heard for ${sessionKey.slice(-32)}; marking occurrence spoken`); } - queueMicrotask(() => this._drainOmniInbox()); - } - - private _completeRoutedResponse(sessionId: string): void { - const sessionKey = this._sessionKey(sessionId); - this._abandonedRoutedRequests.delete(sessionKey); - if (this._routedRequests.delete(sessionKey)) { - this._releaseEagerModelRef(sessionKey); - this.logService.trace(`[voice] completed routed response after playback session=${sessionKey.slice(-32)}`); - } - } - - private _resumePendingResponseAfterPlayback(sessionId: string): void { - const sessionKey = this._sessionKey(sessionId); - const summary = this._pendingResponseSummaries.get(sessionKey); - if (!summary) { - return; - } - if (this._wasResponseHeard(sessionId, summary)) { - this._clearPendingResponse(sessionKey); - this._completeRoutedResponse(sessionId); - return; - } - this._narrate(sessionId, 'response', summary); } /** @@ -5192,37 +4563,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._clearPendingSolicitedNarration(e.narrationId, solicited); } this._solicitedNarrationIds.delete(e.narrationId); - if (e.disposition === 'invalid' && e.reason === 'stale_context' && solicited && solicited.kind !== 'checkpoint') { - this._omniNarrationIds.delete(e.narrationId); - this._clearDeferred(key); - const pending: IVoiceNarratable = { - kind: solicited.kind, - text: solicited.text, - confirmationType: solicited.confirmationType, - ...(solicited.pending ? { pending: solicited.pending } : {}), - }; - const identity = this._narratableIdentity(pending); - if (this._staleContextRetriedPending.get(key) !== identity) { - this._staleContextRetriedPending.set(key, identity); - this.logService.trace(`[voice] narration_ack invalid id=${e.narrationId.slice(0, 8)} reason=stale_context; queueing one revalidated retry`); - this._sendContext(); - this.voiceClientService.flushSessionContext(); - const timer = setTimeout(() => { - this._staleContextNarrationRetryTimers.delete(key); - this._retryPendingNarration(key, pending); - }, VoiceSessionController._STALE_CONTEXT_NARRATION_RETRY_DELAY_MS); - this._staleContextNarrationRetryTimers.set(key, timer); - return; - } - } if (e.disposition === 'invalid' || e.disposition === 'suppressed') { - this._omniNarrationIds.delete(e.narrationId); this.logService.trace(`[voice] narration_ack ${e.disposition} id=${e.narrationId.slice(0, 8)} reason=${e.reason ?? ''}; dropping`); this._clearDeferred(key); if (solicited) { this.telemetryService.publicLog2('voiceNarrationDropped', { kind: solicited.kind, reason: e.disposition }); } - queueMicrotask(() => this._drainOmniInbox()); return; } // busy: defer for a revalidated retry once the guard clears. @@ -5263,7 +4609,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private _deferInterruptedNarration(narrationId: string, solicited: IPendingSolicitedNarration): void { this._clearPendingSolicitedNarration(narrationId, solicited); this._solicitedNarrationIds.delete(narrationId); - this._omniNarrationIds.delete(narrationId); if (solicited.kind === 'checkpoint') { return; } @@ -5309,7 +4654,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this.logService.trace(`[voice] deferred narration for ${sessionKey.slice(-32)} no longer warranted; dropping`); this._clearDeferred(sessionKey); this.telemetryService.publicLog2('voiceNarrationDropped', { kind: deferred.kind, reason: 'stale' }); - queueMicrotask(() => this._drainOmniInbox()); return false; } // The session may no longer be the one shown (the user switched away while @@ -5321,7 +4665,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this.logService.trace(`[voice] deferred narration for ${sessionKey.slice(-32)} no longer shown; dropping`); this._clearDeferred(sessionKey); this.telemetryService.publicLog2('voiceNarrationDropped', { kind: deferred.kind, reason: 'session_changed' }); - queueMicrotask(() => this._drainOmniInbox()); return false; } // Reuse the id only for the same *occurrence*, so the backend dedups a lost @@ -5355,25 +4698,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC return this._narrate(sessionId, current.kind, current.text, undefined, undefined, current.confirmationType, current.pending); } - private _isPendingOmniDispatchAcknowledgement(codingSessionId: string | undefined): boolean { - const pending = this._pendingOmniDispatchAcknowledgement; - return !!pending && (!pending.sessionKey || !codingSessionId || pending.sessionKey === this._sessionKey(codingSessionId)); - } - - private _completeOmniDispatchAcknowledgement(): void { - if (!this._pendingOmniDispatchAcknowledgement) { - return; - } - this._pendingOmniDispatchAcknowledgement = undefined; - const pendingNarrations = [...this._pendingAfterOmniDispatchAcknowledgement]; - this._pendingAfterOmniDispatchAcknowledgement.clear(); - queueMicrotask(() => { - for (const [sessionId, pending] of pendingNarrations) { - this._retryPendingNarration(sessionId, pending); - } - }); - } - /** Drop a deferred narration. */ private _clearDeferred(sessionKey: string): void { this._deferredNarrations.delete(sessionKey); @@ -5617,36 +4941,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._activateShownSession(resource); } - announceSessionInOmni(resource: URI): void { - const sessionId = resource.toString(); - if (!this._isOmniVoiceInboxSession(sessionId)) { - return; - } - const sessionKey = this._sessionKey(sessionId); - const narratable = this._currentNarratable(resource); - if (!narratable) { - this._ensureModelLoaded(resource); - return; - } - if (narratable.kind === 'response') { - if (!this._pendingResponseSummaries.has(sessionKey)) { - return; - } - this._omniClaimedResponseSummaries.set(sessionKey, narratable.text); - } else { - this._omniClaimedPendingIds.set(sessionKey, this._narratableIdentity(narratable)); - } - this._narrate(sessionId, narratable.kind, narratable.text, undefined, undefined, narratable.confirmationType, narratable.pending); - } - - notifyPendingItemResolved(resource: URI): void { - const sessionId = resource.toString(); - this._stopPendingNarration(sessionId); - this.voiceClientService.invalidateSessionCache(sessionId); - this._sendContext(); - this.voiceClientService.flushSessionContext(); - } - /** * Routing decision for one audio-response chunk. When the backend echoes a * per-response id, decide the whole response's fate once (on its first chunk), @@ -5713,49 +5007,15 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._lastNarratedText.delete(this._sessionKey(sessionId)); } - private _isOmniRoutedSession(sessionId: string | undefined): boolean { - if (!sessionId) { - return false; - } - // The floating input releases its focus ownership while an approval is - // being answered. The routed task still owns voice playback until it - // completes, even if `_targetOmniRoute` was cleared by that blur. Otherwise - // a second confirmation is buffered as background audio under the Agent Host - // URI while the scratch input URI remains focused, and can never flush. - const sessionKey = this._sessionKey(sessionId); - return (this._routedRequests.has(sessionKey) && !this._abandonedRoutedRequests.has(sessionKey)) - || (!!this._targetOmniRoute && this._isSameSession(this._targetSession.get()?.toString(), sessionId)); - } - - private _isOmniVoiceInboxActive(): boolean { - return this._omniInputOpen.get() && (this._isConnected.get() || this._isConnecting.get()); - } - - private _isOmniVoiceInboxSession(sessionId: string | undefined): boolean { - return this._isOmniVoiceInboxActive() && this._isOmniInboxEligibleSession(sessionId); - } - - private _isOmniInboxEligibleSession(sessionId: string | undefined): boolean { - if (!sessionId) { - return false; - } - return this._isOmniRoutedSession(sessionId) || this.agentSessionsService.model.sessions.some(session => - !session.isArchived() && this._isSameSession(session.resource.toString(), sessionId)); - } - /** Whether a response for `sessionId` should defer: true unless it is the - * session currently shown to the user or selected through omni-chat - * (untagged audio → play). A non-omni reply the user is awaiting is NOT - * exempted: if they switched away before it arrived, it is deferred like - * any other background narration and flushed on return. */ + * session currently shown to the user (untagged audio → play). A reply the + * user is awaiting is NOT exempted: if they switched away before it arrived, + * it is deferred like any other background narration and flushed on return. */ private _shouldDeferForSession(sessionId: string | undefined): boolean { if (!sessionId) { return false; } - if (this._isOmniVoiceInboxSession(sessionId)) { - return false; - } - return !this._isOmniRoutedSession(sessionId) && !this._isSameSession(this._shownSessionId(), sessionId); + return !this._isSameSession(this._shownSessionId(), sessionId); } /** True when one of the session's buffered responses is the SAME stream as @@ -5971,20 +5231,24 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private _prepareForPlayback(): boolean { this._clearAutoListenTimer(); this._autoListenSuppressed = false; - // A passive barge-in/auto-listen turn can still become backend-active - // after server VAD detects speech. Close it synchronously on the wire - // before request_narration; waiting for the mic's normal drain would send - // ptt_end after the narration request and leave the backend unable to - // synthesize the response. A deliberate user press remains intact so its - // natural release can clear the backend latch and drive a busy retry. - const handsFreeOpenMic = this._pttCurrentTurnPassive || this._bargeInListenActive || this._pttToggleMode; - this.logService.trace(`[voice] prepare playback held=${this._pttHeld} passive=${this._pttCurrentTurnPassive} bargeIn=${this._bargeInListenActive} toggle=${this._pttToggleMode} speech=${this._speechDetectedInTurn}`); - if (this._pttHeld && handsFreeOpenMic) { - this._finishPtt('discard', 'internal'); - this.voiceClientService.sendPttEnd(); - } else if (this._isUserActivelySpeaking()) { + // A held deliberate press (non-passive) latched the backend's + // `user_is_speaking`, so its narration request was NACK'd `busy` and + // deferred: it will not play now. Leave the press fully intact. Aborting + // it here sends no `ptt_end` and would strand the latch; its natural + // release sends `ptt_end`, clearing the guard and driving the + // `narration_unblocked` retry. Only a passive open-mic turn (auto-listen + // or barge-in), which never latched, is safe to abort here to free the + // mic for the incoming narration audio. + if (this._isUserActivelySpeaking()) { return false; } + if (this._pttHeld) { + // A local-only abort ('auto', no `ptt_end`): a passive turn never + // latched `user_is_speaking`, so there's nothing to force-clear. + // 'internal' marks this as a non-user-gesture stop so it doesn't emit + // the explicit listening-stopped signal. + this._finishPtt('auto', 'internal'); + } this._pttToggleMode = false; this._pttHeld = false; this._suppressIncomingAudio = false; @@ -6072,7 +5336,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private _markPendingResponse(sessionId: string, pending: boolean): void { try { - this.voicePlaybackService.setPendingResponse(URI.parse(sessionId), pending && !this._isOmniVoiceInboxSession(sessionId)); + this.voicePlaybackService.setPendingResponse(URI.parse(sessionId), pending); } catch { // sessionId isn't a parseable resource - nothing to indicate. } @@ -6093,38 +5357,14 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // the focused session. Deliberately avoid the _getActiveSessionId() // fallback chain (_targetSession / _lastShownSessionId), which can point // at a not-currently-visible session and wrongly hide its indicator. - const omniTarget = this._targetOmniRoute ? this._targetSession.get()?.toString() : undefined; - const activeId = omniTarget ?? (this._externalActiveSessionMode + const activeId = this._externalActiveSessionMode ? this._activeSessionShown - : this._getFocusedSessionId()); + : this._getFocusedSessionId(); const activeKey = activeId ? this._sessionKey(activeId) : undefined; const waitingKeys = new Set(); for (const sessionId of waitingSessionIds) { const key = this._sessionKey(sessionId); waitingKeys.add(key); - const pendingIdentity = this._pendingIdentityForSession(sessionId); - if (this._abandonedRoutedRequests.has(key)) { - this._omniClaimedPendingIds.set(key, pendingIdentity); - this._clearConfirmationIndicator(key); - continue; - } - if (this._isOmniVoiceInboxActive()) { - this._omniClaimedPendingIds.set(key, pendingIdentity); - this._clearConfirmationIndicator(key); - continue; - } - const claimedIdentity = this._omniClaimedPendingIds.get(key); - if (claimedIdentity === pendingIdentity) { - // This exact occurrence belonged to an omni inbox that was closed. - // Abandon voice delivery instead of transferring a list indicator. - this._clearConfirmationIndicator(key); - continue; - } - if (claimedIdentity !== undefined) { - // A new occurrence replaced the abandoned one without an observable - // intermediate state; it returns to the normal background policy. - this._omniClaimedPendingIds.delete(key); - } if (key === activeKey) { // Now the active session - make sure any entry is gone. this._clearConfirmationIndicator(key); @@ -6142,26 +5382,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } this._clearConfirmationIndicator(key); } - for (const key of [...this._omniClaimedPendingIds.keys()]) { - if (!waitingKeys.has(key)) { - this._omniClaimedPendingIds.delete(key); - } - } - } - - private _pendingIdentityForSession(sessionId: string): string { - let resource: URI | undefined; - try { - resource = URI.parse(sessionId); - } catch { - resource = undefined; - } - const narratable = resource ? this._currentNarratable(resource) : undefined; - if (narratable && narratable.kind !== 'response') { - return this._narratableIdentity(narratable); - } - const pendingId = this._pendingIdFor(sessionId); - return pendingId ? `#${pendingId}` : '@waiting'; } private _clearConfirmationIndicator(sessionId: string): void { @@ -6191,101 +5411,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._markPendingResponse(key, false); } - private _pendingVoiceIndicatorKeys(): Set { - return new Set([ - ...this._confirmationPendingSessions, - ...this._deferredResponses.keys(), - ...this._pendingResponseSummaries.keys(), - ]); - } - - private _releaseOmniInboxToPanel(): void { - const releasedSessionKeys = new Set([ - ...this._omniNarrationQueue.map(item => this._sessionKey(item.sessionId)), - ...this._omniDeferredSessionKeys, - ...this._omniClaimedPendingIds.keys(), - ...this._omniClaimedResponseSummaries.keys(), - ]); - for (const sessionKey of this._routedRequests.keys()) { - releasedSessionKeys.add(sessionKey); - this._routedRequests.delete(sessionKey); - this._abandonedRoutedRequests.delete(sessionKey); - this._releaseEagerModelRef(sessionKey); - } - for (const item of this._omniNarrationQueue) { - const sessionKey = this._sessionKey(item.sessionId); - if (item.kind === 'response') { - this._pendingResponseSummaries.set(sessionKey, item.text); - } else { - this._confirmationPendingSessions.add(sessionKey); - } - } - for (const sessionKey of this._omniClaimedPendingIds.keys()) { - this._confirmationPendingSessions.add(sessionKey); - } - for (const [sessionKey, summary] of this._omniClaimedResponseSummaries) { - this._pendingResponseSummaries.set(sessionKey, summary); - } - - const releasedResponseIds = new Set(this._omniNarrationIds); - for (const responseId of releasedResponseIds) { - const pending = this._pendingSolicitedNarrations.get(responseId); - if (pending) { - const sessionKey = this._sessionKey(pending.sessionId); - releasedSessionKeys.add(sessionKey); - if (pending.kind === 'response') { - this._pendingResponseSummaries.set(sessionKey, pending.text); - } else { - this._confirmationPendingSessions.add(sessionKey); - } - this._clearPendingSolicitedNarration(responseId, pending); - } - this._solicitedNarrationIds.delete(responseId); - this._responseRoutes.delete(responseId); - this._rememberInterruptedAudioId(responseId); - } - for (const key of this._omniDeferredSessionKeys) { - for (const response of this._deferredResponses.get(key) ?? []) { - if (response.responseId) { - releasedResponseIds.add(response.responseId); - this._rememberInterruptedAudioId(response.responseId); - } - } - } - for (let index = this._audioQueue.length - 1; index >= 0; index--) { - const queued = this._audioQueue[index]; - const ownedSession = queued.sessionId && releasedSessionKeys.has(this._sessionKey(queued.sessionId)); - if ((queued.responseId && releasedResponseIds.has(queued.responseId)) || ownedSession) { - this._audioQueue.splice(index, 1); - } - } - const activeOwned = (this._currentPlaybackResponseId && releasedResponseIds.has(this._currentPlaybackResponseId)) - || (this._currentPlaybackSessionId && releasedSessionKeys.has(this._sessionKey(this._currentPlaybackSessionId))); - if (activeOwned) { - this._rememberInterruptedAudioId(this._currentPlaybackResponseId); - this._stopCurrentPlaybackAsInterrupted(); - } - - for (const sessionKey of releasedSessionKeys) { - this._deferredNarrations.delete(sessionKey); - if (this._pendingOwned(sessionKey)) { - this._markPendingResponse(sessionKey, true); - } - } - for (const sessionId of [...this._pendingNarrationRetries.keys()]) { - if (releasedSessionKeys.has(this._sessionKey(sessionId))) { - this._pendingNarrationRetries.delete(sessionId); - } - } - this._omniNarrationQueue.length = 0; - this._omniDeferredSessionKeys.clear(); - this._omniDeferredSessionOrdinals.clear(); - this._omniClaimedPendingIds.clear(); - this._omniClaimedResponseSummaries.clear(); - this._omniNarrationIds.clear(); - this.logService.trace(`[voice] released omni inbox to panel sessions=${releasedSessionKeys.size} responses=${releasedResponseIds.size}`); - } - private _clearDeferredResponses(): void { for (const key of this._deferredResponses.keys()) { this._markPendingResponse(key, false); @@ -6293,7 +5418,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._deferredResponses.clear(); this._responseRoutes.clear(); this._responseSessionIds.clear(); - this._responseIdsWithAudio.clear(); this._ownershipDroppedResponseIds.clear(); for (const key of this._confirmationPendingSessions) { this._markPendingResponse(key, false); @@ -6442,18 +5566,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC */ private _stopPendingNarration(sessionId: string): void { const sessionKey = this._sessionKey(sessionId); - this._pendingAfterOmniDispatchAcknowledgement.delete(sessionKey); - const staleContextRetry = this._staleContextNarrationRetryTimers.get(sessionKey); - if (staleContextRetry) { - clearTimeout(staleContextRetry); - this._staleContextNarrationRetryTimers.delete(sessionKey); - } - for (let index = this._omniNarrationQueue.length - 1; index >= 0; index--) { - const queued = this._omniNarrationQueue[index]; - if (queued.kind !== 'response' && this._sessionKey(queued.sessionId) === sessionKey) { - this._omniNarrationQueue.splice(index, 1); - } - } // Collect the narration ids of this session's actionable narrations that // are still in flight (requested/queued/playing but not finished). const cancelledIds = new Set(); @@ -6464,7 +5576,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } if (cancelledIds.size === 0) { - queueMicrotask(() => this._drainOmniInbox()); return; } // Drop any not-yet-played chunks of those narrations from the queue. @@ -6517,7 +5628,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (this._currentPlaybackResponseId !== undefined && cancelledIds.has(this._currentPlaybackResponseId)) { this._stopCurrentPlaybackAsInterrupted(); } - queueMicrotask(() => this._drainOmniInbox()); } /** @@ -6718,9 +5828,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._replyPlayedSinceSend = true; } if (isFinal) { - if (sessionId && narration?.kind === 'response') { - this._completeRoutedResponse(sessionId); - } this._currentPlaybackSessionId = null; this._currentPlaybackResponseId = undefined; this._currentPlaybackNarration = undefined; @@ -6732,7 +5839,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._notifyCheckpointPlaybackComplete(sessionId, responseId, narration); } this._markNarrationHeard(responseId); - this._omniNarrationIds.delete(responseId); } // Avoid re-entering _processQueue if we're already inside its // drain loop; that loop will continue on its own. @@ -6742,7 +5848,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (this._isHandsFreeEnabled()) { this._scheduleAutoListen(); } - queueMicrotask(() => this._drainOmniInbox()); } } else { // TTS enabled but no audio in this frame. Forward it so a final @@ -6835,60 +5940,9 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC }, VoiceSessionController._STATE_CHANGE_SETTLE_MS); } - /** React to a session reaching a narratable state. If it's the shown or omni-routed session, speak it now; a completed reply on another background session instead shows the sessions-list pending indicator and is read when focused. A new turn (`thinking`) clears both the dedup and any stale pending indicator. */ + /** React to a session reaching a narratable state. If it's the shown session, speak it now; a completed reply on another background session instead shows the sessions-list pending indicator and is read when focused. A new turn (`thinking`) clears both the dedup and any stale pending indicator. */ private _handleNarratableStateChange(sessionId: string, currentState: string, detail: string | undefined, lastResponseSummary: string | undefined, shownNow: string | undefined, confirmationType?: VoiceConfirmationType): void { const sessionKey = this._sessionKey(sessionId); - const omniInboxActive = this._isOmniVoiceInboxSession(sessionId); - if (currentState === 'thinking') { - this._omniClaimedResponseSummaries.delete(sessionKey); - } - if (currentState !== 'waiting_for_confirmation') { - this._omniClaimedPendingIds.delete(sessionKey); - } - if (!omniInboxActive && currentState === 'idle' && lastResponseSummary && this._omniClaimedResponseSummaries.has(sessionKey)) { - this._clearPendingResponse(sessionKey); - this.logService.trace(`[voice] abandoning completed response claimed by closed omni inbox session=${sessionKey.slice(-32)}`); - return; - } - const routedRequest = this._routedRequests.get(sessionKey); - if (this._abandonedRoutedRequests.has(sessionKey)) { - if (currentState === 'waiting_for_confirmation') { - const narratable = this._modelForSession(sessionId) ? this._currentNarratable(URI.parse(sessionId)) : undefined; - if (narratable && narratable.kind !== 'response') { - this._omniClaimedPendingIds.set(sessionKey, this._narratableIdentity(narratable)); - } - } else if (currentState === 'idle' && lastResponseSummary) { - this._omniClaimedResponseSummaries.set(sessionKey, lastResponseSummary); - this._clearPendingResponse(sessionKey); - this._routedRequests.delete(sessionKey); - this._abandonedRoutedRequests.delete(sessionKey); - this._releaseEagerModelRef(sessionKey); - } - this.logService.trace(`[voice] abandoning ${currentState} state for closed omni route session=${sessionKey.slice(-32)}`); - return; - } - if (routedRequest) { - const modelIsResident = !!this._modelForSession(sessionId); - const isCurrentRoutedRequest = modelIsResident && this._isCurrentRoutedRequest(sessionId, routedRequest); - const completedAfterMatchedModelReleased = !modelIsResident - && routedRequest.hasMatchedModelRequest === true - && currentState === 'idle' - && !!lastResponseSummary; - if (!isCurrentRoutedRequest && !completedAfterMatchedModelReleased) { - this.logService.trace(`[voice] suppressing ${currentState} state that does not belong to routed request session=${sessionKey.slice(-32)} request=${routedRequest.requestId ?? ''}`); - return; - } - if (completedAfterMatchedModelReleased) { - this.logService.trace(`[voice] accepting routed completion after matched model release session=${sessionKey.slice(-32)} request=${routedRequest.requestId ?? ''}`); - } - if (currentState === 'thinking') { - this._routedRequests.set(sessionKey, { ...routedRequest, phase: 'running' }); - } else if (currentState === 'waiting_for_confirmation') { - this._routedRequests.set(sessionKey, { ...routedRequest, phase: 'waiting' }); - } else if (currentState === 'idle' && !lastResponseSummary) { - this.logService.trace(`[voice] retaining routed request through summary-less idle session=${sessionKey.slice(-32)} request=${routedRequest.requestId ?? ''}`); - } - } if (currentState === 'idle' || currentState === 'waiting_for_confirmation') { this._cancelVoiceProgress(sessionId); } @@ -6900,7 +5954,9 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // A deferred narration from the previous turn is now stale. this._clearDeferred(sessionKey); } - if (!omniInboxActive && !this._isOmniRoutedSession(sessionId) && !this._isSameSession(sessionId, shownNow)) { + const targetSessionId = this._targetSession.get()?.toString(); + const sessionBoundElsewhere = this._hasDraftTarget.get() || (targetSessionId && !this._isSameSession(sessionId, targetSessionId)); + if (!this._isSameSession(sessionId, shownNow)) { // Background session. A completed reply must not play now: show the // sessions-list indicator and remember the summary so focusing the // session reads it (mirrors the confirmation indicator, which is @@ -6923,14 +5979,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } return; } + if (sessionBoundElsewhere) { + // A pinned target makes Voice Mode session-bound. Other sessions keep + // their normal visual state but do not speak through this connection. + return; + } if (currentState === 'idle' && lastResponseSummary) { - if (omniInboxActive) { - this._omniClaimedResponseSummaries.set(sessionKey, lastResponseSummary); - // Retain the authoritative summary until playback completes. While - // omni is open `_markPendingResponse` suppresses the list indicator; - // closing omni abandons and clears this token. - this._pendingResponseSummaries.set(sessionKey, lastResponseSummary); - } // Narrate the shown session's reply now. Clear its pending indicator // only if it was ALREADY read (a re-fire of a reply we narrated before); // a freshly requested narration keeps the indicator until its audio @@ -6941,17 +5995,11 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // settles to idle. Requesting the summary here queues a second reading // of that same turn, which may surface just before its next prompt. if (this._hasResponseAudioInFlight(sessionKey)) { - // Playback may be an approval acknowledgement rather than this final - // response. Preserve the completed summary so playback completion can - // either recognize it as already heard or request its narration. - if (routedRequest || omniInboxActive) { - this._pendingResponseSummaries.set(sessionKey, lastResponseSummary); - } + return; } else { this._narrate(sessionId, 'response', lastResponseSummary); if (alreadyNarrated || this._wasResponseHeard(sessionId, lastResponseSummary)) { this._clearPendingResponse(sessionKey); - this._completeRoutedResponse(sessionId); } } } else if (currentState === 'waiting_for_confirmation' && detail) { @@ -6964,16 +6012,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // prose, which is what it got before. const question = this._questionNarratable(this._modelForSession(sessionId)); if (question) { - if (omniInboxActive) { - this._omniClaimedPendingIds.set(sessionKey, this._narratableIdentity(question)); - } this._narrate(sessionId, question.kind, question.text, undefined, undefined, undefined, question.pending); } else { const pending = this._pendingNarrationReference(this._modelForSession(sessionId)); const confirmation: IVoiceNarratable = { kind: 'confirmation', text: detail, confirmationType, ...(pending ? { pending } : {}) }; - if (omniInboxActive) { - this._omniClaimedPendingIds.set(sessionKey, this._narratableIdentity(confirmation)); - } this._narrate(sessionId, confirmation.kind, confirmation.text, undefined, undefined, confirmation.confirmationType, confirmation.pending); } } @@ -6990,30 +6032,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC return this.chatService.getSession(resource); } - private _isCurrentRoutedRequest(sessionId: string, routedRequest: IRoutedVoiceRequest): boolean { - const currentRequestId = this._modelForSession(sessionId)?.getRequests().at(-1)?.id; - if (!currentRequestId) { - return false; - } - if (routedRequest.modelRequestId) { - return currentRequestId === routedRequest.modelRequestId; - } - if (routedRequest.requestId === currentRequestId) { - delete routedRequest.previousRequestId; - return true; - } - if (!hasOwn(routedRequest, 'previousRequestId') || currentRequestId === routedRequest.previousRequestId) { - return false; - } - // The provider rehydrated the routed turn under a durable model id that - // differs from the transient send id. The tail moved away from the captured - // pre-dispatch request, so it is safe to adopt once and then match strictly. - routedRequest.modelRequestId = currentRequestId; - delete routedRequest.previousRequestId; - this.logService.trace(`[voice] adopted durable routed request id session=${this._sessionKey(sessionId).slice(-32)} request=${routedRequest.requestId ?? ''} model=${currentRequestId}`); - return true; - } - /** * Flush the coalesced session state changes to the backend and persist only * true net changes to the local timeline. {@link _sendContext} rebuilds the @@ -7220,17 +6238,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Summary-less idle transitions for remote/Copilot sessions: narrate // from the cached summary if we have one, otherwise defer until the // model loads (see _deferIdleNarrationUntilModelLoaded). - const completedWhileOmniVisible = !model - && currentState === 'idle' - && this._claimFreshOmniCompletion(sessionId, s.timing.lastRequestEnded); - if (!model && currentState === 'idle' && (isStateChange || completedWhileOmniVisible)) { + if (!model && currentState === 'idle' && isStateChange) { const cachedSummary = this._lastResponseSummaryById.get(sessionId); if (!cachedSummary) { this._deferIdleNarrationUntilModelLoaded(s.resource); continue; } lastResponseSummary = cachedSummary; - this._pendingResponseSummaries.set(this._sessionKey(sessionId), cachedSummary); } // A completed reply's summary can land after the idle transition (or @@ -7241,17 +6255,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // a new reply. const normalizedSummary = lastResponseSummary ?? ''; const isResponseSummaryChange = !isStateChange && prev !== undefined && currentState === 'idle' && !!normalizedSummary && normalizedSummary !== prev.lastResponseSummary && this._sessionsAwaitingResponseSummary.has(sessionId); - const isOmniResponseCompletion = !!model && this._claimOmniCompletedResponse(model, currentState, normalizedSummary); - if (isOmniResponseCompletion) { - this._pendingResponseSummaries.set(this._sessionKey(sessionId), normalizedSummary); - } // The completion for this run has been accepted; consume the marker. if ((isStateChange && currentState === 'idle' && !!normalizedSummary) || isResponseSummaryChange) { this._sessionsAwaitingResponseSummary.delete(sessionId); } - if (isStateChange || isDetailChange || isResponseSummaryChange || isOmniResponseCompletion) { + if (isStateChange || isDetailChange || isResponseSummaryChange) { const cancelExpiry = this._userCancelledSessions.get(sessionId); if (cancelExpiry) { clearTimeout(cancelExpiry); @@ -7375,9 +6385,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * so it stays accurate even though the backend isn't told about it. */ private _reportedAgentState(realState: string, isActive: boolean): { state: string; hideConfirmationDetail: boolean } { - if (this._isOmniVoiceInboxActive()) { - return { state: realState, hideConfirmationDetail: false }; - } if (realState === 'waiting_for_confirmation' && !isActive) { return { state: 'thinking', hideConfirmationDetail: true }; } @@ -7450,7 +6457,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const sessionList: IVoiceSessionContext['sessions'] = sessions.map(s => { const model = this.chatService.getSession(s.resource); const isActive = s.resource.toString() === targetSessionId; - const backendSessionId = (toAgentHostBackendSessionUri(s.resource) ?? s.resource).toString(); if (!model) { const sessionIdStr = s.resource.toString(); let fallbackState = s.status === AgentSessionStatus.InProgress ? 'thinking' @@ -7479,11 +6485,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // narrates instead of shipping a summary-less (silent) idle. const cachedSummary = fallbackState === 'idle' ? this._lastResponseSummaryById.get(sessionIdStr) : undefined; return { - id: backendSessionId, + id: sessionIdStr, ...(s.label ? { label: s.label } : {}), session_type: 'agent' as const, is_active: isActive, - ...(isActive && this._targetOmniRoute ? { omni_route: this._targetOmniRoute } : {}), agent_state: scoped.state, ...(cachedSummary ? { last_response_summary: cachedSummary } : {}), }; @@ -7507,11 +6512,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // see with no way to answer it by voice. const pending = this._buildPendingPayload(model); return { - id: backendSessionId, + id: s.resource.toString(), ...(s.label ? { label: s.label } : {}), session_type: 'agent' as const, is_active: isActive, - ...(isActive && s.resource.toString() === this._targetSession.get()?.toString() && this._targetOmniRoute ? { omni_route: this._targetOmniRoute } : {}), agent_state: scoped.state, ...(!scoped.hideConfirmationDetail && stateInfo.detail ? { agent_state_detail: stateInfo.detail } : {}), ...(!scoped.hideConfirmationDetail && stateInfo.confirmation_type ? { confirmation_type: stateInfo.confirmation_type } : {}), @@ -7538,11 +6542,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const scoped = this._reportedAgentState(stateInfo.state, isActive); const pending = this._buildPendingPayload(chatModel); sessionList.push({ - id: (toAgentHostBackendSessionUri(chatModel.sessionResource) ?? chatModel.sessionResource).toString(), + id: key, ...(chatModel.title ? { label: chatModel.title } : {}), session_type: 'chat', is_active: isActive, - ...(isActive && key === this._targetSession.get()?.toString() && this._targetOmniRoute ? { omni_route: this._targetOmniRoute } : {}), agent_state: scoped.state, ...(!scoped.hideConfirmationDetail && stateInfo.detail ? { agent_state_detail: stateInfo.detail } : {}), ...(!scoped.hideConfirmationDetail && stateInfo.confirmation_type ? { confirmation_type: stateInfo.confirmation_type } : {}), @@ -7565,13 +6568,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * opened in the UI yet. Once loaded, the autorun observables will re-fire * with full confirmation detail so the backend can narrate properly. */ - private _ensureModelLoaded(resource: URI, retainExisting = false): void { + private _ensureModelLoaded(resource: URI): void { const key = resource.toString(); // Skip if already loaded, resident in the UI, or a load is in flight. // The in-flight guard prevents repeated onDidChangeSessions/autorun // cycles from starting concurrent loads whose refs would overwrite each // other in _eagerModelRefs and leak the prior ref. - if (this._eagerModelRefs.has(key) || this._eagerModelLoading.has(key) || (!retainExisting && this.chatService.getSession(resource))) { + if (this._eagerModelRefs.has(key) || this._eagerModelLoading.has(key) || this.chatService.getSession(resource)) { return; } // A surfaced-but-un-adopted legacy Copilot CLI session must NOT be eagerly @@ -7596,24 +6599,16 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._eagerModelRefs.set(key, ref); // Model state/detail are now readable; flush so confirmation narrates // immediately instead of waiting for the next context send. - const wasPendingIdleNarration = this._pendingIdleNarration.has(key); this._checkSessionStateChanges(); this._sendContext(); this.voiceClientService.flushSessionContext(); - if (wasPendingIdleNarration && this._isOmniVoiceInboxSession(key) && !this._pendingResponseSummaries.has(key)) { - const narratable = this._currentNarratable(resource); - if (narratable?.kind === 'response') { - this._pendingResponseSummaries.set(key, narratable.text); - } - } - // Narrate a now-resident pending item for the focused session or - // the global Omni inbox. _checkSessionStateChanges only narrates on - // a state transition, but an eagerly loaded completion may already - // be idle and would otherwise stay silent. Existing occurrence and - // response dedup prevents double-reading. - if (this._isOmniVoiceInboxSession(key)) { - this.announceSessionInOmni(resource); - } else if (this._shownSessionId() === key) { + // If the user is looking at this session, narrate its now-resident + // pending item directly. _checkSessionStateChanges only narrates on + // a state transition, but a completed reply focused after it settled + // shows no idle->idle transition and would otherwise stay silent. + // _narrate's _lastNarratedText guard prevents double-reading an + // already-read reply; this mirrors the confirmation-on-focus path. + if (this._shownSessionId() === key) { this._activateShownSession(resource); } } @@ -7625,19 +6620,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC }, () => { this._eagerModelLoading.delete(key); this._pendingIdleNarration.delete(key); cts.dispose(); }); } - private _releaseUnusedEagerModelRefs(stillWaiting: ReadonlySet): void { - for (const id of [...this._eagerModelRefs.keys()]) { - if (!stillWaiting.has(id) && !this._routedRequests.has(id)) { - this._releaseEagerModelRef(id); - } - } - } - - private _releaseEagerModelRef(sessionKey: string): void { - this._eagerModelRefs.get(sessionKey)?.dispose(); - this._eagerModelRefs.delete(sessionKey); - } - /** * Defer narrating a session's ``idle`` transition until its chat model is * resident, so the narration can include ``last_response_summary``. Remote/ @@ -7659,17 +6641,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC * completion never narrates the previous reply. */ private _cacheResponseSummary(sessionId: string, state: string, summary: string | undefined): void { - const sessionKey = this._sessionKey(sessionId); - const routedRequest = this._routedRequests.get(sessionKey); - const isCurrentRoutedRequest = routedRequest && this._isCurrentRoutedRequest(sessionId, routedRequest); - if (isCurrentRoutedRequest) { - routedRequest.hasMatchedModelRequest = true; - } - if (isCurrentRoutedRequest && state === 'thinking') { - this._routedRequests.set(sessionKey, { ...routedRequest, phase: 'running' }); - } else if (isCurrentRoutedRequest && state === 'waiting_for_confirmation') { - this._routedRequests.set(sessionKey, { ...routedRequest, phase: 'waiting' }); - } if (state === 'idle' && summary) { this._lastResponseSummaryById.set(sessionId, summary); } else if (state === 'thinking') { @@ -8112,12 +7083,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } const pendingConfirmation = lastRequest?.response?.isPendingConfirmation.get(); - const confirmation = this._getPendingConfirmationInfo(model); + const hasResolvedPendingToolApproval = pendingConfirmation && this._hasResolvedPendingToolApproval(model); + const confirmation = hasResolvedPendingToolApproval ? undefined : this._getPendingConfirmationInfo(model); // `isPendingConfirmation` can remain true while a provider propagates an // approval to its authoritative model. When selection found only retired // tool copies, treat that gap as work in progress instead of re-announcing // the same approval with a generic fallback. - if (confirmation || (pendingConfirmation && !this._hasResolvedPendingToolApproval(model))) { + if (confirmation || (pendingConfirmation && !hasResolvedPendingToolApproval)) { return { state: 'waiting_for_confirmation', ...(confirmation?.detail ? { detail: confirmation.detail } : !confirmation ? { detail: this._formatToolNarrationFallback() } : {}), @@ -8143,8 +7115,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (part.kind !== 'toolInvocation' || !this._isOpenPendingPart(part)) { continue; } - const pendingId = derivePendingId(request!.id, part, this._store); - if (isPendingIdResolved(pendingId)) { + if (restoreResolvedPendingId(request!.id, part, this._store)) { return true; } } diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts index 6c99bcfa6c36bc..e7ec3273ed497d 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts @@ -22,7 +22,6 @@ import { IVoiceDispatchResult, IVoiceModelReference, IVoiceToolCall, markPending import { getVoiceConfirmationType } from '../../common/voiceClient/voiceConfirmation.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { isExplicitFileOrImageVariableEntry } from '../../common/attachments/chatVariableEntries.js'; -import { toAgentHostBackendSessionUri } from '../agentSessions/agentHost/agentHostSessionUri.js'; /** * Callbacks that require access to the chat widget or view state. @@ -567,7 +566,7 @@ export class VoiceToolDispatchService implements IVoiceToolDispatchService { : 'unknown'; const lastActivity = session.timing.lastRequestEnded ?? session.timing.lastRequestStarted ?? session.timing.created ?? 0; return { - id: (toAgentHostBackendSessionUri(session.resource) ?? session.resource).toString(), + id: session.resource.toString(), label: session.label || undefined, session_type: 'agent' as const, state, @@ -591,7 +590,7 @@ export class VoiceToolDispatchService implements IVoiceToolDispatchService { const inProgress = model.hasActiveRequest?.get(); const lastActivity = model.lastMessageDate || 0; sessionData.push({ - id: (toAgentHostBackendSessionUri(model.sessionResource) ?? model.sessionResource).toString(), + id: sessionId, label: model.title || undefined, session_type: 'chat', state: needsInput ? 'waiting_for_input' : inProgress ? 'working' : 'idle', diff --git a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts index a00123838b4d25..dd052a7cccc31b 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceInputMode/voiceInputModeActionViewItem.ts @@ -54,7 +54,7 @@ const DICTATION_TOGGLE_COMMAND_ID = 'workbench.action.chat.toggleSpeechToText'; */ const VOICE_START_COMMAND_ID = 'agentsVoice.startVoiceInChat'; -async function retargetVoiceToCurrentSession(commandService: ICommandService, controller: IVoiceSessionController, window: Window & typeof globalThis): Promise { +async function retargetVoiceToCurrentSession(commandService: ICommandService, controller: IVoiceSessionController): Promise { const currentSession = await commandService.executeCommand('_chat.voice.getCurrentSession'); if (!currentSession) { return false; @@ -62,9 +62,10 @@ async function retargetVoiceToCurrentSession(commandService: ICommandService, co try { const resource = URI.parse(currentSession); if (resource.scheme === 'sessions-voice') { - controller.takeDraftInputOwnership(window); + controller.setDraftTarget(); } else { - controller.takeSessionInputOwnership(resource, window); + controller.setTargetSession(resource); + controller.activateSession(resource); } return true; } catch { @@ -174,9 +175,7 @@ export class ChatVoiceInputModeToggleListenAction extends Action2 { this._holdActive = true; try { - if (!controller.retainOmniInputOwnershipForBargeIn(win)) { - await retargetVoiceToCurrentSession(accessor.get(ICommandService), controller, win); - } + await retargetVoiceToCurrentSession(accessor.get(ICommandService), controller); // Auto-connect on the first hold so users can start talking with one shortcut. if (!controller.isConnected.get() && !controller.isConnecting.get()) { await controller.connect(win); @@ -313,8 +312,6 @@ export interface IVoiceInputModePillOptions { readonly isDictationActive?: IObservable; /** Whether the shared Voice Mode transport belongs to this input. */ readonly isVoiceActive?: IObservable; - /** Claim Voice Mode for this host instead of targeting the last focused chat session. */ - readonly activateVoiceMode?: () => void | Promise; } /** @@ -825,20 +822,12 @@ export class VoiceInputModeActionViewItem extends BaseActionViewItem { const targetWindow = getWindow(this._voiceCell); if (controller.isConnected.get() || controller.isConnecting.get()) { if (this._options?.isVoiceActive?.get() === false) { - if (this._options.activateVoiceMode) { - await this._options.activateVoiceMode(); - } else { - await retargetVoiceToCurrentSession(this.commandService, controller, targetWindow); - } + await retargetVoiceToCurrentSession(this.commandService, controller); return; } controller.disconnect(); } else { - if (this._options?.activateVoiceMode) { - await this._options.activateVoiceMode(); - } else { - await retargetVoiceToCurrentSession(this.commandService, controller, targetWindow); - } + await retargetVoiceToCurrentSession(this.commandService, controller); controller.connect(targetWindow).catch(() => { /* connect failures are surfaced/logged by the controller */ }); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 02ea5e3d7d093f..56d0546a8e1a98 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -49,7 +49,6 @@ const NEXT_QUESTION_ACTION_ID = 'workbench.action.chat.nextQuestion'; export interface IChatQuestionCarouselOptions { onSubmit: (answers: Map | undefined) => void; shouldAutoFocus?: boolean; - fitContent?: boolean; } class ChatQuestionAnswerCollapsiblePart extends ChatCollapsibleContentPart { @@ -169,7 +168,6 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this.domNode = dom.$('.chat-question-carousel-container'); this.domNode.classList.toggle('chat-question-carousel-conversation', carousel.answerPresentation === 'conversation'); - this.domNode.classList.toggle('chat-question-carousel-fit-content', this._options.fitContent === true); this.domNode.id = generateUuid(); this._inChatQuestionCarouselContextKey = ChatContextKeys.inChatQuestionCarousel.bindTo(this._contextKeyService); this._chatQuestionCarouselHasTerminalContextKey = ChatContextKeys.chatQuestionCarouselHasTerminal.bindTo(this._contextKeyService); @@ -549,9 +547,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const availableScrollableHeight = Math.floor(maxContainerHeight - contentVerticalPadding - nonScrollableContentHeight); const contentScrollableHeight = scrollableContent.scrollHeight; - const constrainedScrollableHeight = this._options.fitContent - ? contentScrollableHeight - : Math.max(0, Math.min(availableScrollableHeight, contentScrollableHeight)); + const constrainedScrollableHeight = Math.max(0, Math.min(availableScrollableHeight, contentScrollableHeight)); const constrainedScrollableHeightPx = `${constrainedScrollableHeight}px`; // Constrain wrapper + content so no stale flex sizing survives between steps. diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 6278b7db5e8ab9..6c49afefb5a766 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -29,10 +29,6 @@ position: relative; } -.interactive-session .chat-question-carousel-container.chat-question-carousel-fit-content { - max-height: none; -} - .interactive-session .chat-question-carousel-container:focus { outline: none; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index a5983fadceb0f0..2725a3f0a12319 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -3821,7 +3821,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer handleSubmit(answers, part) }); return part; @@ -3831,7 +3830,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer handleSubmit(answers, part!) }); @@ -3839,7 +3837,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer handleSubmit(answers, fallbackPart) }); return fallbackPart; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index c6105d7679229e..2f574e0ec07f24 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -156,10 +156,6 @@ function isInlineChat(widget: IChatWidget): boolean { return isIChatResourceViewContext(widget.viewContext) && Boolean(widget.viewContext.isInlineChat); } -export function isChatInputWindow(widget: IChatWidget): boolean { - return isIChatResourceViewContext(widget.viewContext) && Boolean(widget.viewContext.isChatInputWindow); -} - /** Whether the widget is a short-lived, single-task chat surface. */ function isTransientChat(widget: IChatWidget): boolean { return widget.location !== ChatAgentLocation.Chat || isInlineChat(widget) || isQuickChat(widget); @@ -293,6 +289,7 @@ export class ChatWidget extends Disposable implements IChatWidget { private readonly _onDidSubmitAgent = this._register(new Emitter<{ agent: IChatAgentData; slashCommand?: IChatAgentCommand }>()); readonly onDidSubmitAgent = this._onDidSubmitAgent.event; + private _submitHandlerInFlight = false; private _onDidChangeAgent = this._register(new Emitter<{ agent: IChatAgentData; slashCommand?: IChatAgentCommand }>()); readonly onDidChangeAgent = this._onDidChangeAgent.event; @@ -895,10 +892,6 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.viewModel?.editing && this.configurationService.getValue('chat.editRequests') !== 'input' ? this.inlineInputPart : this.inputPart; } - get contextPicker() { - return this.viewOptions.contextPicker; - } - /** * The main input part at the buttom of the chat widget. Use `input` to get the active input (main or inline editing part). */ @@ -1020,7 +1013,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.createInput(this.container, { renderFollowups, renderStyle, renderInputToolbarBelowInput }); } - if (this.location === ChatAgentLocation.Chat && !isInlineChat(this) && !this.scopedContextKeyService.contextMatchesRules(ChatContextKeys.inChatInputWindow)) { + if (this.location === ChatAgentLocation.Chat && !isInlineChat(this)) { const inputContainer = this.inputPart.inputContainerElement; const petHost = this.inputPart.element; const inputHasContent = observableFromEvent(this, this.inputEditor.onDidChangeModelContent, () => this.inputEditor.getValue().length > 0); @@ -2337,21 +2330,12 @@ export class ChatWidget extends Disposable implements IChatWidget { supportsChangingModes: this.viewOptions.supportsChangingModes, dndContainer: this.viewOptions.dndContainer, inputEditorMinLines: this.viewOptions.inputEditorMinLines, - inputEditorMaxHeight: this.viewOptions.inputEditorMaxHeight, - deferredNotificationsEnabled: this.viewOptions.deferredNotificationsEnabled, isTransientChat: isTransientChat(this), widgetViewKindTag: this.getWidgetViewKindTag(), defaultMode: this.viewOptions.defaultMode, sessionTypePickerDelegate: this.viewOptions.sessionTypePickerDelegate, - modelPickerSessionType: this.viewOptions.modelPickerSessionType, workspacePickerDelegate: this.viewOptions.workspacePickerDelegate, isSessionsWindow: this.viewOptions.isSessionsWindow, - onDidChangeModelPickerVisibility: this.viewOptions.onDidChangeModelPickerVisibility, - inputPickerPosition: this.viewOptions.inputPickerPosition, - inputPickerContainer: this.viewOptions.inputPickerContainer, - inputPickerAnchor: this.viewOptions.inputPickerAnchor, - inputPickerOpenOnMouseUp: this.viewOptions.inputPickerOpenOnMouseUp, - contextPicker: this.viewOptions.contextPicker, }; if (this.viewModel?.editing) { @@ -2863,33 +2847,17 @@ export class ChatWidget extends Disposable implements IChatWidget { return undefined; } - const hasCustomSubmitHandler = !!this.viewOptions.submitHandler; - if (hasCustomSubmitHandler) { - this.input.setSubmitPending(true, true); + if (!options?.preserveInput) { + // preserveInput submissions (e.g. /compact or programmatic maintenance + // requests) leave the input draft untouched, so they must not stop an + // unrelated dictation and flush its final transcript into that draft. + await stopDictationForEditor(this.inputEditor); } - try { - if (!options?.preserveInput) { - // preserveInput submissions (e.g. /compact or programmatic maintenance - // requests) leave the input draft untouched, so they must not stop an - // unrelated dictation and flush its final transcript into that draft. - await stopDictationForEditor(this.inputEditor); - if (hasCustomSubmitHandler) { - // Finalizing dictation can edit the input, which clears pending state. - this.input.setSubmitPending(true, true); - } - } - - if (this.viewModel) { - markChat(this.viewModel.sessionResource, ChatPerfMark.RequestStart); - } - return await this._acceptInput(query ? { query } : undefined, options); - } catch (error) { - if (hasCustomSubmitHandler) { - this.input.setSubmitPending(false); - } - throw error; + if (this.viewModel) { + markChat(this.viewModel.sessionResource, ChatPerfMark.RequestStart); } + return this._acceptInput(query ? { query } : undefined, options); } async rerunLastRequest(): Promise { @@ -3047,9 +3015,6 @@ export class ChatWidget extends Disposable implements IChatWidget { const start = Date.now(); await this.input.generating; if (Date.now() - start > generatingAutoSubmitWindow) { - if (this.viewOptions.submitHandler) { - this.input.setSubmitPending(false); - } return; } } @@ -3059,25 +3024,28 @@ export class ChatWidget extends Disposable implements IChatWidget { } if (!this.viewModel) { - if (this.viewOptions.submitHandler) { - this.input.setSubmitPending(false); - } return; } let savedBeforeSend = false; // Check if a custom submit handler wants to handle this submission if (this.viewOptions.submitHandler) { - const inputValue = !query ? this.getInput() : query.query; - await saveAllBeforeChatSend(this.configurationService, this.editorService); - savedBeforeSend = true; - const attachedContext = this.input.getAttachedContext().asArray(); - const handled = await this.viewOptions.submitHandler(inputValue, this.input.currentModeKind, attachedContext, options.isVoiceModeInput); - if (handled) { + if (this._submitHandlerInFlight) { return; } - // The handler declined to route this submission; restore the send button. - this.input.setSubmitPending(false); + this._submitHandlerInFlight = true; + try { + const inputValue = !query ? this.getInput() : query.query; + await saveAllBeforeChatSend(this.configurationService, this.editorService); + savedBeforeSend = true; + const attachedContext = this.input.getAttachedContext().asArray(); + const handled = await this.viewOptions.submitHandler(inputValue, this.input.currentModeKind, attachedContext, options.isVoiceModeInput); + if (handled) { + return; + } + } finally { + this._submitHandlerInFlight = false; + } } const isUserQuery = !query; 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 0c198fbad4a070..3b9cb08d3e1ee3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -12,7 +12,6 @@ import { IActionViewItem } from '../../../../../../base/browser/ui/actionbar/act import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import * as aria from '../../../../../../base/browser/ui/aria/aria.js'; import { ButtonWithIcon } from '../../../../../../base/browser/ui/button/button.js'; -import { IAnchor } from '../../../../../../base/browser/ui/contextview/contextview.js'; import { createInstantHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IAction } from '../../../../../../base/common/actions.js'; import { equals as arraysEqual } from '../../../../../../base/common/arrays.js'; @@ -26,7 +25,6 @@ import { onUnexpectedError } from '../../../../../../base/common/errors.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; -import { AnchorPosition } from '../../../../../../base/common/layout.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; @@ -131,7 +129,7 @@ import { IChatAttachmentWidgetRegistry } from '../../attachments/chatAttachmentW import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, BrowserViewAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; import { ChatImplicitContexts } from '../../attachments/chatImplicitContext.js'; import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; -import { IChatContextPickerDelegate, IChatWidget, IChatWidgetService, IChatWidgetViewModelChangeEvent, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js'; +import { IChatWidget, IChatWidgetService, IChatWidgetViewModelChangeEvent, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js'; import { ChatEditingShowChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; import { resizeImage } from '../../chatImageUtils.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js'; @@ -206,9 +204,8 @@ export interface IChatInputPartOptions { supportsChangingModes?: boolean; dndContainer?: HTMLElement; inputEditorMinLines?: number; - inputEditorMaxHeight?: number; deferredNotificationsEnabled?: boolean; - /** Whether this input is a transient surface (inline, terminal, quick chat, chat input window). */ + /** Whether this input is a transient surface (inline, terminal, or quick chat). */ isTransientChat?: boolean; widgetViewKindTag: string; /** @@ -216,8 +213,6 @@ export interface IChatInputPartOptions { * When provided, allows the input part to maintain independent state for the selected session type. */ sessionTypePickerDelegate?: ISessionTypePickerDelegate; - /** Override the temporary model's session type for routing-only surfaces. */ - modelPickerSessionType?: string; /** * Optional delegate for the workspace picker. * When provided, shows a workspace picker allowing users to select a target workspace @@ -269,12 +264,6 @@ export interface IChatInputPartOptions { * can pass `0` so the editor fills the box and its scrollbar sits at the edge. */ inputPartHorizontalPadding?: number; - onDidChangeModelPickerVisibility?: (visible: boolean) => void | Promise; - inputPickerPosition?: AnchorPosition | (() => AnchorPosition); - inputPickerContainer?: HTMLElement | (() => HTMLElement | undefined); - inputPickerAnchor?: (anchor: HTMLElement) => HTMLElement | IAnchor; - inputPickerOpenOnMouseUp?: boolean; - contextPicker?: IChatContextPickerDelegate; } export interface IWorkingSetEntry { @@ -599,8 +588,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private inputModel: ITextModel | undefined; private inputEditorHasText: IContextKey; private inputEditorHasSendableContent: IContextKey; - private inputSubmitPending: IContextKey; - private inputRouting: IContextKey; private chatCursorAtTop: IContextKey; private inputEditorHasFocus: IContextKey; private currentlyEditingInputKey!: IContextKey; @@ -958,15 +945,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge addAttachments: (entries: readonly IChatRequestVariableEntry[]) => attachmentModel.addContext(...entries), }, styles)); - this.inputEditorMaxHeight = this.options.inputEditorMaxHeight ?? (this.options.renderStyle === 'compact' ? INPUT_EDITOR_MAX_HEIGHT / 3 : INPUT_EDITOR_MAX_HEIGHT); + this.inputEditorMaxHeight = this.options.renderStyle === 'compact' ? INPUT_EDITOR_MAX_HEIGHT / 3 : INPUT_EDITOR_MAX_HEIGHT; const padding = this.options.renderStyle === 'compact' ? INPUT_EDITOR_PADDING.compact : INPUT_EDITOR_PADDING.default; this.singleLineInputEditorHeight = INPUT_EDITOR_LINE_HEIGHT + padding.top + padding.bottom; this.inputEditorMinHeight = this.options.inputEditorMinLines ? this.options.inputEditorMinLines * INPUT_EDITOR_LINE_HEIGHT + padding.top + padding.bottom : undefined; this.inputEditorHasText = ChatContextKeys.inputHasText.bindTo(contextKeyService); this.inputEditorHasSendableContent = ChatContextKeys.inputHasSendableContent.bindTo(contextKeyService); - this.inputSubmitPending = ChatContextKeys.inputSubmitPending.bindTo(contextKeyService); - this.inputRouting = ChatContextKeys.inputRouting.bindTo(contextKeyService); this.chatCursorAtTop = ChatContextKeys.inputCursorAtTop.bindTo(contextKeyService); this.inputEditorHasFocus = ChatContextKeys.inputHasFocus.bindTo(contextKeyService); this._hasQuestionCarouselContextKey = ChatContextKeys.Editing.hasQuestionCarousel.bindTo(contextKeyService); @@ -1287,8 +1272,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private _createModelPickerDelegate(): IModelPickerDelegate { - const inputPickerContainer = this.options.inputPickerContainer; - const inputPickerPosition = this.options.inputPickerPosition; return { currentModel: this._currentLanguageModel, setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { @@ -1299,15 +1282,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge isCacheWarm: () => (this._widget?.viewModel?.model.getRequests().length ?? 0) > 0, getPresentationOptions: () => this._getModelPickerPresentationOptions(), modelConfiguration: this._modelConfigStore, - onDidChangeVisibility: this.options.onDidChangeModelPickerVisibility, - get anchorPosition() { - return typeof inputPickerPosition === 'function' ? inputPickerPosition() : inputPickerPosition; - }, - get actionWidgetContainer() { - return typeof inputPickerContainer === 'function' ? inputPickerContainer() : inputPickerContainer; - }, - getActionWidgetAnchor: this.options.inputPickerAnchor, - openOnMouseUp: this.options.inputPickerOpenOnMouseUp, }; } @@ -1925,9 +1899,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * delegate when there is no session yet (the welcome view has no view model). */ private getCurrentSessionType(): string | undefined { - if (this.options.modelPickerSessionType) { - return this.options.modelPickerSessionType; - } const sessionResource = this._widget?.viewModel?.model.sessionResource; if (sessionResource) { return getChatSessionType(sessionResource); @@ -2359,22 +2330,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.renderAttachedContext(); } - /** - * Toggle the "submit pending" state. While pending, the input reflects that a - * submitted request is still being routed/dispatched (e.g. omni-chat routing, - * where submission is intercepted and handled off-model) so the send button is - * disabled until the submission resolves or the draft changes. Any input content - * change clears this automatically. - */ - setSubmitPending(pending: boolean, routing = pending): void { - const changed = this.inputSubmitPending.get() !== pending || this.inputRouting.get() !== routing; - this.inputSubmitPending.set(pending); - this.inputRouting.set(routing); - if (changed) { - this.executeToolbar?.refresh(); - } - } - private _updateInputContentContextKeys(): void { const inputHasText = !!this._inputEditor?.getModel()?.getValue().trim(); this.inputEditorHasText.set(inputHasText); @@ -2930,11 +2885,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private updateDeferredNotificationsEligibility(e?: IChatWidgetViewModelChangeEvent): void { - if (this.options.deferredNotificationsEnabled !== undefined) { - this._deferredNotificationsEnabled.set(this.options.deferredNotificationsEnabled, undefined); - return; - } - if (this.environmentService.isSessionsWindow) { this._deferredNotificationsEnabled.set(true, undefined); return; @@ -3309,10 +3259,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._updateInputContentContextKeys(); - // A submitted request was pending (e.g. omni-chat routing) but the draft - // changed: the user is editing again, so re-enable sending. - this.setSubmitPending(false); - // Update monospace state as the command prefix is typed/removed. this.updateInputEditorFontFamily(); @@ -3351,12 +3297,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const { location } = this.getWidgetLocationInfo(widget); const focusedWidget = observableFromEvent(this, this.chatWidgetService.onDidChangeFocusedSession, () => this.chatWidgetService.lastFocusedWidget); const isVoiceInputActive = derived(this, reader => focusedWidget.read(reader) === widget); - const isOmniInput = this.contextKeyService.getContextKeyValue(ChatContextKeys.inChatInputWindow.key) === true; const isVoiceSessionActive = derived(this, reader => { - const omniInputOpen = this.voiceSessionController.omniInputOpen.read(reader); - if (omniInputOpen) { - return isOmniInput; - } if (!isVoiceInputActive.read(reader)) { return false; } @@ -3370,11 +3311,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge getOverflowAnchor: () => this.inputActionsToolbar.getElement(), actionContext: { widget }, compact: derived(reader => this._stableInputPartWidth.read(reader) < CHAT_INPUT_PICKER_COLLAPSE_WIDTH), - listOptions: this.options.inputPickerPosition === undefined ? undefined : { - anchorPosition: typeof this.options.inputPickerPosition === 'function' - ? this.options.inputPickerPosition() - : this.options.inputPickerPosition, - }, }; const primarySessionPickerOptions: IChatInputPickerOptions = { ...pickerOptions, @@ -3465,7 +3401,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } })); this.inputActionsToolbar.getElement().classList.add('chat-input-toolbar'); - this.inputActionsToolbar.context = { widget, contextPicker: this.options.contextPicker } satisfies IChatExecuteActionContext; + this.inputActionsToolbar.context = { widget } satisfies IChatExecuteActionContext; this._register(this.inputActionsToolbar.onDidChangeMenuItems(() => { // Update container reference for the pickers (cloud sessions host them in the primary toolbar) const toolbarElement = this.inputActionsToolbar.getElement(); @@ -3514,17 +3450,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge isActive: isVoiceInputActive, isDictationActive: isDictationInputActive, isVoiceActive: isVoiceSessionActive, - activateVoiceMode: isOmniInput ? () => { - this.voiceSessionController.takeOmniInputOwnership(dom.getWindow(toolbarsContainer)); - } : undefined, }); } if ((action.id === ChatSubmitAction.ID || action.id === ChatEditingSessionSubmitAction.ID) && action instanceof MenuItemAction) { return this.instantiationService.createInstance(class extends MenuEntryActionViewItem { - protected override getHoverContents() { - return isOmniInput ? undefined : super.getHoverContents(); - } - override render(container: HTMLElement): void { super.render(container); container.classList.add('chat-submit-button'); @@ -3548,7 +3477,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }, })); this.executeToolbar.getElement().classList.add('chat-execute-toolbar'); - this.executeToolbar.context = { widget, contextPicker: this.options.contextPicker } satisfies IChatExecuteActionContext; + this.executeToolbar.context = { widget } satisfies IChatExecuteActionContext; // The lone dictation / Voice Mode control drops its circular border and // only regains it when both share the row (see the matching rules in // chat.css). Count the voice-input actions from the toolbar's action @@ -3588,7 +3517,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge })); this.inputSideToolbarContainer = toolbarSide.getElement(); toolbarSide.getElement().classList.add('chat-side-toolbar'); - toolbarSide.context = { widget, contextPicker: this.options.contextPicker } satisfies IChatExecuteActionContext; + toolbarSide.context = { widget } satisfies IChatExecuteActionContext; } // Secondary toolbar (permissions) — below the input box. diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts index 976808d52ee93b..ddc4987f23213a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts @@ -32,8 +32,8 @@ export interface IChatInputPickerOptions { export function withChatInputPickerMotion(listOptions: IActionListOptions | undefined): IActionListOptions { return { - anchorPosition: AnchorPosition.ABOVE, ...withActionWidgetDropdownMotion(listOptions), + anchorPosition: AnchorPosition.ABOVE, }; } 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 01f4fe3db08edd..973051a341d37b 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 @@ -8,11 +8,9 @@ import { IManagedHoverContent } from '../../../../../../../base/browser/ui/hover import { getBaseLayerHoverDelegate } from '../../../../../../../base/browser/ui/hover/hoverDelegate2.js'; import { getDefaultHoverDelegate } from '../../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { BaseActionViewItem } from '../../../../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { IAnchor } from '../../../../../../../base/browser/ui/contextview/contextview.js'; import { IAction } from '../../../../../../../base/common/actions.js'; import { IStringDictionary } from '../../../../../../../base/common/collections.js'; import { Event } from '../../../../../../../base/common/event.js'; -import { AnchorPosition } from '../../../../../../../base/common/layout.js'; import { MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../../../../../base/common/observable.js'; import { localize } from '../../../../../../../nls.js'; @@ -75,11 +73,6 @@ export interface IModelPickerDelegate { * writes configuration through the global {@link ILanguageModelsService}. */ readonly modelConfiguration?: IModelConfigurationAccess; - onDidChangeVisibility?(visible: boolean): void | Promise; - readonly anchorPosition?: AnchorPosition; - readonly actionWidgetContainer?: HTMLElement; - getActionWidgetAnchor?(anchor: HTMLElement): HTMLElement | IAnchor; - readonly openOnMouseUp?: boolean; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerConfiguration.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerConfiguration.ts index 6d4e56b0f3e80d..d1889cfbfc0035 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerConfiguration.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPicker/modelPickerConfiguration.ts @@ -4,9 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../../../base/browser/dom.js'; -import { IAnchor } from '../../../../../../../base/browser/ui/contextview/contextview.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; -import { AnchorPosition } from '../../../../../../../base/common/layout.js'; import { formatTokenCount } from '../../../../../../../base/common/numbers.js'; import { ThemeIcon } from '../../../../../../../base/common/themables.js'; import { localize } from '../../../../../../../nls.js'; @@ -56,17 +54,10 @@ export interface IModelPickerConfigurationHost { readonly shouldShowCacheBreakHint: () => boolean; readonly getCacheBreakLearnMoreLink: () => IActionListHeaderLink | undefined; readonly dismissCacheBreakHint: () => void; - readonly onDidChangeVisibility?: (visible: boolean) => void | Promise; - readonly getActionWidgetContainer?: () => HTMLElement | undefined; - readonly getActionWidgetAnchor?: (anchor: HTMLElement) => HTMLElement | IAnchor; - readonly getAnchorPosition?: () => AnchorPosition | undefined; } export class ModelPickerConfiguration { - private _showRequestId = 0; - private _activeButton: HTMLElement | undefined; - constructor( private readonly _host: IModelPickerConfigurationHost, @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, @@ -123,11 +114,6 @@ export class ModelPickerConfiguration { if (this._host.isDisabled() || !button || !this._host.getSelectedModel()) { return; } - if (button.getAttribute('aria-expanded') === 'true') { - this._showRequestId++; - this._actionWidgetService.hide(true); - return; - } const items = this._buildItems(); if (!items.length) { @@ -135,7 +121,6 @@ export class ModelPickerConfiguration { } const previouslyFocusedElement = dom.getActiveElement(); - const showRequestId = ++this._showRequestId; const delegate = { onSelect: async (action: IActionWidgetDropdownAction) => { this._actionWidgetService.focusItemById(action.id); @@ -143,15 +128,7 @@ export class ModelPickerConfiguration { this._actionWidgetService.updateItems(this._buildItems(), action.id); }, onHide: () => { - this._showRequestId++; - if (this._activeButton === button) { - this._activeButton = undefined; - } button.setAttribute('aria-expanded', 'false'); - const visibilityChange = this._host.onDidChangeVisibility?.(false); - if (visibilityChange) { - void visibilityChange.catch(() => { }); - } if (dom.isHTMLElement(previouslyFocusedElement)) { previouslyFocusedElement.focus(); } @@ -159,71 +136,34 @@ export class ModelPickerConfiguration { }; button.setAttribute('aria-expanded', 'true'); - this._activeButton = button; const showCacheBreakHint = this._host.shouldShowCacheBreakHint(); - const showActionWidget = () => { - if (showRequestId !== this._showRequestId || button.getAttribute('aria-expanded') !== 'true') { - return; - } - this._actionWidgetService.show( - 'ChatModelConfigPicker', - false, - items, - delegate, - this._host.getActionWidgetAnchor?.(button) ?? button, - this._host.getActionWidgetContainer?.(), - [], - { - isChecked: element => element.kind === ActionListItemKind.Action ? !!element.item?.checked : undefined, - getRole: element => element.kind === ActionListItemKind.Action ? 'menuitemradio' as const : 'separator' as const, - getWidgetRole: () => 'menu' as const, - }, - withChatInputPickerMotion({ - headerText: showCacheBreakHint ? localize('chat.config.cacheBreakHint', "Changing these options mid-session resets the prompt cache and may increase cost.") : undefined, - headerIcon: showCacheBreakHint ? Codicon.info : undefined, - headerLink: showCacheBreakHint ? this._host.getCacheBreakLearnMoreLink() : undefined, - headerDismiss: showCacheBreakHint ? this._host.dismissCacheBreakHint : undefined, - reserveSubmenuSpace: false, - anchorPosition: this._host.getAnchorPosition?.(), - }), - ); + this._actionWidgetService.show( + 'ChatModelConfigPicker', + false, + items, + delegate, + button, + undefined, + [], + { + isChecked: element => element.kind === ActionListItemKind.Action ? !!element.item?.checked : undefined, + getRole: element => element.kind === ActionListItemKind.Action ? 'menuitemradio' as const : 'separator' as const, + getWidgetRole: () => 'menu' as const, + }, + withChatInputPickerMotion({ + headerText: showCacheBreakHint ? localize('chat.config.cacheBreakHint', "Changing these options mid-session resets the prompt cache and may increase cost.") : undefined, + headerIcon: showCacheBreakHint ? Codicon.info : undefined, + headerLink: showCacheBreakHint ? this._host.getCacheBreakLearnMoreLink() : undefined, + headerDismiss: showCacheBreakHint ? this._host.dismissCacheBreakHint : undefined, + reserveSubmenuSpace: false, + }), + ); - if (focusGroup) { - const groupItem = items.find(item => item.kind === ActionListItemKind.Action && item.item?.id?.startsWith(`${focusGroup}.`)); - if (groupItem?.kind === ActionListItemKind.Action && groupItem.item) { - this._actionWidgetService.focusItemById(groupItem.item.id); - } + if (focusGroup) { + const groupItem = items.find(item => item.kind === ActionListItemKind.Action && item.item?.id?.startsWith(`${focusGroup}.`)); + if (groupItem?.kind === ActionListItemKind.Action && groupItem.item) { + this._actionWidgetService.focusItemById(groupItem.item.id); } - }; - const visibilityChange = this._host.onDidChangeVisibility?.(true); - if (visibilityChange) { - void visibilityChange.then(showActionWidget, () => { - if (showRequestId !== this._showRequestId) { - return; - } - this._showRequestId++; - if (this._activeButton === button) { - this._activeButton = undefined; - } - button.setAttribute('aria-expanded', 'false'); - const hideVisibilityChange = this._host.onDidChangeVisibility?.(false); - if (hideVisibilityChange) { - void hideVisibilityChange.catch(() => { }); - } - if (dom.isHTMLElement(previouslyFocusedElement)) { - previouslyFocusedElement.focus(); - } - }); - } else { - showActionWidget(); - } - } - - dispose(): void { - this._showRequestId++; - if (this._activeButton) { - this._activeButton = undefined; - this._actionWidgetService.hide(true); } } 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 48372ee315cd3a..ac63192dee58f9 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 @@ -14,7 +14,6 @@ import { IStringDictionary } from '../../../../../../../base/common/collections. import { Codicon } from '../../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { KeyCode } from '../../../../../../../base/common/keyCodes.js'; -import { AnchorPosition } from '../../../../../../../base/common/layout.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; import { disposableTimeout } from '../../../../../../../base/common/async.js'; import { autorun, IObservable } from '../../../../../../../base/common/observable.js'; @@ -108,9 +107,6 @@ export class ModelPickerWidget extends Disposable { private _workspaceTrustInitialized = false; private _activatingAfterTrust = false; private readonly _activatingTimer = this._register(new MutableDisposable()); - private readonly _pendingAuxiliaryRelayout = this._register(new MutableDisposable()); - private readonly _activeShowDisposables = this._register(new MutableDisposable()); - private _showRequestId = 0; private _domNode: HTMLElement | undefined; private _badgeIcon: HTMLElement | undefined; @@ -156,10 +152,6 @@ export class ModelPickerWidget extends Disposable { shouldShowCacheBreakHint: () => this.shouldShowCacheBreakHint(/* excludeAutoModel */ false), getCacheBreakLearnMoreLink: () => this.getCacheBreakLearnMoreLink(), dismissCacheBreakHint: () => this.dismissCacheBreakHint(), - onDidChangeVisibility: visible => this._delegate.onDidChangeVisibility?.(visible), - getActionWidgetContainer: () => this._delegate.actionWidgetContainer, - getActionWidgetAnchor: anchor => this._delegate.getActionWidgetAnchor?.(anchor) ?? anchor, - getAnchorPosition: () => this._delegate.anchorPosition, }); this._register(this._languageModelsService.onDidChangeLanguageModels(() => { if (this._activatingAfterTrust && this._delegate.getModels().length > 0) { @@ -355,30 +347,13 @@ export class ModelPickerWidget extends Disposable { * Registers mouse-down and Enter/Space key handlers on a button element. */ private _registerButtonAction(element: HTMLElement, action: () => void): void { - let expandedOnMouseDown = false; - if (this._delegate.openOnMouseUp) { - this._register(dom.addDisposableGenericMouseDownListener(element, e => { - // Focusing this window can dismiss a picker in another window before mouse-up. - if (e.button === 0) { - expandedOnMouseDown = element.getAttribute('aria-expanded') === 'true'; - } - })); - } - const runAction = (e: MouseEvent) => { + this._register(dom.addDisposableGenericMouseDownListener(element, e => { if (e.button !== 0) { return; } dom.EventHelper.stop(e, true); - if (this._delegate.openOnMouseUp && expandedOnMouseDown && element.getAttribute('aria-expanded') !== 'true') { - expandedOnMouseDown = false; - return; - } - expandedOnMouseDown = false; action(); - }; - this._register(this._delegate.openOnMouseUp - ? dom.addDisposableGenericMouseUpListener(element, runAction) - : dom.addDisposableGenericMouseDownListener(element, runAction)); + })); this._register(dom.addDisposableListener(element, dom.EventType.KEY_DOWN, (e) => { const event = new StandardKeyboardEvent(e); if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) { @@ -433,13 +408,6 @@ export class ModelPickerWidget extends Disposable { return; } if (this._nameButton?.getAttribute('aria-expanded') === 'true') { - this._showRequestId++; - this._activeShowDisposables.clear(); - this._nameButton.setAttribute('aria-expanded', 'false'); - const visibilityChange = this._delegate.onDidChangeVisibility?.(false); - if (visibilityChange) { - void visibilityChange.catch(() => { }); - } this._actionWidgetService.hide(true); return; } @@ -520,9 +488,6 @@ export class ModelPickerWidget extends Disposable { // picker is hidden. The ActionListWidget only tracks the disposable for the // currently-shown hover; all other items' hover disposables would leak. const hoverDisposables = new DisposableStore(); - const showDisposables = new DisposableStore(); - showDisposables.add(hoverDisposables); - this._activeShowDisposables.value = showDisposables; for (const item of items) { if (item.hover?.disposable) { hoverDisposables.add(item.hover.disposable); @@ -560,7 +525,6 @@ export class ModelPickerWidget extends Disposable { void this._openerService.open(uri, { allowCommands: true }); }, minWidth: 200, - anchorPosition: this._delegate.anchorPosition ?? AnchorPosition.ABOVE, }); const previouslyFocusedElement = dom.getActiveElement(); @@ -570,17 +534,8 @@ export class ModelPickerWidget extends Disposable { action.run(); }, onHide: () => { - this._showRequestId++; - if (this._activeShowDisposables.value === showDisposables) { - this._activeShowDisposables.clear(); - } else { - showDisposables.dispose(); - } + hoverDisposables.dispose(); this._nameButton?.setAttribute('aria-expanded', 'false'); - const visibilityChange = this._delegate.onDidChangeVisibility?.(false); - if (visibilityChange) { - void visibilityChange.catch(() => { }); - } if (dom.isHTMLElement(previouslyFocusedElement)) { previouslyFocusedElement.focus(); } @@ -588,63 +543,18 @@ export class ModelPickerWidget extends Disposable { }; this._nameButton?.setAttribute('aria-expanded', 'true'); - const showRequestId = ++this._showRequestId; - const showActionWidget = () => { - if (showRequestId !== this._showRequestId || this._nameButton?.getAttribute('aria-expanded') !== 'true') { - if (this._activeShowDisposables.value === showDisposables) { - this._activeShowDisposables.clear(); - } - return; - } - this._actionWidgetService.show( - 'ChatModelPicker', - false, - items, - delegate, - this._delegate.getActionWidgetAnchor?.(anchorElement) ?? anchorElement, - this._delegate.actionWidgetContainer, - [], - getModelPickerAccessibilityProvider(), - listOptions - ); - if (this._delegate.onDidChangeVisibility) { - this._pendingAuxiliaryRelayout.value = dom.scheduleAtNextAnimationFrame(dom.getWindow(anchorElement), () => { - this._actionWidgetService.updateItems(items); - }); - } - }; - const visibilityChange = this._delegate.onDidChangeVisibility?.(true); - if (visibilityChange) { - void visibilityChange.then(showActionWidget, () => { - if (showRequestId !== this._showRequestId) { - return; - } - this._showRequestId++; - if (this._activeShowDisposables.value === showDisposables) { - this._activeShowDisposables.clear(); - } - this._nameButton?.setAttribute('aria-expanded', 'false'); - const hideVisibilityChange = this._delegate.onDidChangeVisibility?.(false); - if (hideVisibilityChange) { - void hideVisibilityChange.catch(() => { }); - } - if (dom.isHTMLElement(previouslyFocusedElement)) { - previouslyFocusedElement.focus(); - } - }); - } else { - showActionWidget(); - } - } - override dispose(): void { - this._showRequestId++; - this._activeShowDisposables.clear(); - this._configuration.dispose(); - if (this._nameButton?.getAttribute('aria-expanded') === 'true') { - this._actionWidgetService.hide(true); - } - super.dispose(); + this._actionWidgetService.show( + 'ChatModelPicker', + false, + items, + delegate, + anchorElement, + undefined, + [], + getModelPickerAccessibilityProvider(), + listOptions + ); } private _updateBadge(): void { 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 e55310450b7e0d..7a7b0635da72c6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -4793,19 +4793,6 @@ have to be updated for changes to the rules above, or to support more deeply nes font-size: var(--vscode-codiconFontSize-compact); } -/* Add Context (+) button (quick / omni chat): the attach action lives in the - execute toolbar there, so give its glyph the same compact control tier as the - send and voice buttons. Without this it falls back to the default 16px codicon - size and reads visibly larger than the send button beside it. */ -.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item > .action-label.codicon-add-compact { - box-sizing: border-box; - width: 22px; - height: 22px; - justify-content: center; - border-radius: var(--vscode-cornerRadius-circle); - font-size: var(--vscode-codiconFontSize-compact); -} - .chat-execute-toolbar.chat-voice-input-actions-multiple .monaco-action-bar .action-item > .action-label.codicon-mic, .chat-execute-toolbar.chat-voice-input-actions-multiple .monaco-action-bar .action-item > .action-label.codicon-mic-filled, .chat-execute-toolbar.chat-voice-input-actions-multiple .monaco-action-bar .action-item > .action-label.codicon-mic-download-compact, 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 5a77a716baccd3..20bf2261de97aa 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -518,18 +518,13 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } /** - * The single chat input voice mode is currently bound to. Mirrors the routing - * used by `_chat.voice.acceptInput`: an explicit target session (set by the - * floating aux window) wins, otherwise the last-focused chat widget's session, - * falling back to this pane's own session. The glow / transcript render only on + * The single chat input voice mode is currently bound to. An explicit target + * session wins, otherwise the last-focused chat widget's session falls back to + * this pane's own session. The glow / transcript render only on * the pane whose session matches this, so with several chat inputs open (e.g. * this pane plus a chat editor) exactly one lights up. */ private _currentVoiceInputResource(reader?: IReader): URI | undefined { - const omniInputOpen = reader ? this.voiceSessionController.omniInputOpen.read(reader) : this.voiceSessionController.omniInputOpen.get(); - if (omniInputOpen) { - return undefined; - } const target = reader ? this.voiceSessionController.targetSession.read(reader) : this.voiceSessionController.targetSession.get(); if (target) { return target; @@ -644,7 +639,6 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._register(autorun(reader => { const connected = this.voiceSessionController.isConnected.read(reader); const voiceState = this.voiceSessionController.voiceState.read(reader); - const omniInputOpen = this.voiceSessionController.omniInputOpen.read(reader); // Only run the per-frame glow loop for states that actually render a // glow. Idle renders none, so keeping the loop alive then would burn a // requestAnimationFrame callback every frame for nothing. React to @@ -655,7 +649,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const sim = this.voiceInputModeService.simulatedVoiceState.read(reader); const simGlow = sim === 'listening' || sim === 'speaking'; const liveGlow = connected && isGlowingVoiceState(voiceState) && !(voiceState === 'listening' && this.voiceSessionController.isMuted.read(reader)); - if (!omniInputOpen && (simGlow || liveGlow)) { + if (simGlow || liveGlow) { startGlowAnimation(); } else { stopGlowAnimation(); @@ -714,7 +708,6 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const turns = this.voiceSessionController.transcriptTurns.read(reader); const connected = this.voiceSessionController.isConnected.read(reader); const voiceState = this.voiceSessionController.voiceState.read(reader); - const omniInputOpen = this.voiceSessionController.omniInputOpen.read(reader); const targetSession = this.voiceSessionController.targetSession.read(reader); const currentSession = this._currentSessionResource.read(reader); const showTranscript = showTranscriptSetting.read(reader); @@ -723,7 +716,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const visible = turns.filter(t => t.text.length > 0 || (t.speaker === 'user' && t.isPartial)); const showListeningPlaceholder = voiceState === 'listening' && (!showTranscript || !showLiveTranscript); - if (!connected || omniInputOpen) { + if (!connected) { listeningSession = undefined; ownerSession = undefined; transcriptOverlayNode.style.display = 'none'; diff --git a/src/vs/workbench/contrib/chat/chatCodeOrganization.md b/src/vs/workbench/contrib/chat/chatCodeOrganization.md index a370dc1ef8f6a6..bccc1226465722 100644 --- a/src/vs/workbench/contrib/chat/chatCodeOrganization.md +++ b/src/vs/workbench/contrib/chat/chatCodeOrganization.md @@ -23,11 +23,3 @@ This contrib is, as of the end of 2025, the largest workbench contrib in VS Code - `participants/` - Chat participant management (sometimes called "agents" in code). - `tools/` - Language model tools infrastructure and services. - `builtinTools/` - Implementations of some built-in tools. - -## Agents-Only Omni Session Routing - -The floating Omni chat input is owned exclusively by the standalone Agents window. Normal editor workbenches do not load its contribution, register its commands or service, or expose editor menus and accessibility help for opening it. The Sessions sidebar header is the canonical entry point and remains gated by `chat.omni.enabled`. - -`vs/workbench` defines a narrow provider-neutral routing contract, while `vs/sessions` registers the Agents implementation. The boundary includes routable sessions plus a new-session workspace catalog (groups, recent workspaces, browse actions, restored selection, and stable provider identity) without importing Sessions types into workbench. The adapter builds that catalog from the same shared picker model, `ISessionsRecentWorkspacesService`, and `ISessionsProvidersService` used by the Sessions welcome picker. The Omni popup renders the provider-neutral data in its own action-widget auxiliary window, so Local/GitHub/Remote/custom tabs and provider browse UI stay scoped to the floating input. - -Existing-session requests resolve the current owning `ISession` and chat and send through `ISessionsManagementService.sendRequest` in the background. New folder sessions pass the selected folder and provider ID to `createAndSendNewChatRequest`; workspace-less quick chats use the corresponding quick-chat API. A selected provider that disappears is rejected rather than silently falling through to another provider. This keeps provider-owned authentication, policy, Workspace Trust, remote-host behavior, attachment handling, model/mode configuration, and cancellation authoritative. Delivery **Open** uses `ISessionsService.openSession`, so the Agents window renders the selected session locally. diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index d49ec068877e86..3425dbe134d87d 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -41,8 +41,6 @@ export namespace ChatContextKeys { export const inputHasText = new RawContextKey('chatInputHasText', false, { type: 'boolean', description: localize('interactiveInputHasText', "True when the chat input has text.") }); export const inputHasSendableContent = new RawContextKey('chatInputHasSendableContent', false, { type: 'boolean', description: localize('interactiveInputHasSendableContent', "True when the chat input has text or file attachments that can be sent.") }); - export const inputSubmitPending = new RawContextKey('chatInputSubmitPending', false, { type: 'boolean', description: localize('chatInputSubmitPending', "True when a submitted request is being routed or dispatched (e.g. omni-chat routing) and cannot be re-sent yet.") }); - export const inputRouting = new RawContextKey('chatInputRouting', false, { type: 'boolean', description: localize('chatInputRouting', "True while the destination for a submitted chat request is being resolved.") }); export const inputHasFocus = new RawContextKey('chatInputHasFocus', false, { type: 'boolean', description: localize('interactiveInputHasFocus', "True when the chat input has focus.") }); export const inChatInput = new RawContextKey('inChatInput', false, { type: 'boolean', description: localize('inInteractiveInput', "True when focus is in the chat input, false otherwise.") }); export const inChatSession = new RawContextKey('inChat', false, { type: 'boolean', description: localize('inChat', "True when focus is in the chat widget, false otherwise.") }); @@ -121,7 +119,6 @@ export namespace ChatContextKeys { export const inputHasAgent = new RawContextKey('chatInputHasAgent', false); export const location = new RawContextKey('chatLocation', undefined); export const inQuickChat = new RawContextKey('quickChatHasFocus', false, { type: 'boolean', description: localize('inQuickChat', "True when the quick chat UI has focus, false otherwise.") }); - export const inChatInputWindow = new RawContextKey('inChatInputWindow', false, { type: 'boolean', description: localize('inChatInputWindow', "True when focus is in the floating chat input window, false otherwise.") }); export const inAgentSessionsWelcome = new RawContextKey('inAgentSessionsWelcome', false, { type: 'boolean', description: localize('inAgentSessionsWelcome', "True when the chat input is within the agent sessions welcome page.") }); export const inAutomationsDialog = new RawContextKey('inAutomationsDialog', false, { type: 'boolean', description: localize('inAutomationsDialog', "True when the chat input is within the automations dialog.") }); export const chatSessionType = new RawContextKey('chatSessionType', '', { type: 'string', description: localize('chatSessionType', "The type of the current chat session.") }); diff --git a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts b/src/vs/workbench/contrib/chat/common/chatInputWindow.ts deleted file mode 100644 index f93888ddadf41b..00000000000000 --- a/src/vs/workbench/contrib/chat/common/chatInputWindow.ts +++ /dev/null @@ -1,100 +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 { Event } from '../../../../base/common/event.js'; -import { IDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable } from '../../../../base/common/observable.js'; -import { URI } from '../../../../base/common/uri.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IRectangle } from '../../../../platform/window/common/window.js'; - -export const CHAT_INPUT_WINDOW_TOGGLE_COMMAND_ID = 'workbench.action.chat.toggleInputWindow'; -export const CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID = '_chat.omni.acceptVoiceInput'; -export const CHAT_INPUT_WINDOW_SET_VOICE_TARGET_COMMAND_ID = '_chat.voice.setOmniTarget'; - -/** - * Default height for the floating chat input window. - */ -export const CHAT_INPUT_WINDOW_DEFAULT_HEIGHT = 110; - -/** - * Storage keys for persisting window state across restarts. - */ -export const enum ChatInputWindowStorageKeys { - WindowOpen = 'chatInputWindow.windowOpen', - WindowPositionOffset = 'chatInputWindow.windowPositionOffset', - DismissedCIFailures = 'chatInputWindow.dismissedCIFailures', -} - -export interface IChatInputWindowPositionOffset { - readonly x: number; - readonly y: number; -} - -export function getChatInputWindowBounds(invokingWindowBounds: IRectangle, width: number, height: number, offset?: IChatInputWindowPositionOffset): IRectangle { - return { - x: Math.round(invokingWindowBounds.x + (offset?.x ?? (invokingWindowBounds.width - width) / 2)), - y: Math.round(invokingWindowBounds.y + (offset?.y ?? (invokingWindowBounds.height - height) / 2)), - width, - height, - }; -} - -export const IChatInputWindowService = createDecorator('chatInputWindowService'); - -/** A session whose pull request has failing CI checks. */ -export interface IChatInputWindowCIFailure { - readonly sessionResource: URI; - readonly occurrenceId: string; - readonly label: string; - readonly failed: number; - readonly pending: number; - readonly updatedAt: number; -} - -/** Supplies actionable failing-CI sessions to the floating chat input. */ -export interface IChatInputWindowCIFailureProvider { - readonly failures: IObservable; - fixCI(sessionResource: URI): void; -} - -export interface IChatInputWindowService { - readonly _serviceBrand: undefined; - - /** - * Whether the floating chat input window is currently open. - */ - readonly isOpen: boolean; - /** Whether the floating input's auxiliary window currently owns OS focus. */ - readonly hasFocus: boolean; - - /** - * Fires when the window opens or closes. - */ - readonly onDidChangeOpen: Event; - - /** - * Registers failing CI sessions to show in the floating input's attention panel. - */ - registerCIFailureProvider(provider: IChatInputWindowCIFailureProvider): IDisposable; - - /** Routes voice input through omni when its auxiliary window owns focus. */ - acceptVoiceInput(text: string): Promise; - - /** - * Opens the floating chat input window. No-op if already open. - */ - openWindow(invokingWindowBounds?: IRectangle): Promise; - - /** - * Closes the floating chat input window. No-op if already closed. - */ - closeWindow(): void; - - /** - * Toggles the floating chat input window open/closed. - */ - toggleWindow(invokingWindowBounds?: IRectangle): Promise; -} diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 77d0427badd84a..424dbf40864519 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1761,7 +1761,6 @@ export type ChatSendResult = export interface ChatSendResultRejected { readonly kind: 'rejected'; readonly reason: string; - readonly reasonCode?: 'cancelled' | 'providerRemoved'; /** Set when the session was replaced before the request was rejected (e.g. untitled -> read-only contributed session). */ readonly newSessionResource?: URI; } @@ -1775,8 +1774,6 @@ export interface ChatSendResultSent { export interface ChatSendResultQueued { readonly kind: 'queued'; - /** The id of the request model created for this queued message. */ - readonly requestId: string; /** * Promise that resolves when the queued message is actually processed. * Will resolve to a 'sent' or 'rejected' result. diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 7c21244580fdcb..c19a6ccc2b4e22 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1127,7 +1127,7 @@ export class ChatService extends Disposable implements IChatService { } this.trace('sendRequest', `Queued message for session ${sessionResource}`); - return { kind: 'queued', requestId: requestModel.id, deferred: deferred.p }; + return { kind: 'queued', deferred: deferred.p }; } async sendRequest(sessionResource: URI, request: string, options?: IChatSendRequestOptions): Promise { @@ -2196,7 +2196,7 @@ export class ChatService extends Disposable implements IChatService { // Reject the deferred promise for the removed request const deferred = this._queuedRequestDeferreds.get(requestId); if (deferred) { - deferred.complete({ kind: 'rejected', reason: 'Request was removed from queue', reasonCode: 'cancelled' }); + deferred.complete({ kind: 'rejected', reason: 'Request was removed from queue' }); this._queuedRequestDeferreds.delete(requestId); } } @@ -2247,7 +2247,7 @@ export class ChatService extends Disposable implements IChatService { } const deferred = this._queuedRequestDeferreds.get(local.request.id); if (deferred) { - deferred.complete({ kind: 'rejected', reason: 'Request is no longer in the provider queue', reasonCode: 'providerRemoved' }); + deferred.complete({ kind: 'rejected', reason: 'Request was removed from queue' }); this._queuedRequestDeferreds.delete(local.request.id); } } diff --git a/src/vs/workbench/contrib/chat/common/sessionRouter.ts b/src/vs/workbench/contrib/chat/common/sessionRouter.ts deleted file mode 100644 index cbdd99cafe3eb9..00000000000000 --- a/src/vs/workbench/contrib/chat/common/sessionRouter.ts +++ /dev/null @@ -1,352 +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 { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Event } from '../../../../base/common/event.js'; -import { IDisposable } from '../../../../base/common/lifecycle.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { URI } from '../../../../base/common/uri.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IChatSendRequestOptions } from './chatService/chatService.js'; - -/** - * Setting that gates the "omni" chat experience — advisory badge routing on omni - * surfaces such as Quick Chat. See `chat.shared.contribution.ts` for the schema. - */ -export const OmniChatEnabledSettingId = 'chat.omni.enabled'; - -/** Existing sessions must exceed this confidence to be shown or selected. */ -export const SESSION_ROUTE_CONFIDENCE_THRESHOLD = 0.8; - -export function isHighConfidenceSessionRoute(result: ISessionRouteResult): boolean { - return result.confidence > SESSION_ROUTE_CONFIDENCE_THRESHOLD; -} - -/** - * A session that a user request can be routed to. Populated by the caller from - * the session list (e.g. `IChatSessionsService` / `ISessionsService`). - */ -export interface IRoutableSession { - /** Stable identifier used to dispatch the request (e.g. via a `send_message` tool). */ - readonly sessionId: string; - /** Authoritative provider-owned session resource, when available. */ - readonly resource?: URI; - /** Human-readable session name shown to the user. */ - readonly label: string; - /** Owning repository, when known (e.g. `owner/repo`). */ - readonly repo?: string; - /** Working directory of the session, when known. */ - readonly cwd?: string; - /** Coarse activity state (e.g. `idle`, `working`), when known. */ - readonly status?: string; - /** Epoch milliseconds of the last activity, when known. */ - readonly lastActivity?: number; - /** Provider-supplied session summary/description, when known. */ - readonly description?: string; - /** The session's opening user request, when known. */ - readonly firstRequest?: string; - /** The session's most recent user request, when known. */ - readonly lastRequest?: string; - /** The session's most recent response (already truncated by the caller), when known. */ - readonly lastResponse?: string; -} - -export type ChatSessionRoutingDispatchReasonCode = 'cancelled' | 'providerRemoved' | 'unsupportedOptions' | 'workspaceNotTrusted'; - -export interface IChatSessionRoutingDispatchResult { - readonly status: 'sent' | 'queued' | 'rejected'; - readonly resource?: URI; - readonly requestId?: string; - /** Last activity timestamp before dispatch, used to identify completion of this request. */ - readonly activityBaseline?: number; - readonly reason?: string; - readonly reasonCode?: ChatSessionRoutingDispatchReasonCode; - /** Reveals the routed session in its owning presentation service. */ - readonly reveal?: () => Promise; - readonly completion?: Promise; -} - -export interface IChatSessionRoutingWorkspace { - readonly uri: URI; - readonly providerId: string; - readonly group?: string; - readonly label: string; - readonly description?: string; - readonly icon?: ThemeIcon; - readonly disabled?: boolean; -} - -export interface IChatSessionRoutingWorkspaceGroup { - readonly id: string; - readonly label?: string; - readonly tooltip?: string; - readonly icon?: ThemeIcon; -} - -export interface IChatSessionRoutingWorkspaceBrowseAction { - readonly id: string; - readonly providerId?: string; - readonly group?: string; - readonly label: string; - readonly description?: string; - readonly icon?: ThemeIcon; - readonly disabled?: boolean; -} - -export interface IChatSessionRoutingWorkspaceCatalog { - readonly groups: readonly IChatSessionRoutingWorkspaceGroup[]; - readonly workspaces: readonly IChatSessionRoutingWorkspace[]; - readonly browseActions: readonly IChatSessionRoutingWorkspaceBrowseAction[]; - readonly defaultWorkspace?: IChatSessionRoutingWorkspace; -} - -export interface IChatSessionRoutingNewSessionTarget { - readonly folder?: URI; - readonly providerId?: string; -} - -/** - * Provider-neutral catalog and dispatch boundary used by routing hosts that own - * a broader session model than the workbench's renderer-local chat catalog. - */ -export interface IChatSessionRoutingProvider { - readonly onDidChangeSessions?: Event; - readonly onDidChangeNewSessionWorkspaceCatalog?: Event; - getCandidateSessions(token: CancellationToken): readonly IRoutableSession[] | Promise; - getSessionSnapshot?(resource: URI, token: CancellationToken): IRoutableSession | undefined | Promise; - watchSession?(resource: URI, listener: () => void): IDisposable; - getNewSessionWorkspaceCatalog?(): IChatSessionRoutingWorkspaceCatalog | Promise; - selectNewSessionWorkspace?(workspace: IChatSessionRoutingWorkspace): void | Promise; - browseNewSessionWorkspace?(actionId: string, token: CancellationToken): Promise; - resolveSessionResource(sessionId: string): URI | undefined; - dispatchToSession( - sessionId: string, - message: string, - options: IChatSendRequestOptions, - token: CancellationToken, - ): Promise; - dispatchToNewSession( - target: IChatSessionRoutingNewSessionTarget, - message: string, - options: IChatSendRequestOptions, - token: CancellationToken, - ): Promise; - revealSession(resource: URI): Promise; -} - -export const IChatSessionRoutingProviderService = createDecorator('chatSessionRoutingProviderService'); - -export interface IChatSessionRoutingProviderService { - readonly _serviceBrand: undefined; - - registerProvider(provider: IChatSessionRoutingProvider): IDisposable; - getProvider(): IChatSessionRoutingProvider | undefined; -} - -/** A single scored candidate produced by the router, sorted best-first. */ -export interface ISessionRouteResult { - readonly sessionId: string; - /** Match confidence in the range [0, 1]. */ - readonly confidence: number; - /** Optional short rationale for display/debugging. */ - readonly reason?: string; -} - -export interface ISessionRouteRequest { - /** The raw user utterance (e.g. dictated text) to route. */ - readonly utterance: string; - /** Candidate sessions to score against. */ - readonly sessions: readonly IRoutableSession[]; -} - -export const ISessionRouter = createDecorator('sessionRouter'); - -/** - * Scores which existing session a free-form user request best matches, so a - * floating input / voice surface can route the request (or disambiguate when no - * candidate is confident enough). - */ -export interface ISessionRouter { - readonly _serviceBrand: undefined; - - /** - * Rank the candidate sessions for the given utterance, best match first. - * Returns no matches when model scoring is unavailable so callers safely - * create a new session instead of guessing from lexical overlap. - */ - route(request: ISessionRouteRequest, token: CancellationToken): Promise; -} - -// --- Prompt + parsing helpers (pure; reused by any scoring backend) --- - -/** A provider-agnostic chat message used to prompt the scoring model. */ -export interface ISessionRouterMessage { - readonly role: 'system' | 'user'; - readonly content: string; -} - -/** - * Upper bound on any single free-text field embedded in the router prompt, so - * one verbose session (e.g. a long response) can't dominate or blow the prompt. - */ -export const ROUTER_FIELD_CLIP_LENGTH = 240; - -/** Collapse whitespace and clip a free-text field for embedding in the prompt. */ -function clip(text: string, max: number = ROUTER_FIELD_CLIP_LENGTH): string { - const normalized = text.replace(/\s+/g, ' ').trim(); - return normalized.length > max ? `${normalized.slice(0, max)}...` : normalized; -} - -/** - * Build the chat messages sent to the scoring model. Kept pure and exported so - * the same prompt can back a renderer language-model request, a CAPI utility - * completion, or a local model without divergence. - */ -export function buildRouterMessages(request: ISessionRouteRequest): ISessionRouterMessage[] { - const sessionLines = request.sessions.map(session => { - const parts = [`id=${session.sessionId}`, `name=${JSON.stringify(session.label)}`]; - if (session.repo) { parts.push(`repo=${session.repo}`); } - if (session.cwd) { parts.push(`cwd=${session.cwd}`); } - if (session.status) { parts.push(`status=${session.status}`); } - if (session.description) { parts.push(`summary=${JSON.stringify(clip(session.description))}`); } - if (session.firstRequest) { parts.push(`firstRequest=${JSON.stringify(clip(session.firstRequest))}`); } - if (session.lastRequest) { parts.push(`lastRequest=${JSON.stringify(clip(session.lastRequest))}`); } - if (session.lastResponse) { parts.push(`lastResponse=${JSON.stringify(clip(session.lastResponse))}`); } - return `- ${parts.join(' ')}`; - }).join('\n'); - - const system = [ - 'Decide from the user request whether it is best handled as a continuation of an existing coding session or whether it warrants a new session.', - 'Route to an existing session only when continuing that session preserves useful task context; prefer a new session for a distinct task, even when it is in the same repository.', - 'Each candidate may include a summary plus its first request, most recent request, and most recent response; weigh these more heavily than the name when present.', - 'Score every candidate session from 0 (no match) to 1 (certain match).', - 'Reserve scores above 0.8 for a clear continuation of the same concrete task; shared repository names or generic coding terms are not enough.', - 'When the request could reasonably start a new task, score every existing session at 0.8 or below.', - 'Respond with ONLY a JSON array, sorted by confidence descending, of objects:', - '[{"sessionId": string, "confidence": number, "reason": string}]', - 'Do not include any prose or code fences.' - ].join('\n'); - - const user = `Request: ${JSON.stringify(request.utterance)}\nSessions:\n${sessionLines}`; - - return [ - { role: 'system', content: system }, - { role: 'user', content: user } - ]; -} - -/** - * Parse the scoring model's raw text response into results, keeping only known - * session ids and clamping confidences to [0, 1]. Tolerates code fences and - * surrounding prose by extracting the first JSON array. Returns `undefined` when - * nothing usable can be parsed, signalling callers to fall back. - */ -export function parseRouterResponse(text: string, validSessionIds: ReadonlySet): ISessionRouteResult[] | undefined { - const match = text.match(/\[[\s\S]*\]/); - if (!match) { - return undefined; - } - - let parsed: unknown; - try { - parsed = JSON.parse(match[0]); - } catch { - return undefined; - } - if (!Array.isArray(parsed)) { - return undefined; - } - - const results: ISessionRouteResult[] = []; - const seen = new Set(); - for (const entry of parsed) { - if (!entry || typeof entry !== 'object') { - continue; - } - const record = entry as Record; - const sessionId = record.sessionId; - if (typeof sessionId !== 'string' || !validSessionIds.has(sessionId) || seen.has(sessionId)) { - continue; - } - const rawConfidence = record.confidence; - if (typeof rawConfidence !== 'number' || !isFinite(rawConfidence)) { - continue; - } - const confidence = Math.max(0, Math.min(1, rawConfidence)); - seen.add(sessionId); - results.push({ - sessionId, - confidence, - reason: typeof record.reason === 'string' ? record.reason : undefined - }); - } - - if (!results.length) { - return undefined; - } - results.sort((a, b) => b.confidence - a.confidence); - return results; -} - -/** - * Zero-dependency lexical ranking used only to break equal model scores. - * Token-overlap heuristic over the session's identity/content fields (label, - * repo, cwd, description, and, when enriched, its first/most-recent request and - * most-recent response). - * - * The score is calibrated against the candidate's own metadata rather than the - * raw utterance length: it blends how much of the session's strongest identity - * field the utterance covers (recall, taken as the best match across the fields - * so a strong label match is not diluted by repo or path tokens) with - * how much of the utterance those tokens consume (precision). This keeps an - * obvious label match routable even for long sentences instead of drowning it in - * unrelated utterance tokens. - */ -export function heuristicScore(request: ISessionRouteRequest): ISessionRouteResult[] { - const terms = new Set(tokenize(request.utterance)); - const results = request.sessions.map(session => { - if (!terms.size) { - return { sessionId: session.sessionId, confidence: 0 }; - } - const fields = [session.label, session.repo, session.cwd, session.description, session.firstRequest, session.lastRequest, session.lastResponse].filter(isNonEmpty); - let bestRecall = 0; - const matchedTerms = new Set(); - for (const field of fields) { - const fieldTokens = new Set(tokenize(field)); - if (!fieldTokens.size) { - continue; - } - let fieldHits = 0; - for (const token of fieldTokens) { - if (terms.has(token)) { - fieldHits++; - matchedTerms.add(token); - } - } - bestRecall = Math.max(bestRecall, fieldHits / fieldTokens.size); - } - if (!matchedTerms.size) { - return { sessionId: session.sessionId, confidence: 0 }; - } - const precision = matchedTerms.size / terms.size; - const confidence = 0.75 * bestRecall + 0.25 * precision; - return { sessionId: session.sessionId, confidence }; - }); - results.sort((a, b) => b.confidence - a.confidence); - return results; -} - -function tokenize(text: string): string[] { - return text.toLowerCase().split(/[^a-z0-9]+/).filter(term => term.length > 1 && !ROUTER_STOP_WORDS.has(term)); -} - -function isNonEmpty(value: string | undefined): value is string { - return !!value; -} - -const ROUTER_STOP_WORDS = new Set([ - 'about', 'agent', 'and', 'are', 'can', 'change', 'chat', 'code', 'fix', 'for', 'from', 'have', 'into', 'its', 'make', - 'on', 'please', 'project', 'repo', 'repository', 'session', 'task', 'that', 'the', 'this', 'to', 'update', 'was', 'with', 'work', -]); diff --git a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts index 9eebf39766e7d8..2a790b57e0e6a0 100644 --- a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts @@ -106,7 +106,7 @@ const resolvedPendingToolOccurrences = new Map(); const pendingToolOccurrenceById = new Map(); const pendingToolResolutionVersion = observableValue('pendingToolResolutionVersion', 0); -const MAX_RESOLVED_PENDING_TOOL_OCCURRENCES = 256; +const MAX_RESOLVED_PENDING_TOOL_OCCURRENCES = 200; function isPendingToolState(state: IChatToolInvocation.State): boolean { return state.type === IChatToolInvocation.StateKind.WaitingForConfirmation @@ -159,6 +159,20 @@ function pendingToolOccurrenceId(occurrence: IActivePendingToolOccurrence): stri return `${occurrence.requestId}#${occurrence.token}`; } +function pruneResolvedPendingToolOccurrences(): void { + while (resolvedPendingToolOccurrences.size > MAX_RESOLVED_PENDING_TOOL_OCCURRENCES) { + const oldest = resolvedPendingToolOccurrences.entries().next().value; + if (!oldest) { + return; + } + const [semanticKey, occurrence] = oldest; + resolvedPendingToolOccurrences.delete(semanticKey); + if (occurrence.participants.size === 0 && pendingToolOccurrenceById.get(pendingToolOccurrenceId(occurrence)) === occurrence) { + pendingToolOccurrenceById.delete(pendingToolOccurrenceId(occurrence)); + } + } +} + function resolvePendingToolOccurrence(occurrence: IActivePendingToolOccurrence): void { if (occurrence.resolved) { return; @@ -169,21 +183,11 @@ function resolvePendingToolOccurrence(occurrence: IActivePendingToolOccurrence): } resolvedPendingToolOccurrences.delete(occurrence.semanticKey); resolvedPendingToolOccurrences.set(occurrence.semanticKey, occurrence); - while (resolvedPendingToolOccurrences.size > MAX_RESOLVED_PENDING_TOOL_OCCURRENCES) { - const oldestKey = resolvedPendingToolOccurrences.keys().next().value; - if (oldestKey === undefined) { - break; - } - const oldest = resolvedPendingToolOccurrences.get(oldestKey); - resolvedPendingToolOccurrences.delete(oldestKey); - if (oldest && pendingToolOccurrenceById.get(pendingToolOccurrenceId(oldest)) === oldest) { - pendingToolOccurrenceById.delete(pendingToolOccurrenceId(oldest)); - } - } + pruneResolvedPendingToolOccurrences(); pendingToolResolutionVersion.set(pendingToolResolutionVersion.get() + 1, undefined); } -function pendingToolOccurrence(requestId: string, invocation: IChatToolInvocation, mint: boolean, store?: DisposableStore): IActivePendingToolOccurrence | undefined { +function pendingToolOccurrence(requestId: string, invocation: IChatToolInvocation, mint: boolean, store?: DisposableStore, restoreResolved = false): IActivePendingToolOccurrence | undefined { const semanticKey = pendingToolSemanticKey(requestId, invocation); const current = pendingToolOccurrenceByPart.get(invocation); if (!semanticKey) { @@ -202,8 +206,10 @@ function pendingToolOccurrence(requestId: string, invocation: IChatToolInvocatio releasePendingToolParticipant(invocation, current); } - let occurrence = activePendingToolOccurrences.get(semanticKey) - ?? resolvedPendingToolOccurrences.get(semanticKey); + let occurrence = activePendingToolOccurrences.get(semanticKey); + if (!occurrence && restoreResolved) { + occurrence = resolvedPendingToolOccurrences.get(semanticKey); + } if (!occurrence) { if (!mint) { return undefined; @@ -233,9 +239,11 @@ function pendingToolOccurrence(requestId: string, invocation: IChatToolInvocatio if (trackedOccurrence.participants.size === 0 && activePendingToolOccurrences.get(trackedOccurrence.semanticKey) === trackedOccurrence) { activePendingToolOccurrences.delete(trackedOccurrence.semanticKey); } - if (!trackedOccurrence.resolved - && trackedOccurrence.participants.size === 0 - && pendingToolOccurrenceById.get(pendingToolOccurrenceId(trackedOccurrence)) === trackedOccurrence) { + if ( + trackedOccurrence.participants.size === 0 + && (!trackedOccurrence.resolved || resolvedPendingToolOccurrences.get(trackedOccurrence.semanticKey) !== trackedOccurrence) + && pendingToolOccurrenceById.get(pendingToolOccurrenceId(trackedOccurrence)) === trackedOccurrence + ) { pendingToolOccurrenceById.delete(pendingToolOccurrenceId(trackedOccurrence)); } observer.dispose(); @@ -320,6 +328,16 @@ export function isPendingIdResolved(pendingId: string, reader?: IReader): boolea return pendingToolOccurrenceById.get(pendingId)?.resolved === true; } +/** Restore the retired id for a late rehydrated copy of an already-handled tool approval. */ +export function restoreResolvedPendingId(requestId: string, part: object, store?: DisposableStore): string | undefined { + const invocation = part as Partial; + if (invocation.kind !== 'toolInvocation' || !invocation.state) { + return undefined; + } + const occurrence = pendingToolOccurrence(requestId, invocation as IChatToolInvocation, false, store, true); + return occurrence?.resolved ? pendingToolOccurrenceId(occurrence) : undefined; +} + /** * Resolve the id of an already-published pending part, or `undefined`. * @@ -349,8 +367,6 @@ export interface IVoiceSessionContext { /** Which frontend session surface owns this conversation. */ session_type?: 'agent' | 'chat'; is_active: boolean; - /** Omni routing decision for backend narration of the selected target. */ - omni_route?: 'existing_session' | 'new_session'; agent_state: string; agent_state_detail?: string; confirmation_type?: VoiceConfirmationType; @@ -640,7 +656,7 @@ export interface IVoiceClientService { * because the state field itself didn't change. */ invalidateSessionCache(sessionId: string): void; - sendToolResult(callId: string, result: string | IVoiceDispatchResult, codingSessionId?: string): void; + sendToolResult(callId: string, result: string | IVoiceDispatchResult): void; /** Report that one correlated checkpoint playback attempt finished locally. */ sendNarrationPlaybackComplete(codingSessionId: string, narrationId: string, playbackId: string): void; /** @@ -657,7 +673,7 @@ export interface IVoiceClientService { * backend's mirror has caught up. The id is deliberately *not* folded into * `text`, which every dedup and retry-reuse guard keys on. */ - requestNarration(codingSessionId: string, kind: VoiceNarrationKind, text: string, narrationId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata, confirmationType?: VoiceConfirmationType, pending?: { pendingId: string }, prepareToReceiveAudio?: () => void): string | undefined; + requestNarration(codingSessionId: string, kind: VoiceNarrationKind, text: string, narrationId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata, confirmationType?: VoiceConfirmationType, pending?: { pendingId: string }): string | undefined; /** * Notify the backend of a session state transition. * diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts index 73e35cec383a6d..bc6f431301624a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatAccessibilityHelp.test.ts @@ -100,17 +100,6 @@ suite('Chat Accessibility Help', () => { }); }); - test('does not describe the Agents-only floating input window in panel chat', () => { - const keybindingService = { - lookupKeybindings: () => [], - } as unknown as IKeybindingService; - - assert.strictEqual( - getAccessibilityHelpText('panelChat', keybindingService, true).includes('floating chat input window'), - false, - ); - }); - test('only describes spoken agent progress in agent mode', () => { const keybindingService = { lookupKeybindings: () => [], @@ -136,14 +125,12 @@ suite('Chat Accessibility Help', () => { editsView: getAccessibilityHelpText('editsView', keybindingService, true).includes(''), quickChat: getAccessibilityHelpText('quickChat', keybindingService, true).includes(''), inlineChat: getAccessibilityHelpText('inlineChat', keybindingService, true).includes(''), - chatInputWindow: getAccessibilityHelpText('chatInputWindow', keybindingService, true).includes(''), }, { panelChat: true, agentView: true, editsView: true, quickChat: false, inlineChat: false, - chatInputWindow: false, }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatContext.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatContext.test.ts index 9a2a3c9df23630..cca05f5f901add 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatContext.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatContext.test.ts @@ -4,10 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { extUri } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { getSessionWorkspaceName, isSameSessionWorkspace, shouldShowOpenEditorsContext } from '../../../browser/actions/chatContext.js'; +import { shouldShowOpenEditorsContext } from '../../../browser/actions/chatContext.js'; import { IChatWidget } from '../../../browser/chat.js'; function widget(overrides: Partial>): Pick { @@ -55,46 +54,4 @@ suite('ChatContext', () => { false ); }); - - test('matches session workspaces by repository before cwd', () => { - assert.deepStrictEqual({ - sameFolder: isSameSessionWorkspace( - { cwd: '/Users/megan/repo/', repo: 'microsoft/vscode' }, - { cwd: '/users/megan/repo', repo: 'microsoft/vscode' }, - ), - sameRepositoryWorktree: isSameSessionWorkspace( - { cwd: '/Users/megan/repo', repo: 'microsoft/vscode' }, - { cwd: '/Users/megan/repo-worktree', repo: 'microsoft/vscode' }, - ), - caseInsensitiveRepository: isSameSessionWorkspace( - { repo: 'Microsoft/VSCode' }, - { repo: 'microsoft/vscode' }, - ), - differentRepository: isSameSessionWorkspace( - { cwd: '/Users/megan/repo', repo: 'microsoft/vscode' }, - { cwd: '/Users/megan/repo', repo: 'microsoft/typescript' }, - ), - caseSensitiveCwd: isSameSessionWorkspace( - { cwd: '/work/Foo' }, - { cwd: '/work/foo' }, - extUri, - ), - }, { - sameFolder: true, - sameRepositoryWorktree: true, - caseInsensitiveRepository: true, - differentRepository: false, - caseSensitiveCwd: false, - }); - }); - - test('labels a session workspace by repository or folder name', () => { - assert.deepStrictEqual({ - repository: getSessionWorkspaceName({ repo: 'microsoft/vscode', cwd: '/Users/megan/repo-worktree' }), - folder: getSessionWorkspaceName({ cwd: '/Users/megan/Repos/typescript/' }), - }, { - repository: 'vscode', - folder: 'typescript', - }); - }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatExecuteActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatExecuteActions.test.ts index 789a0b04314db2..6f6da4d370d660 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatExecuteActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatExecuteActions.test.ts @@ -17,7 +17,7 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { IsSessionsWindowContext } from '../../../../../common/contextkeys.js'; import { type IChatAcceptInputOptions, IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; -import { ChatEditingSessionSubmitAction, ChatSubmitAction, ExecuteHandoffActionId, GetHandoffsActionId, OpenModelPickerAction, registerChatExecuteActions } from '../../../browser/actions/chatExecuteActions.js'; +import { ChatSubmitAction, ExecuteHandoffActionId, GetHandoffsActionId, OpenModelPickerAction, registerChatExecuteActions } from '../../../browser/actions/chatExecuteActions.js'; import { AgentSessionProviders } from '../../../browser/agentSessions/agentSessions.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; @@ -478,31 +478,4 @@ suite('ChatSubmitAction', () => { assert.deepStrictEqual(acceptedOptions, { cancelCurrentRequest: true }); }); - - test('shows pending action while submission awaits dispatch', () => { - const items = MenuRegistry.getMenuItems(MenuId.ChatExecute) - .filter((candidate): candidate is IMenuItem => isIMenuItem(candidate)); - const pendingItem = items.find(item => item.command.id === 'workbench.action.chat.submitPending'); - const sendItem = items.find(item => item.command.id === ChatEditingSessionSubmitAction.ID); - assert.ok(pendingItem?.when); - assert.ok(sendItem?.when); - - const context = { - getValue: (key: string) => ({ - [ChatContextKeys.hasActiveRequest.key]: false, - [ChatContextKeys.chatModeKind.key]: ChatModeKind.Agent, - [ChatContextKeys.withinEditSessionDiff.key]: false, - [ChatContextKeys.inputSubmitPending.key]: true, - [ChatContextKeys.inputRouting.key]: false, - })[key] as T, - }; - - assert.deepStrictEqual({ - pending: pendingItem.when.evaluate(context), - send: sendItem.when.evaluate(context), - }, { - pending: true, - send: false, - }); - }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/sessionRouter/chatSessionRoutingController.test.ts b/src/vs/workbench/contrib/chat/test/browser/sessionRouter/chatSessionRoutingController.test.ts deleted file mode 100644 index 63f6189baf92c5..00000000000000 --- a/src/vs/workbench/contrib/chat/test/browser/sessionRouter/chatSessionRoutingController.test.ts +++ /dev/null @@ -1,1135 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import sinon from 'sinon'; -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { AnchorPosition } from '../../../../../../base/common/layout.js'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../../../platform/actionWidget/browser/actionList.js'; -import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; -import { ITabbedActionListShowOptions } from '../../../../../../platform/actionWidget/browser/tabbedActionListWidget.js'; -import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; -import { AgentSessionProviders } from '../../../browser/agentSessions/agentSessions.js'; -import { ChatSessionRoutingController, IChatSessionRoutingHost } from '../../../browser/sessionRouter/chatSessionRoutingController.js'; -import { ChatRequestQueueKind, ChatSendResult, IChatService } from '../../../common/chatService/chatService.js'; -import { ChatModeKind } from '../../../common/constants.js'; -import { IChatSessionRoutingProvider, IChatSessionRoutingWorkspace, IChatSessionRoutingWorkspaceBrowseAction, IRoutableSession } from '../../../common/sessionRouter.js'; - -suite('ChatSessionRoutingController', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - teardown(() => sinon.restore()); - - test('shows the selected folder and folder picker for multi-root new sessions', async () => { - const clock = sinon.useFakeTimers(); - const vscode = folder('vscode', '/work/vscode', 0); - const docs = folder('docs', '/work/docs', 1); - const container = document.createElement('div'); - document.body.appendChild(container); - let submitted = false; - type FolderPickerItem = - | { readonly id: string; readonly kind: 'workspace'; readonly folder: IWorkspaceFolder } - | { readonly id: 'choose-folder'; readonly kind: 'choose' }; - let pickerItems: readonly IActionListItem[] | undefined; - let pickerDelegate: IActionListDelegate | undefined; - let pickerAnchor: HTMLElement | undefined; - let pickerContainer: HTMLElement | undefined; - let pickerOptions: IActionListOptions | undefined; - const pickerVisibility: boolean[] = []; - let folderDialogDefault: URI | undefined; - const actionWidgetService = { - show: ( - _user: string, - _supportsPreview: boolean, - items: readonly IActionListItem[], - delegate: IActionListDelegate, - anchor: HTMLElement, - actionWidgetContainer: HTMLElement | undefined, - _actionBarActions: undefined, - _accessibilityProvider: unknown, - listOptions: IActionListOptions, - ) => { - pickerItems = items as readonly IActionListItem[]; - pickerDelegate = delegate as unknown as IActionListDelegate; - pickerAnchor = anchor; - pickerContainer = actionWidgetContainer; - pickerOptions = listOptions; - }, - hide: () => pickerDelegate?.onHide(), - } as unknown as IActionWidgetService; - const host = { - widget: { - inputEditor: { - onDidChangeModelContent: Event.None, - getValue: () => 'create a new session to update docs', - }, - attachmentModel: { - onDidChange: Event.None, - attachments: [], - }, - input: { setSubmitPending: () => { } }, - getSelectedModelRequestOptions: () => ({ userSelectedModelId: 'copilot/claude-opus-4.6' }), - getModeRequestOptions: () => ({}), - }, - getOwnSessionResource: () => undefined, - getNewSessionTarget: () => AgentSessionProviders.AgentHostCopilot, - getSelectedModelLabel: () => 'Claude Opus 4.6', - onDidChangeActionWidgetVisibility: (visible: boolean) => pickerVisibility.push(visible), - getActionWidgetContainer: () => container, - getActionWidgetAnchor: (anchor: HTMLElement) => anchor, - getActionWidgetAnchorPosition: () => AnchorPosition.BELOW, - pickFolder: async (defaultUri: URI | undefined) => { - folderDialogDefault = defaultUri; - return URI.file('/outside/external-project'); - }, - placeBadge: (badge: HTMLElement) => container.appendChild(badge), - } as unknown as IChatSessionRoutingHost; - const workspaceContextService = { - getWorkspace: () => ({ folders: [vscode, docs] }), - getWorkspaceFolder: (resource: URI) => [vscode, docs].find(candidate => candidate.uri.toString() === resource.toString()), - } as IWorkspaceContextService; - const controller = new ChatSessionRoutingController( - host, - 'test', - { sendRequest: async () => { submitted = true; return { kind: 'rejected' }; } } as unknown as IChatService, - undefined!, - undefined!, - undefined!, - undefined!, - { info: () => { }, warn: () => { } } as never, - workspaceContextService, - { getDefaultFolder: () => undefined, setFolder: () => { } } as never, - actionWidgetService, - { createInstance: () => ({ dispose: () => { } }) } as never, - ); - - await controller.handleSubmit('create a new session to update docs', undefined!); - const label = container.querySelector('.chat-routing-badge-name'); - const model = container.querySelector('.chat-routing-badge-score'); - const changeFolder = container.querySelector('.chat-routing-badge-folder-action'); - const countdown = container.querySelector('.chat-routing-badge-countdown'); - assert.deepStrictEqual({ - submitted, - label: label?.textContent, - model: model?.textContent, - changeFolder: changeFolder?.textContent, - countdown: countdown?.textContent, - hasPopup: changeFolder?.getAttribute('aria-haspopup'), - }, { - submitted: false, - label: 'New session in docs', - model: 'Claude Opus 4.6', - changeFolder: 'docs', - countdown: 'sending in 5s', - hasPopup: 'menu', - }); - - try { - clock.tick(3_000); - assert.strictEqual(countdown?.textContent, 'sending in 2s'); - changeFolder?.click(); - await Promise.resolve(); - await Promise.resolve(); - assert.strictEqual(countdown?.textContent, 'waiting for you'); - assert.strictEqual(changeFolder?.getAttribute('aria-expanded'), 'true'); - assert.strictEqual(pickerAnchor, changeFolder); - assert.strictEqual(pickerContainer, container); - assert.strictEqual(pickerOptions?.showFilter, true); - assert.strictEqual(pickerOptions?.filterPlaceholder, 'Search folders'); - assert.strictEqual(pickerOptions?.focusFilterOnOpen, true); - assert.strictEqual(pickerOptions?.anchorPosition, AnchorPosition.BELOW); - assert.deepStrictEqual(pickerItems?.map(item => item.label), ['vscode', 'docs', 'Choose Folder…']); - clock.tick(5_000); - assert.strictEqual(countdown?.textContent, 'waiting for you'); - pickerDelegate?.onSelect(pickerItems![0].item!); - await clock.tickAsync(0); - assert.deepStrictEqual({ - label: label?.textContent, - changeFolder: changeFolder?.textContent, - expanded: changeFolder?.getAttribute('aria-expanded'), - countdown: countdown?.textContent, - pickerVisibility, - }, { - label: 'New session in vscode', - changeFolder: 'vscode', - expanded: 'false', - countdown: 'sending in 2s', - pickerVisibility: [true, false], - }); - changeFolder?.click(); - await Promise.resolve(); - await Promise.resolve(); - const chooseFolder = pickerItems?.find(item => item.item?.kind === 'choose')?.item; - assert.ok(chooseFolder); - pickerDelegate?.onSelect(chooseFolder); - await clock.tickAsync(0); - assert.deepStrictEqual({ - label: label?.textContent, - changeFolder: changeFolder?.textContent, - folderDialogDefault: folderDialogDefault?.toString(), - countdown: countdown?.textContent, - pickerVisibility, - }, { - label: 'New session in external-project', - changeFolder: 'external-project', - folderDialogDefault: vscode.uri.toString(), - countdown: 'sending in 2s', - pickerVisibility: [true, false, true, false], - }); - clock.tick(1_000); - assert.strictEqual(countdown?.textContent, 'sending in 1s'); - } finally { - controller.dispose(); - container.remove(); - clock.restore(); - } - }); - - test('shows the provider workspace picker with an empty workbench and dispatches the selected provider', async () => { - const clock = sinon.useFakeTimers(); - const container = document.createElement('div'); - document.body.appendChild(container); - const localWorkspace: IChatSessionRoutingWorkspace = { - uri: URI.file('/work/local'), - providerId: 'local', - group: 'Local', - label: 'local', - description: '~/work', - icon: { id: 'folder' }, - }; - const githubWorkspace: IChatSessionRoutingWorkspace = { - uri: URI.parse('github-remote-file://github/microsoft/vscode'), - providerId: 'github', - group: 'GitHub', - label: 'microsoft/vscode', - description: 'GitHub', - icon: { id: 'github' }, - }; - const browseAction: IChatSessionRoutingWorkspaceBrowseAction = { - id: 'provider:github:0', - providerId: 'github', - group: 'GitHub', - label: 'Select...', - icon: { id: 'folder-opened' }, - }; - type TestFolderPickerItem = - | { readonly kind: 'providerWorkspace'; readonly workspace: IChatSessionRoutingWorkspace } - | { readonly kind: 'providerBrowse'; readonly action: IChatSessionRoutingWorkspaceBrowseAction }; - let tabbedOptions: ITabbedActionListShowOptions | undefined; - let tabbedVisible = false; - const tabbedWidget = { - get isVisible() { return tabbedVisible; }, - show: (options: ITabbedActionListShowOptions) => { - tabbedOptions = options; - tabbedVisible = true; - }, - hide: () => { - if (!tabbedVisible) { - return; - } - tabbedVisible = false; - tabbedOptions?.delegate.onHide(); - }, - dispose: () => { }, - }; - let input = 'create a new session to update docs'; - const pickerVisibility: boolean[] = []; - const pickerErrors: string[] = []; - const selectedProviders: string[] = []; - let dispatchedTarget: { readonly folder?: URI; readonly providerId?: string } | undefined; - const routingProvider: IChatSessionRoutingProvider = { - getCandidateSessions: () => [], - getNewSessionWorkspaceCatalog: () => ({ - groups: [{ id: 'Local' }, { id: 'GitHub' }, { id: 'Remote' }], - workspaces: [localWorkspace, githubWorkspace], - browseActions: [browseAction, { - id: 'provider:remote:0', - providerId: 'remote', - group: 'Remote', - label: 'Select...', - icon: { id: 'remote' }, - }], - defaultWorkspace: localWorkspace, - }), - selectNewSessionWorkspace: workspace => { - selectedProviders.push(workspace.providerId); - }, - browseNewSessionWorkspace: async () => undefined, - resolveSessionResource: () => undefined, - dispatchToSession: async () => ({ status: 'rejected' }), - dispatchToNewSession: async target => { - dispatchedTarget = target; - return { status: 'sent', resource: URI.parse('session:/created') }; - }, - revealSession: async () => { }, - }; - const host = { - widget: { - inputEditor: { - onDidChangeModelContent: Event.None, - getValue: () => input, - setValue: (value: string) => input = value, - }, - attachmentModel: { - onDidChange: Event.None, - attachments: [], - clear: () => { }, - }, - input: { setSubmitPending: () => { } }, - getSelectedModelRequestOptions: () => ({}), - getModeRequestOptions: () => ({}), - }, - getOwnSessionResource: () => undefined, - getRoutingProvider: () => routingProvider, - onDidChangeActionWidgetVisibility: (visible: boolean) => pickerVisibility.push(visible), - getActionWidgetContainer: () => container, - getActionWidgetAnchor: (anchor: HTMLElement) => anchor, - getActionWidgetAnchorPosition: () => AnchorPosition.BELOW, - placeBadge: (badge: HTMLElement) => container.appendChild(badge), - } as unknown as IChatSessionRoutingHost; - const controller = new ChatSessionRoutingController( - host, - 'test', - { getSession: () => undefined } as unknown as IChatService, - undefined!, - undefined!, - undefined!, - undefined!, - { info: () => { }, warn: () => { }, error: (message: string, error: Error) => pickerErrors.push(`${message}: ${error.message}`) } as never, - { - getWorkspace: () => ({ folders: [] }), - getWorkspaceFolder: () => undefined, - } as unknown as IWorkspaceContextService, - { getDefaultFolder: () => undefined, setFolder: () => { } } as never, - { hide: () => { } } as unknown as IActionWidgetService, - { createInstance: () => tabbedWidget } as never, - ); - - try { - await controller.handleSubmit(input, ChatModeKind.Agent); - const label = container.querySelector('.chat-routing-badge-name'); - const changeFolder = container.querySelector('.chat-routing-badge-folder-action'); - assert.deepStrictEqual({ - label: label?.textContent, - changeFolder: changeFolder?.textContent, - }, { - label: 'New session in local', - changeFolder: 'local', - }); - - changeFolder?.click(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - assert.deepStrictEqual({ - tabs: tabbedOptions?.tabs.map(tab => tab.id), - githubItems: tabbedOptions?.createActionList('GitHub').items.map(item => item.label), - pickerVisibility, - pickerErrors, - }, { - tabs: ['Local', 'GitHub', 'Remote'], - githubItems: ['microsoft/vscode', '', 'Select...'], - pickerVisibility: [true], - pickerErrors: [], - }); - - const githubItem = tabbedOptions?.createActionList('GitHub').items - .find(item => item.item?.kind === 'providerWorkspace')?.item; - assert.ok(githubItem); - tabbedOptions?.delegate.onSelect(githubItem!); - await clock.tickAsync(0); - assert.deepStrictEqual({ - label: label?.textContent, - changeFolder: changeFolder?.textContent, - selectedProviders, - pickerVisibility, - focused: document.activeElement === changeFolder, - }, { - label: 'New session in microsoft/vscode', - changeFolder: 'microsoft/vscode', - selectedProviders: ['github'], - pickerVisibility: [true, false], - focused: true, - }); - - container.querySelector('.chat-routing-badge-row')?.click(); - await Promise.resolve(); - await Promise.resolve(); - assert.deepStrictEqual({ - folder: dispatchedTarget?.folder?.toString(), - providerId: dispatchedTarget?.providerId, - }, { - folder: githubWorkspace.uri.toString(), - providerId: 'github', - }); - } finally { - controller.dispose(); - container.remove(); - clock.restore(); - } - }); - - test('uses provider workspace labels for mentions and the provider default', () => { - const localWorkspace: IChatSessionRoutingWorkspace = { - uri: URI.file('/work/local'), - providerId: 'local', - group: 'Local', - label: 'local', - }; - const githubWorkspace: IChatSessionRoutingWorkspace = { - uri: URI.parse('github-remote-file://github/microsoft/vscode'), - providerId: 'github', - group: 'GitHub', - label: 'microsoft/vscode', - }; - const controller = new ChatSessionRoutingController( - {} as IChatSessionRoutingHost, - 'test', - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - { info: () => { } } as never, - { - getWorkspace: () => ({ folders: [] }), - getWorkspaceFolder: () => undefined, - } as unknown as IWorkspaceContextService, - { getDefaultFolder: () => undefined } as never, - undefined!, - undefined!, - ); - Reflect.set(controller, '_workspaceCatalog', { - groups: [{ id: 'Local' }, { id: 'GitHub' }], - workspaces: [localWorkspace, githubWorkspace], - browseActions: [], - defaultWorkspace: localWorkspace, - }); - const resolveTarget = Reflect.get(controller, '_resolveNewSessionTarget') as ( - utterance: string, - attachments: undefined, - results: readonly [], - candidates: readonly [], - ) => { folder?: URI; providerId?: string; label: string }; - - assert.deepStrictEqual([ - resolveTarget.call(controller, 'update microsoft/vscode', undefined, [], []), - resolveTarget.call(controller, 'start something new', undefined, [], []), - ].map(target => ({ - folder: target.folder?.toString(), - providerId: target.providerId, - label: target.label, - })), [ - { - folder: githubWorkspace.uri.toString(), - providerId: 'github', - label: 'New session in microsoft/vscode', - }, - { - folder: localWorkspace.uri.toString(), - providerId: 'local', - label: 'New session in local', - }, - ]); - controller.dispose(); - }); - - test('returns the stable request id for an immediately sent route', async () => { - const resource = URI.parse('agent-host-copilotcli:/untitled-route'); - const chatService = { - sendRequest: async (): Promise => ({ - kind: 'sent', - newSessionResource: URI.parse('agent-host-copilotcli:/durable-route'), - data: { - agent: undefined!, - responseCreatedPromise: Promise.resolve({ requestId: 'stable-request-id' } as never), - responseCompletePromise: Promise.resolve(), - }, - }), - } as unknown as IChatService; - const controller = new ChatSessionRoutingController( - {} as IChatSessionRoutingHost, - 'test', - chatService, - undefined!, - undefined!, - undefined!, - undefined!, - { info: () => { }, warn: () => { } } as never, - undefined!, - { setFolder: () => { } } as never, - undefined!, - undefined!, - ); - const sendRequest = Reflect.get(controller, '_sendRequest') as (resource: URI, utterance: string, options: object) => Promise<{ status: string; resource?: URI; requestId?: string }>; - - const result = await sendRequest.call(controller, resource, 'Run the build', {}); - - assert.deepStrictEqual({ - status: result.status, - resource: result.resource?.toString(), - requestId: result.requestId, - }, { - status: 'sent', - resource: 'agent-host-copilotcli:/durable-route', - requestId: 'stable-request-id', - }); - controller.dispose(); - }); - - test('dismisses routed pending input with the delivery badge', () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const resource = URI.parse('agent-host-copilotcli:/dismissed-route'); - let dismissed: { resource: string; requestId: string | undefined } | undefined; - const controller = new ChatSessionRoutingController( - { - placeBadge: (badge: HTMLElement) => container.appendChild(badge), - onDidDismissRoute: (dismissedResource: URI, requestId: string | undefined) => { - dismissed = { resource: dismissedResource.toString(), requestId }; - }, - } as unknown as IChatSessionRoutingHost, - 'test', - { getSession: () => undefined } as unknown as IChatService, - { model: { getSession: () => undefined, onDidChangeSessions: Event.None } } as never, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - ); - const showDeliveryConfirmation = Reflect.get(controller, '_showDeliveryConfirmation') as ( - label: string, - result: { status: 'sent'; resource: URI; requestId: string }, - ) => void; - - showDeliveryConfirmation.call(controller, 'Session', { status: 'sent', resource, requestId: 'request-1' }); - container.querySelectorAll('.chat-routing-badge-action')[1]?.click(); - - assert.deepStrictEqual({ - dismissed, - badgeConnected: !!container.querySelector('.chat-routing-badge'), - }, { - dismissed: { resource: resource.toString(), requestId: 'request-1' }, - badgeConnected: false, - }); - - controller.dispose(); - container.remove(); - }); - - test('uses the provider reveal operation for delivery Open', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - let localOpenCount = 0; - let providerOpenCount = 0; - const controller = new ChatSessionRoutingController( - { - placeBadge: (badge: HTMLElement) => container.appendChild(badge), - } as unknown as IChatSessionRoutingHost, - 'test', - { getSession: () => undefined } as unknown as IChatService, - undefined!, - undefined!, - undefined!, - { openSession: () => localOpenCount++ } as never, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - ); - const showDeliveryConfirmation = Reflect.get(controller, '_showDeliveryConfirmation') as ( - label: string, - result: { status: 'sent'; resource: URI; reveal: () => Promise }, - ) => void; - - showDeliveryConfirmation.call(controller, 'Provider session', { - status: 'sent', - resource: URI.parse('agent-host-copilotcli:/provider-delivery'), - reveal: async () => { providerOpenCount++; }, - }); - const actions = [...container.querySelectorAll('.chat-routing-badge-action')]; - actions[0]?.click(); - await Promise.resolve(); - - assert.deepStrictEqual({ - actions: actions.map(action => action.textContent), - localOpenCount, - providerOpenCount, - badgeConnected: !!container.querySelector('.chat-routing-badge'), - }, { - actions: ['Open', 'Dismiss'], - localOpenCount: 0, - providerOpenCount: 1, - badgeConnected: true, - }); - - controller.dispose(); - container.remove(); - }); - - test('updates a provider delivery with its title and completed response', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const resource = URI.parse('session:/provider-delivery'); - const sessionsChanged = new Emitter(); - let snapshot: IRoutableSession = { - sessionId: 'provider:session', - label: 'New session', - status: 'working', - lastActivity: 1, - }; - const provider = { - watchSession: (_resource: URI, listener: () => void) => sessionsChanged.event(listener), - getSessionSnapshot: async () => snapshot, - } as unknown as IChatSessionRoutingProvider; - const controller = new ChatSessionRoutingController( - { - placeBadge: (badge: HTMLElement) => container.appendChild(badge), - getRoutingProvider: () => provider, - } as unknown as IChatSessionRoutingHost, - 'test', - { getSession: () => undefined } as unknown as IChatService, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - ); - const showDeliveryConfirmation = Reflect.get(controller, '_showDeliveryConfirmation') as ( - label: string, - result: { status: 'sent'; resource: URI; reveal: () => Promise }, - ) => void; - - showDeliveryConfirmation.call(controller, 'New session', { - status: 'sent', - resource, - reveal: async () => { }, - }); - await Promise.resolve(); - assert.strictEqual(container.querySelector('.chat-routing-badge-label')?.textContent, 'In progress: New session'); - snapshot = { - sessionId: 'provider:session', - label: 'Update routing badge', - status: 'idle', - lastActivity: 2, - lastResponse: 'Done. I created [megan.md](file:///megan.md).', - }; - sessionsChanged.fire(); - await Promise.resolve(); - - assert.deepStrictEqual({ - label: container.querySelector('.chat-routing-badge-label')?.textContent, - link: container.querySelector('.chat-routing-badge-response-preview a')?.textContent, - }, { - label: 'Completed update routing badge:Done. I created megan.md.', - link: 'megan.md', - }); - const clearCompletedDeliveries = Reflect.get(controller, '_clearCompletedDeliveryConfirmations') as () => void; - clearCompletedDeliveries.call(controller); - assert.strictEqual(container.querySelector('.chat-routing-badge'), null); - - controller.dispose(); - sessionsChanged.dispose(); - container.remove(); - }); - - test('flattens a heading response preview and indicates more content', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const resource = URI.parse('session:/provider-heading'); - const sessionsChanged = new Emitter(); - let snapshot: IRoutableSession = { - sessionId: 'provider:session', - label: 'New session', - status: 'working', - lastActivity: 1, - }; - const provider = { - watchSession: (_resource: URI, listener: () => void) => sessionsChanged.event(listener), - getSessionSnapshot: async () => snapshot, - } as unknown as IChatSessionRoutingProvider; - const controller = new ChatSessionRoutingController( - { - placeBadge: (badge: HTMLElement) => container.appendChild(badge), - getRoutingProvider: () => provider, - } as unknown as IChatSessionRoutingHost, - 'test', - { getSession: () => undefined } as unknown as IChatService, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - ); - const showDeliveryConfirmation = Reflect.get(controller, '_showDeliveryConfirmation') as ( - label: string, - result: { status: 'sent'; resource: URI; reveal: () => Promise }, - ) => void; - - showDeliveryConfirmation.call(controller, 'New session', { - status: 'sent', - resource, - reveal: async () => { }, - }); - await Promise.resolve(); - snapshot = { - sessionId: 'provider:session', - label: 'Session activity', - status: 'idle', - lastActivity: 2, - lastResponse: '# Current session activity\n\nThree sessions are running.', - }; - sessionsChanged.fire(); - await Promise.resolve(); - - assert.deepStrictEqual({ - label: container.querySelector('.chat-routing-badge-label')?.textContent, - heading: container.querySelector('.chat-routing-badge-response-preview h1'), - }, { - label: 'Completed session activity:Current session activity\u2026', - heading: null, - }); - - controller.dispose(); - sessionsChanged.dispose(); - container.remove(); - }); - - test('keeps a bare-URL response preview linking to the correct target', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const resource = URI.parse('session:/provider-bare-url'); - const sessionsChanged = new Emitter(); - let snapshot: IRoutableSession = { - sessionId: 'provider:session', - label: 'New session', - status: 'working', - lastActivity: 1, - }; - const provider = { - watchSession: (_resource: URI, listener: () => void) => sessionsChanged.event(listener), - getSessionSnapshot: async () => snapshot, - } as unknown as IChatSessionRoutingProvider; - const controller = new ChatSessionRoutingController( - { - placeBadge: (badge: HTMLElement) => container.appendChild(badge), - getRoutingProvider: () => provider, - } as unknown as IChatSessionRoutingHost, - 'test', - { getSession: () => undefined } as unknown as IChatService, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - ); - const showDeliveryConfirmation = Reflect.get(controller, '_showDeliveryConfirmation') as ( - label: string, - result: { status: 'sent'; resource: URI; reveal: () => Promise }, - ) => void; - - showDeliveryConfirmation.call(controller, 'New session', { - status: 'sent', - resource, - reveal: async () => { }, - }); - await Promise.resolve(); - snapshot = { - sessionId: 'provider:session', - label: 'Bare URL', - status: 'idle', - lastActivity: 2, - lastResponse: 'https://example.com/page\n\nMore details follow.', - }; - sessionsChanged.fire(); - await Promise.resolve(); - - assert.deepStrictEqual({ - label: container.querySelector('.chat-routing-badge-label')?.textContent, - href: container.querySelector('.chat-routing-badge-response-preview a')?.dataset.href, - }, { - label: 'Completed bare URL:https://example.com/page\u2026', - href: 'https://example.com/page', - }); - - controller.dispose(); - sessionsChanged.dispose(); - container.remove(); - }); - - test('keeps unresolved delivery rows when another request starts', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const controller = new ChatSessionRoutingController( - { - placeBadge: (badge: HTMLElement) => container.appendChild(badge), - } as unknown as IChatSessionRoutingHost, - 'test', - { getSession: () => undefined } as unknown as IChatService, - { model: { getSession: () => undefined, onDidChangeSessions: Event.None } } as never, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - ); - const showDeliveryConfirmation = Reflect.get(controller, '_showDeliveryConfirmation') as ( - label: string, - result: { status: 'sent'; resource: URI }, - ) => void; - - showDeliveryConfirmation.call(controller, 'First session', { status: 'sent', resource: URI.parse('test:/first') }); - showDeliveryConfirmation.call(controller, 'Second session', { status: 'sent', resource: URI.parse('test:/second') }); - - assert.deepStrictEqual( - [...container.querySelectorAll('.chat-routing-badge-label')].map(element => element.textContent), - ['Sent to First session', 'Sent to Second session'] - ); - - controller.dispose(); - container.remove(); - }); - - test('keeps an existing session reference until a queued route completes', async () => { - const resource = URI.parse('agent-host-copilotcli:/existing-route'); - let resolveQueued!: (result: ChatSendResult) => void; - const queued = new Promise(resolve => resolveQueued = resolve); - let disposed = false; - let sentOptions: { userSelectedModelId?: string; agentIdSilent?: string; queue?: ChatRequestQueueKind } | undefined; - const chatService = { - acquireOrLoadSession: async () => ({ - object: { sessionResource: resource }, - dispose: () => disposed = true, - }), - sendRequest: async (_resource: URI, _message: string, options: typeof sentOptions): Promise => { - sentOptions = options; - return { kind: 'queued', requestId: 'queued-request', deferred: queued }; - }, - } as unknown as IChatService; - const host = { - widget: { - inputEditor: { getValue: () => 'different draft', setValue: () => { } }, - attachmentModel: { attachments: [], clear: () => { } }, - }, - } as unknown as IChatSessionRoutingHost; - const controller = new ChatSessionRoutingController( - host, - 'test', - chatService, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - undefined!, - ); - const dispatch = Reflect.get(controller, '_dispatchToSession') as ( - sessionId: string, - input: string, - attachmentIds: readonly string[], - utterance: string, - options: object, - token: CancellationToken, - notifyRoute: boolean, - ) => Promise<{ completion?: Promise }>; - - const result = await dispatch.call(controller, resource.toString(), 'run', [], 'run', { userSelectedModelId: 'picked-model' }, CancellationToken.None, false); - - assert.strictEqual(disposed, false); - assert.strictEqual(sentOptions?.userSelectedModelId, undefined); - assert.strictEqual(sentOptions?.agentIdSilent, AgentSessionProviders.AgentHostCopilot); - assert.strictEqual(sentOptions?.queue, ChatRequestQueueKind.Queued); - resolveQueued({ - kind: 'sent', - data: { - agent: undefined!, - responseCreatedPromise: Promise.resolve({ requestId: 'queued-request' } as never), - responseCompletePromise: Promise.resolve(), - }, - }); - await result.completion; - assert.strictEqual(disposed, true); - controller.dispose(); - }); - - test('dispatches new sessions through the routing provider hook', async () => { - const resource = URI.parse('agent-host-copilotcli:/new-route'); - const folder = URI.file('/workspace'); - let dispatched: { folder: URI | undefined; providerId: string | undefined; message: string; modelId: string | undefined } | undefined; - let resolvedRequestId: string | undefined; - let localCreateCount = 0; - const routingProvider: IChatSessionRoutingProvider = { - getCandidateSessions: () => [], - resolveSessionResource: () => undefined, - dispatchToSession: async () => ({ status: 'rejected' }), - dispatchToNewSession: async (target, message, options) => { - dispatched = { folder: target.folder, providerId: target.providerId, message, modelId: options.userSelectedModelId }; - return { status: 'sent', resource }; - }, - revealSession: async () => { }, - }; - const controller = new ChatSessionRoutingController( - { - widget: { - inputEditor: { getValue: () => 'different draft', setValue: () => { } }, - attachmentModel: { attachments: [], clear: () => { } }, - }, - getRoutingProvider: () => routingProvider, - onDidResolveRoute: (_resource: URI | undefined, _kind: 'existing_session' | 'new_session' | undefined, _voice: boolean | undefined, requestId: string | undefined) => { - resolvedRequestId = requestId; - }, - } as unknown as IChatSessionRoutingHost, - 'test', - { - startNewLocalSession: () => { localCreateCount++; return undefined; }, - getSession: () => ({ lastRequest: { id: 'durable-provider-request' } }), - } as unknown as IChatService, - undefined!, - undefined!, - undefined!, - undefined!, - { info: () => { }, warn: () => { } } as never, - undefined!, - { setFolder: () => { } } as never, - undefined!, - undefined!, - ); - const dispatch = Reflect.get(controller, '_dispatchToNewSession') as ( - input: string, - attachmentIds: readonly string[], - utterance: string, - options: object, - token: CancellationToken, - notifyRoute: boolean, - target: { folder: URI; providerId: string }, - ) => Promise<{ status: string; resource?: URI; reveal?: () => Promise }>; - - const result = await dispatch.call(controller, 'run', [], 'run', { userSelectedModelId: 'model' }, CancellationToken.None, true, { folder, providerId: 'provider' }); - - assert.deepStrictEqual({ - dispatched, - localCreateCount, - resolvedRequestId, - result: { status: result.status, resource: result.resource?.toString(), hasReveal: !!result.reveal }, - }, { - dispatched: { folder, providerId: 'provider', message: 'run', modelId: 'model' }, - localCreateCount: 0, - resolvedRequestId: 'durable-provider-request', - result: { status: 'sent', resource: resource.toString(), hasReveal: true }, - }); - controller.dispose(); - }); - - test('uses provider candidates instead of the renderer-local catalog', async () => { - let localResolveCount = 0; - const routingProvider: IChatSessionRoutingProvider = { - getCandidateSessions: () => [ - { sessionId: 'provider:b', label: 'B' }, - { sessionId: 'provider:a', label: 'A' }, - { sessionId: 'provider:a', label: 'Duplicate A' }, - ], - resolveSessionResource: () => undefined, - dispatchToSession: async () => ({ status: 'rejected' }), - dispatchToNewSession: async () => ({ status: 'rejected' }), - revealSession: async () => { }, - }; - const controller = new ChatSessionRoutingController( - { - getOwnSessionResource: () => undefined, - getRoutingProvider: () => routingProvider, - } as unknown as IChatSessionRoutingHost, - 'test', - undefined!, - { - model: { - resolve: async () => { localResolveCount++; }, - sessions: [], - }, - } as never, - { getChatSessionContribution: () => ({ isReadOnly: false }) } as never, - undefined!, - undefined!, - { warn: () => { } } as never, - undefined!, - undefined!, - undefined!, - undefined!, - ); - const collect = Reflect.get(controller, '_collectCandidateSessions') as (token: CancellationToken) => Promise; - - const candidates = await collect.call(controller, CancellationToken.None); - - assert.deepStrictEqual(candidates.map(candidate => candidate.sessionId), [ - 'provider:a', - 'provider:b', - ]); - assert.strictEqual(localResolveCount, 0); - controller.dispose(); - }); - - test('dispatches provider candidates without using renderer-local chat services', async () => { - const providerResource = URI.parse('agent-host-copilotcli:/provider'); - const providerCandidate = { sessionId: 'provider:session', label: 'Provider' }; - let input = 'Run tests'; - let clearedAttachments = false; - let localAcquireCount = 0; - let providerRevealCount = 0; - let dispatched: { candidateId: string; message: string; modelId: string | undefined } | undefined; - const callbacks: string[] = []; - const routingProvider: IChatSessionRoutingProvider = { - getCandidateSessions: () => [providerCandidate], - resolveSessionResource: candidateId => candidateId === providerCandidate.sessionId ? providerResource : undefined, - dispatchToSession: async (candidateId, message, options) => { - dispatched = { candidateId, message, modelId: options.userSelectedModelId }; - return { status: 'sent', resource: providerResource, requestId: 'request-1' }; - }, - dispatchToNewSession: async () => ({ status: 'rejected' }), - revealSession: async () => { providerRevealCount++; }, - }; - const host = { - widget: { - inputEditor: { - getValue: () => input, - setValue: (value: string) => input = value, - }, - attachmentModel: { - attachments: [], - clear: () => clearedAttachments = true, - }, - }, - getOwnSessionResource: () => undefined, - getRoutingProvider: () => routingProvider, - onWillDispatchRoute: () => callbacks.push('will'), - onDidResolveRoute: () => callbacks.push('resolved'), - } as unknown as IChatSessionRoutingHost; - const controller = new ChatSessionRoutingController( - host, - 'test', - { - acquireOrLoadSession: async () => { - localAcquireCount++; - return undefined; - }, - } as unknown as IChatService, - { model: { resolve: async () => { }, sessions: [] } } as never, - undefined!, - undefined!, - undefined!, - { warn: () => { } } as never, - undefined!, - undefined!, - undefined!, - undefined!, - ); - const collect = Reflect.get(controller, '_collectCandidateSessions') as (token: CancellationToken) => Promise; - await collect.call(controller, CancellationToken.None); - const dispatch = Reflect.get(controller, '_dispatchToSession') as ( - sessionId: string, - input: string, - attachmentIds: readonly string[], - utterance: string, - options: object, - token: CancellationToken, - notifyRoute: boolean, - ) => Promise<{ status: string; resource?: URI; reveal?: () => Promise }>; - - const result = await dispatch.call(controller, providerCandidate.sessionId, input, [], 'Run tests', { userSelectedModelId: 'model' }, CancellationToken.None, true); - await result.reveal?.(); - - assert.deepStrictEqual({ - dispatched, - localAcquireCount, - providerRevealCount, - callbacks, - input, - clearedAttachments, - result: { status: result.status, resource: result.resource?.toString() }, - }, { - dispatched: { - candidateId: providerCandidate.sessionId, - message: 'Run tests', - modelId: 'model', - }, - localAcquireCount: 0, - providerRevealCount: 1, - callbacks: ['will', 'resolved'], - input: '', - clearedAttachments: true, - result: { status: 'sent', resource: providerResource.toString() }, - }); - controller.dispose(); - }); - - test('does not send another provider session metadata to the Copilot router', async () => { - const session = (providerType: AgentSessionProviders, path: string) => ({ - resource: URI.from({ scheme: providerType, path }), - providerType, - label: path, - status: undefined, - isArchived: () => false, - }); - const agentSessionsService = { - model: { - resolve: async () => { }, - sessions: [ - session(AgentSessionProviders.AgentHostCopilot, '/copilot'), - session(AgentSessionProviders.AgentHostClaude, '/claude'), - ], - }, - }; - const controller = new ChatSessionRoutingController( - { getOwnSessionResource: () => undefined } as IChatSessionRoutingHost, - 'test', - undefined!, - agentSessionsService as never, - { getChatSessionContribution: () => ({ isReadOnly: false }) } as never, - undefined!, - undefined!, - { info: () => { }, warn: () => { } } as never, - undefined!, - undefined!, - undefined!, - undefined!, - ); - const collect = Reflect.get(controller, '_collectCandidateSessions') as (token: CancellationToken) => Promise; - - const candidates = await collect.call(controller, CancellationToken.None); - - assert.deepStrictEqual(candidates.map(candidate => candidate.sessionId), ['agent-host-copilotcli:/copilot']); - controller.dispose(); - }); -}); - -function folder(name: string, path: string, index: number): IWorkspaceFolder { - const uri = URI.file(path); - return { uri, name, index, toResource: relativePath => URI.joinPath(uri, relativePath) }; -} diff --git a/src/vs/workbench/contrib/chat/test/browser/sessionRouter/chatSessionRoutingHelpers.test.ts b/src/vs/workbench/contrib/chat/test/browser/sessionRouter/chatSessionRoutingHelpers.test.ts deleted file mode 100644 index 171f97f09e8d45..00000000000000 --- a/src/vs/workbench/contrib/chat/test/browser/sessionRouter/chatSessionRoutingHelpers.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { URI } from '../../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; -import { heuristicScore } from '../../../common/sessionRouter.js'; -import { parseExplicitNewSessionRequest, resolveMentionedWorkspaceFolder, resolveNewSessionWorkspaceFolder, resolveSessionWorkspaceFolder, selectBestSessionRoute, selectRouterShortlist } from '../../../browser/sessionRouter/chatSessionRoutingHelpers.js'; - -suite('Chat session routing helpers', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - const vscode = folder('vscode', '/work/vscode', 0); - const docs = folder('vscode-docs', '/work/vscode-docs', 1); - - test('chooses an explicitly mentioned workspace folder', () => { - assert.deepStrictEqual([ - resolveNewSessionWorkspaceFolder('update the vscode-docs API reference', [vscode, docs], [], [], vscode.uri)?.toString(), - resolveNewSessionWorkspaceFolder('update the vscode docs API reference', [vscode, docs], [], [], vscode.uri)?.toString(), - resolveNewSessionWorkspaceFolder('update the VS Code docs API reference', [vscode, docs], [], [], vscode.uri)?.toString(), - resolveNewSessionWorkspaceFolder('update the VSCODE DOCS API reference', [vscode, docs], [], [], vscode.uri)?.toString(), - ], [ - docs.uri.toString(), - docs.uri.toString(), - docs.uri.toString(), - docs.uri.toString(), - ]); - }); - - test('uses a related session working directory when starting a new session', () => { - const result = resolveNewSessionWorkspaceFolder( - 'continue the authentication cleanup', - [vscode, docs], - [{ sessionId: 'related', confidence: 0.5 }], - [{ sessionId: 'related', label: 'Authentication cleanup', cwd: '/work/vscode-docs/src' }], - vscode.uri, - ); - - assert.strictEqual(result?.toString(), docs.uri.toString()); - }); - - test('explicit folder mention overrides a related session in another folder', () => { - const result = resolveNewSessionWorkspaceFolder( - 'update the vscode-docs API reference', - [vscode, docs], - [{ sessionId: 'related', confidence: 0.9 }], - [{ sessionId: 'related', label: 'Related work', cwd: '/work/vscode/src' }], - vscode.uri, - ); - - assert.strictEqual(result?.toString(), docs.uri.toString()); - }); - - test('explicit folder mention constrains existing session routing', () => { - const mentionedFolder = resolveMentionedWorkspaceFolder('fix the API in vscode-docs', [vscode, docs]); - const candidates = [ - { sessionId: 'vscode', label: 'API work', cwd: '/work/vscode/src' }, - { sessionId: 'docs', label: 'Documentation', cwd: '/WORK/VSCODE-DOCS/GUIDES' }, - { sessionId: 'unknown', label: 'Unknown folder' }, - ]; - - assert.deepStrictEqual({ - mentionedFolder: mentionedFolder?.name, - matchingCandidates: candidates - .filter(candidate => resolveSessionWorkspaceFolder(candidate, [vscode, docs]) === mentionedFolder) - .map(candidate => candidate.sessionId), - }, { - mentionedFolder: 'vscode-docs', - matchingCandidates: ['docs'], - }); - }); - - test('bounds transcript enrichment using preliminary scores', () => { - const candidates = Array.from({ length: 13 }, (_, index) => ({ - sessionId: `s${index}`, - label: `Session ${index}`, - status: index === 12 ? 'working' : 'idle', - lastActivity: index, - })); - const shortlist = selectRouterShortlist(candidates, [ - { sessionId: 's0', confidence: 0.9 }, - { sessionId: 's3', confidence: 0.8 }, - ]); - - assert.deepStrictEqual({ - length: shortlist.length, - first: shortlist[0].sessionId, - second: shortlist[1].sessionId, - third: shortlist[2].sessionId, - excluded: candidates.filter(candidate => !shortlist.includes(candidate)).map(candidate => candidate.sessionId), - }, { - length: 12, - first: 's0', - second: 's3', - third: 's12', - excluded: ['s1'], - }); - }); - - test('falls back to working and recent sessions when heuristic scores are all zero', () => { - const candidates = Array.from({ length: 13 }, (_, index) => ({ - sessionId: `s${index}`, - label: `Session ${index}`, - status: index === 12 ? 'working' : 'idle', - lastActivity: index, - })); - const preliminaryResults = heuristicScore({ - utterance: 'work on this with the agent', - sessions: candidates, - }); - const shortlist = selectRouterShortlist(candidates, preliminaryResults); - - assert.deepStrictEqual({ - positiveResults: preliminaryResults.filter(result => result.confidence > 0).length, - first: shortlist[0].sessionId, - excluded: candidates.filter(candidate => !shortlist.includes(candidate)).map(candidate => candidate.sessionId), - }, { - positiveResults: 0, - first: 's12', - excluded: ['s0'], - }); - }); - - test('selects only a high-confidence route', () => { - assert.deepStrictEqual(selectBestSessionRoute([ - { sessionId: 'best', confidence: 0.9 }, - { sessionId: 'previous', confidence: 0.86 }, - ]), { sessionId: 'best', confidence: 0.9 }); - assert.strictEqual(selectBestSessionRoute([{ sessionId: 'weak', confidence: 0.8 }]), undefined); - }); - - test('keeps the default folder for a weak related-session match', () => { - const result = resolveNewSessionWorkspaceFolder( - 'start something new', - [vscode, docs], - [{ sessionId: 'weak', confidence: 0.1 }], - [{ sessionId: 'weak', label: 'Unrelated docs work', cwd: '/work/vscode-docs' }], - vscode.uri, - ); - - assert.strictEqual(result?.toString(), vscode.uri.toString()); - }); - - test('extracts only explicit new-session tasks', () => { - assert.strictEqual(parseExplicitNewSessionRequest('Create a new session to update the chocolate file'), 'update the chocolate file'); - assert.strictEqual(parseExplicitNewSessionRequest('Please start a new chat session for fixing tests'), 'fixing tests'); - assert.strictEqual(parseExplicitNewSessionRequest('Create a new session'), undefined); - assert.strictEqual(parseExplicitNewSessionRequest('Create a file in the current session'), undefined); - }); -}); - -function folder(name: string, path: string, index: number): IWorkspaceFolder { - const uri = URI.file(path); - return { uri, name, index, toResource: relativePath => URI.joinPath(uri, relativePath) }; -} diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceClientService.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceClientService.test.ts index 21164abc3a8bbb..9f89f66f60866a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceClientService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceClientService.test.ts @@ -513,45 +513,6 @@ suite('VoiceClientService', () => { ]); }); - test('prepares for narration audio before sending the request', async () => { - const { service } = createService(); - await service.connect(createTestWindow()); - service.sendStartSession({ sessions: [], display_locale: '' }, 'machine'); - const sentBeforeNarration = socket().sent.length; - let sentWhenPrepared = -1; - - const narrationId = service.requestNarration('cs1', 'response', 'Done.', undefined, undefined, undefined, undefined, () => { - sentWhenPrepared = socket().sent.length; - return true; - }); - - assert.deepStrictEqual({ - sentBeforeNarration, - sentWhenPrepared, - sentAfterNarration: socket().sent.length, - narrationId: typeof narrationId, - }, { - sentBeforeNarration: 1, - sentWhenPrepared: 1, - sentAfterNarration: 2, - narrationId: 'string', - }); - }); - - test('links a tool result to its resolved coding session', async () => { - const { service } = createService(); - await service.connect(createTestWindow()); - - service.sendToolResult('call-1', 'ok', 'copilotcli:/session-1'); - - assert.deepStrictEqual(socket().sent.at(-1), { - type: 'tool_result', - call_id: 'call-1', - result: 'ok', - coding_session_id: 'copilotcli:/session-1', - }); - }); - test('drops a narration requested before the session starts', async () => { const { service } = createService(); diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts index 95c7ed219dee90..f8523eb4bdeb16 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceSessionController.test.ts @@ -9,7 +9,7 @@ import { mainWindow } from '../../../../../../base/browser/window.js'; import { DeferredPromise } from '../../../../../../base/common/async.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { autorun, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -36,7 +36,6 @@ import { IMicCaptureService } from '../../../browser/voiceClient/micCaptureServi import { ITtsPlaybackService } from '../../../browser/voiceClient/ttsPlaybackService.js'; import { VoiceSessionController } from '../../../browser/voiceClient/voiceSessionController.js'; import { IVoiceToolDispatchService } from '../../../browser/voiceClient/voiceToolDispatchService.js'; -import { CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID } from '../../../common/chatInputWindow.js'; import { ChatSendResult, ElicitationState, IChatConfirmation, IChatModelReference, IChatSendRequestOptions, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { derivePendingId, isPendingIdResolved, IVoiceAudioResponse, IVoiceBargeIn, IVoiceCheckpointNarrationMetadata, IVoiceClientService, IVoiceDispatchResult, IVoiceFatalDisconnect, IVoiceNarrationAck, IVoiceNarrationSignal, IVoicePttStartOptions, IVoiceSessionContext, IVoiceSpeechStarted, IVoiceToolCall, IVoiceTranscription, markPendingIdResolved, peekPendingId, VoiceConfirmationType, VoiceNarrationKind, VOICE_AGENT_PROGRESS_SETTING } from '../../../common/voiceClient/voiceClientService.js'; @@ -112,16 +111,15 @@ class TestVoiceClientService extends mock() { override sendNarrationPlaybackComplete(codingSessionId: string, narrationId: string, playbackId: string): void { this.playbackCompletions.push({ sessionId: codingSessionId, narrationId, playbackId }); } - readonly toolResults: { callId: string; result: string | IVoiceDispatchResult; codingSessionId?: string }[] = []; + readonly toolResults: { callId: string; result: string | IVoiceDispatchResult }[] = []; private toolResultResolver: (() => void) | undefined; readonly toolResultReceived = new Promise(resolve => this.toolResultResolver = resolve); - override sendToolResult(callId: string, result: string | IVoiceDispatchResult, codingSessionId?: string): void { - this.toolResults.push({ callId, result, ...(codingSessionId ? { codingSessionId } : {}) }); + override sendToolResult(callId: string, result: string | IVoiceDispatchResult): void { + this.toolResults.push({ callId, result }); this.toolResultResolver?.(); } - override requestNarration(codingSessionId: string, kind: VoiceNarrationKind, text: string, narrationId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata, confirmationType?: VoiceConfirmationType, pending?: { pendingId: string }, prepareToReceiveAudio?: () => void): string | undefined { - prepareToReceiveAudio?.(); + override requestNarration(codingSessionId: string, kind: VoiceNarrationKind, text: string, narrationId?: string, checkpoint?: IVoiceCheckpointNarrationMetadata, confirmationType?: VoiceConfirmationType, pending?: { pendingId: string }): string | undefined { const id = narrationId ?? `narration-${++this.narrationCounter}`; this.requests.push({ sessionId: codingSessionId, kind, text, narrationId: id, ...(pending ? { pendingId: pending.pendingId } : {}), ...(checkpoint ? { checkpoint } : {}), ...(confirmationType ? { confirmationType } : {}) }); this.wireEvents.push({ type: 'request_narration', kind, text, ...(confirmationType ? { confirmationType } : {}) }); @@ -386,6 +384,25 @@ function agentSessionEntry(id: string, label: string | undefined, status: AgentS }; } +function sentChatSendResult(id: string): ChatSendResult { + const response = { + id, + requestId: `request-${id}`, + isComplete: false, + isCanceled: false, + onDidChange: Event.None, + response: { value: [] as readonly IChatProgressResponseContent[] }, + } as unknown as IChatResponseModel; + return { + kind: 'sent', + data: { + agent: {} as never, + responseCreatedPromise: Promise.resolve(response), + responseCompletePromise: Promise.resolve(), + }, + }; +} + class TestChatService extends mock() { override readonly chatModels = observableValue('chatModels', []); readonly sendRequestOptions: (IChatSendRequestOptions | undefined)[] = []; @@ -399,29 +416,6 @@ class TestChatService extends mock() { override async acquireOrLoadSession(): Promise { return undefined; } } -class TrackingLoadChatService extends TestChatService { - readonly loaded: string[] = []; - private residentModel: IChatModel | undefined; - - setResident(resource: URI): void { - this.residentModel = { - sessionResource: resource, - getRequests: () => [], - } as unknown as IChatModel; - } - - override getSession(): IChatModel | undefined { - return this.residentModel; - } - - override async acquireOrLoadSession(resource?: URI): Promise { - if (resource) { - this.loaded.push(resource.toString()); - } - return undefined; - } -} - /** * Chat service that records session creation and sends, so the `new_session` * flag on `send_to_chat` can be checked end to end. @@ -441,7 +435,7 @@ class NewSessionChatService extends mock() { } override async sendRequest(resource: URI, message: string): Promise { this.sent.push({ resource: resource.toString(), message }); - return { kind: 'rejected', reason: 'test' }; + return sentChatSendResult(`new-session-send-${this.sent.length}`); } } @@ -502,7 +496,6 @@ class ControllableChatService extends mock() { override readonly chatModels = observableValue('chatModels', []); private readonly _sessions = new Map(); override getSession(resource: URI): IChatModel | undefined { return this._sessions.get(resource.toString()); } - override acquireOrLoadSession(): Promise { return Promise.resolve(undefined); } setModels(models: readonly IChatModel[]): void { this._sessions.clear(); for (const model of models) { @@ -642,23 +635,13 @@ class MaterializingChatWidget extends mock() { class TestCommandService extends mock() { readonly acceptedInputs: string[] = []; - readonly acceptedOmniInputs: string[] = []; - - constructor(private readonly omniFocused = false) { - super(); - } override async executeCommand(commandId: string, ...args: unknown[]): Promise { - let result: string | boolean | undefined; + let result: string | undefined; if (commandId === '_chat.voice.getCurrentSession') { result = 'chat-session'; } else if (commandId === '_chat.voice.acceptInput' && typeof args[0] === 'string') { this.acceptedInputs.push(args[0]); - } else if (commandId === CHAT_INPUT_WINDOW_ACCEPT_VOICE_COMMAND_ID && typeof args[0] === 'string') { - if (this.omniFocused) { - this.acceptedOmniInputs.push(args[0]); - } - result = this.omniFocused; } return result as T; } @@ -674,6 +657,15 @@ class AdoptingCommandService extends TestCommandService { } } +class RejectingAcceptCommandService extends TestCommandService { + override async executeCommand(commandId: string, ...args: unknown[]): Promise { + if (commandId === '_chat.voice.acceptInput') { + throw new Error('accept failed'); + } + return super.executeCommand(commandId, ...args); + } +} + class TestTelemetryService extends NullTelemetryServiceShape { readonly events: { name: string; data: unknown }[] = []; @@ -755,24 +747,6 @@ suite('VoiceSessionController', () => { )); } - async function connectWithOmniOpen(controller: VoiceSessionController, voiceClientService: TestVoiceClientService): Promise { - await controller.connect(mainWindow); - voiceClientService.fireConnectionState(true); - await voiceClientService.sessionCommandSent.p; - // An open socket is not a live session: a rejected connect is accepted - // before it is closed so the close frame can carry a reason, so the - // controller waits for the backend's ack before reporting connected. - // Without this the omni inbox stays inactive and nothing narrates. - voiceClientService.fireSessionInit(); - controller.setOmniInputOpen(true); - } - - function showSessionsInAgentsList(controller: VoiceSessionController, ...sessionIds: string[]): void { - const agentSessionsService = Reflect.get(controller, 'agentSessionsService') as IAgentSessionsService; - (agentSessionsService.model.sessions as unknown[]).push(...sessionIds.map(sessionId => - agentSessionEntry(sessionId, 'Test session', AgentSessionStatus.InProgress))); - } - function createVoiceProgressResponse(id: string, requestId = `request-${id}`) { const changeEmitter = store.add(new Emitter<{ reason: 'other' }>()); const parts: { kind: 'voiceProgress'; id: string; value: string }[] = []; @@ -809,7 +783,10 @@ suite('VoiceSessionController', () => { undefined, new TestAgentSessionsService([focusedSession, backgroundSession]), ); - await connectWithOmniOpen(controller, voiceClientService); + await controller.connect(mainWindow); + voiceClientService.fireConnectionState(true); + await voiceClientService.sessionCommandSent.p; + voiceClientService.fireSessionInit(); controller.setActiveSessionShown(focusedSession.resource); controller['_pttCurrentTurnId'] = 'turn-idle'; @@ -2151,7 +2128,7 @@ suite('VoiceSessionController', () => { ) => void; controller.setActiveSessionShown(sessionResource); - controller.setTargetSession(sessionResource, 'existing_session'); + controller.setTargetSession(sessionResource); const firstTool = waitingTerminalTool('first-tool'); const firstModel = pendingResponsePartModel(sessionResource, firstTool, 'Needs approval', true, 'routed-request'); chatService.setModels([firstModel]); @@ -2206,7 +2183,7 @@ suite('VoiceSessionController', () => { const pendingIds: (string | undefined)[] = []; controller.setActiveSessionShown(sessionResource); - controller.setTargetSession(sessionResource, 'existing_session'); + controller.setTargetSession(sessionResource); chatService.setModels([model]); const narrateCurrentApproval = () => { @@ -2265,7 +2242,7 @@ suite('VoiceSessionController', () => { const armConfirmationFlushWatchdog = Reflect.get(controller, '_armConfirmationFlushWatchdog') as (sessionId: string, label: string, isTransition: boolean) => void; controller.setActiveSessionShown(sessionResource); - controller.setTargetSession(sessionResource, 'existing_session'); + controller.setTargetSession(sessionResource); const firstTool = waitingTerminalTool('first-watchdog-tool'); const firstModel = pendingResponsePartModel(sessionResource, firstTool, 'Needs approval', true, 'routed-request'); chatService.setModels([firstModel]); @@ -2273,9 +2250,6 @@ suite('VoiceSessionController', () => { handleStateChange.call(controller, sessionResource.toString(), firstState.state, firstState.detail, undefined, sessionResource.toString(), firstState.confirmation_type); markNarrationHeard.call(controller, voiceClientService.requests[0].narrationId); - // Replace the completed first tool with the next pending occurrence without - // calling the normal state-change handler, matching the missed-transition - // condition this fallback exists to recover. const secondTool = waitingTerminalTool('second-watchdog-tool', 'npm install'); chatService.setModels([pendingResponsePartModel(sessionResource, secondTool, 'Needs approval', true, 'routed-request')]); Reflect.set(controller, '_pttHeld', true); @@ -2294,12 +2268,6 @@ suite('VoiceSessionController', () => { ], listeningTurnHeld: false, }); - - // The watchdog can be re-armed while the same occurrence is still pending; - // the shared in-flight/occurrence dedup must keep that retry silent. - armConfirmationFlushWatchdog.call(controller, sessionResource.toString(), 'Chat', false); - clock.tick(1_500); - assert.strictEqual(voiceClientService.requests.length, 2); }); test('same confirmation text with a new type is not deduplicated', async () => { @@ -4106,7 +4074,7 @@ suite('VoiceSessionController', () => { shownSession: voiceSession.toString(), defersVoiceSession: false, playedAudio: ['saved voice-session response'], - narrations: [{ sessionId: 'copilot:/voice-session', kind: 'confirmation', text: 'Approve the saved command.' }], + narrations: [{ sessionId: voiceSession.toString(), kind: 'confirmation', text: 'Approve the saved command.' }], }); }); @@ -4175,152 +4143,6 @@ suite('VoiceSessionController', () => { }); }); - test('omni surface ownership clears its draft and routed target when released', () => { - const controller = createController(new TestVoiceClientService()); - const routedSession = URI.parse('agent-host-copilot:/omni-target'); - - controller.setOmniInputActive(true); - controller.setDraftTarget(); - assert.deepStrictEqual({ - omniInputActive: controller.omniInputActive.get(), - hasDraftTarget: controller.hasDraftTarget.get(), - targetSession: controller.targetSession.get(), - }, { - omniInputActive: true, - hasDraftTarget: true, - targetSession: undefined, - }); - - controller.setTargetSession(routedSession, 'existing_session'); - controller.setOmniInputActive(false); - assert.deepStrictEqual({ - omniInputActive: controller.omniInputActive.get(), - hasDraftTarget: controller.hasDraftTarget.get(), - targetSession: controller.targetSession.get(), - }, { - omniInputActive: false, - hasDraftTarget: false, - targetSession: undefined, - }); - }); - - test('session input atomically takes capture ownership from omni', () => { - const controller = createController(new TestVoiceClientService()); - const session = URI.parse('agent-host-copilot:/session-owner'); - - controller.setOmniInputActive(true); - controller.setDraftTarget(); - controller.takeSessionInputOwnership(session, mainWindow); - - assert.deepStrictEqual({ - omniInputActive: controller.omniInputActive.get(), - hasDraftTarget: controller.hasDraftTarget.get(), - targetSession: controller.targetSession.get()?.toString(), - }, { - omniInputActive: false, - hasDraftTarget: false, - targetSession: session.toString(), - }); - - controller.setOmniInputActive(true); - controller.takeDraftInputOwnership(mainWindow); - assert.deepStrictEqual({ - omniInputActive: controller.omniInputActive.get(), - hasDraftTarget: controller.hasDraftTarget.get(), - targetSession: controller.targetSession.get(), - }, { - omniInputActive: false, - hasDraftTarget: true, - targetSession: undefined, - }); - - controller.takeOmniInputOwnership(mainWindow); - assert.deepStrictEqual({ - omniInputActive: controller.omniInputActive.get(), - hasDraftTarget: controller.hasDraftTarget.get(), - targetSession: controller.targetSession.get(), - }, { - omniInputActive: true, - hasDraftTarget: true, - targetSession: undefined, - }); - }); - - test('barge-in preserves the Omni route instead of retargeting to panel chat', () => { - const controller = createController(new TestVoiceClientService()); - const omniSession = URI.parse('agent-host-copilot:/omni-route'); - - controller.takeOmniInputOwnership(mainWindow); - controller.setTargetSession(omniSession, 'existing_session'); - const retained = controller.retainOmniInputOwnershipForBargeIn(mainWindow); - - assert.deepStrictEqual({ - retained, - omniInputActive: controller.omniInputActive.get(), - hasDraftTarget: controller.hasDraftTarget.get(), - targetSession: controller.targetSession.get()?.toString(), - }, { - retained: true, - omniInputActive: true, - hasDraftTarget: false, - targetSession: omniSession.toString(), - }); - - Reflect.set(controller, '_window', undefined); - assert.strictEqual(controller.retainOmniInputOwnershipForBargeIn(mainWindow), false); - - controller.setOmniInputActive(false); - assert.strictEqual(controller.retainOmniInputOwnershipForBargeIn(mainWindow), false); - }); - - test('omni open state is observable independently of capture ownership', () => { - const controller = createController(new TestVoiceClientService()); - const states: boolean[] = []; - const listener = autorun(reader => states.push(controller.omniInputOpen.read(reader))); - - controller.setOmniInputOpen(true); - controller.setOmniInputOpen(false); - listener.dispose(); - - assert.deepStrictEqual(states, [false, true, false]); - }); - - test('omni blur preserves an in-progress turn until voice returns to idle', async () => { - const controller = createController(new TestVoiceClientService()); - const voiceState = Reflect.get(controller, '_voiceState') as { set(value: string, tx: undefined): void }; - - controller.setOmniInputActive(true); - controller.setDraftTarget(); - voiceState.set('processing', undefined); - controller.releaseOmniInputOnBlur(); - - assert.strictEqual(controller.omniInputActive.get(), true); - assert.strictEqual(controller.hasDraftTarget.get(), true); - - voiceState.set('idle', undefined); - await Promise.resolve(); - - assert.strictEqual(controller.omniInputActive.get(), false); - assert.strictEqual(controller.hasDraftTarget.get(), false); - assert.strictEqual(controller.targetSession.get(), undefined); - }); - - test('omni focus reacquisition cancels a deferred blur release', async () => { - const controller = createController(new TestVoiceClientService()); - const voiceState = Reflect.get(controller, '_voiceState') as { set(value: string, tx: undefined): void }; - - controller.setOmniInputActive(true); - controller.setDraftTarget(); - voiceState.set('processing', undefined); - controller.releaseOmniInputOnBlur(); - controller.setOmniInputActive(true); - voiceState.set('idle', undefined); - await Promise.resolve(); - - assert.strictEqual(controller.omniInputActive.get(), true); - assert.strictEqual(controller.hasDraftTarget.get(), true); - }); - test('untagged solicited narration dropped after retargeting retries when its session returns', async () => { const voiceClientService = new TestVoiceClientService(); const ttsPlaybackService = new TestTtsPlaybackService(); @@ -4364,8 +4186,8 @@ suite('VoiceSessionController', () => { kind: request.kind, text: request.text, })), [ - { sessionId: 'copilot:/first-session', kind: 'response', text: 'The first task is complete.' }, - { sessionId: 'copilot:/first-session', kind: 'response', text: 'The first task is complete.' }, + { sessionId: firstSession.toString(), kind: 'response', text: 'The first task is complete.' }, + { sessionId: firstSession.toString(), kind: 'response', text: 'The first task is complete.' }, ]); }); @@ -4672,1545 +4494,116 @@ suite('VoiceSessionController', () => { assert.strictEqual(session.label, 'Auth fix'); }); - test('marks an omni-routed target for backend narration', () => { - const resource = URI.parse('vscode-chat://a'); - const voiceClientService = new TestVoiceClientService(); - const controller = createController( - voiceClientService, undefined, undefined, undefined, undefined, undefined, undefined, undefined, - new TestAgentSessionsService([agentSessionEntry(resource.toString(), 'Auth fix', AgentSessionStatus.InProgress)]), - ); - const buildSessionContext = Reflect.get(controller, '_buildSessionContext') as () => { - sessions: { id: string; is_active: boolean; omni_route?: string }[]; - }; - (Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }).set(true, undefined); - - controller.setTargetSession(resource, 'new_session'); - const [session] = buildSessionContext.call(controller).sessions; - const synchronized = voiceClientService.wireEvents.at(-1); - - assert.strictEqual(session.is_active, true); - assert.strictEqual(session.omni_route, 'new_session'); - assert.strictEqual(synchronized?.type, 'session_context'); - assert.strictEqual(synchronized?.type === 'session_context' && synchronized.context.sessions[0].is_active, true); - }); - - test('open omni plays direct audio from a listed background session without a pending indicator', async () => { + test('focus transfers voice ownership and narrates a pending background response', async () => { const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); const voicePlaybackService = new RecordingVoicePlaybackService(); + const chatWidgetService = new TestChatWidgetService(); const controller = createController( - voiceClientService, ttsPlaybackService, undefined, undefined, undefined, undefined, - undefined, undefined, undefined, undefined, undefined, voicePlaybackService, + voiceClientService, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, voicePlaybackService, chatWidgetService, ); - const resource = URI.parse('vscode-chat://background-direct-audio'); - showSessionsInAgentsList(controller, resource.toString()); - await connectWithOmniOpen(controller, voiceClientService); - - voiceClientService.fireAudioResponse({ - audio: 'The background task is complete.', - isFirstChunk: true, - isFinal: true, - codingSessionId: resource.toString(), - responseId: 'background-response', - transcript: 'The background task is complete.', - }); - - assert.deepStrictEqual({ - playedAudio: ttsPlaybackService.playedAudio, - pendingSessions: [...voicePlaybackService.pendingSessions], - }, { - playedAudio: ['The background task is complete.'], - pendingSessions: [], - }); - }); - - test('open omni claims a coalesced completed session exactly once', async () => { - const voiceClientService = new TestVoiceClientService(); - const controller = createController(voiceClientService); - const sessionId = URI.parse('vscode-chat://coalesced-completion').toString(); - showSessionsInAgentsList(controller, sessionId); - await connectWithOmniOpen(controller, voiceClientService); - const claim = Reflect.get(controller, '_claimFreshOmniCompletion') as (sessionId: string, endedAt: number) => boolean; + const focusedSession = URI.parse('vscode-chat://focused-panel-session'); + const backgroundSession = URI.parse('vscode-chat://background-panel-session'); + const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as ( + sessionId: string, + state: string, + detail: string | undefined, + summary: string | undefined, + shown: string | undefined, + ) => void; + await controller.connect(mainWindow); + voiceClientService.fireConnectionState(true); + await voiceClientService.sessionCommandSent.p; + voiceClientService.fireSessionInit(); + controller.setTargetSession(focusedSession); - assert.deepStrictEqual([ - claim.call(controller, sessionId, 1), - claim.call(controller, sessionId, 1), - ], [true, false]); - }); + handleStateChange.call(controller, focusedSession.toString(), 'idle', undefined, 'Focused response.', focusedSession.toString()); + handleStateChange.call(controller, backgroundSession.toString(), 'idle', undefined, 'Background response.', focusedSession.toString()); - test('open omni claims each completed response id exactly once', async () => { - const voiceClientService = new TestVoiceClientService(); - const controller = createController(voiceClientService); - const resource = URI.parse('vscode-chat://completed-response-identity'); - showSessionsInAgentsList(controller, resource.toString()); - await connectWithOmniOpen(controller, voiceClientService); - const response = { id: 'response-1', isComplete: true, isCanceled: false }; - const model = { - sessionResource: resource, - lastRequest: { response }, - } as unknown as IChatModel; - const claim = Reflect.get(controller, '_claimOmniCompletedResponse') as (model: IChatModel, state: string, summary: string) => boolean; + assert.deepStrictEqual(voiceClientService.requests.map(request => request.text), ['Focused response.']); + assert.ok(voicePlaybackService.pendingSessions.has(backgroundSession.toString())); - const first = claim.call(controller, model, 'idle', 'First completed response.'); - const duplicate = claim.call(controller, model, 'idle', 'First completed response.'); - response.id = 'response-2'; - const next = claim.call(controller, model, 'idle', 'Second completed response.'); + chatWidgetService.focus(backgroundSession); + (Reflect.get(controller, '_onFocusedSessionChanged') as () => void).call(controller); - assert.deepStrictEqual([first, duplicate, next], [true, false, true]); + assert.deepStrictEqual(voiceClientService.requests.map(request => request.text), ['Focused response.', 'Background response.']); + assert.strictEqual(controller.targetSession.get()?.toString(), backgroundSession.toString()); }); - test('open omni does not claim audio from a session missing from the Agents list', async () => { + test('materializing an untitled chat preserves Voice Mode ownership', async () => { const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const voicePlaybackService = new RecordingVoicePlaybackService(); + const untitledSession = URI.parse('agent-host-copilotcli:/untitled-voice-session'); + const materializedSession = URI.parse('agent-host-copilotcli:/materialized-voice-session'); + const widget = store.add(new MaterializingChatWidget(untitledSession)); + const chatWidgetService = new TestChatWidgetService([widget]); const controller = createController( - voiceClientService, ttsPlaybackService, undefined, undefined, undefined, undefined, - undefined, undefined, undefined, undefined, undefined, voicePlaybackService, + voiceClientService, undefined, undefined, undefined, undefined, undefined, + undefined, undefined, undefined, undefined, undefined, undefined, chatWidgetService, ); - const resource = URI.parse('vscode-chat://hidden-background-audio'); - await connectWithOmniOpen(controller, voiceClientService); + await controller.connect(mainWindow); + voiceClientService.fireConnectionState(true); + await voiceClientService.sessionCommandSent.p; + voiceClientService.fireSessionInit(); + controller.setTargetSession(untitledSession); - voiceClientService.fireAudioResponse({ - audio: 'This hidden task is complete.', - isFirstChunk: true, - isFinal: true, - codingSessionId: resource.toString(), - responseId: 'hidden-background-response', - transcript: 'This hidden task is complete.', - }); + widget.materialize(materializedSession); - assert.deepStrictEqual({ - playedAudio: ttsPlaybackService.playedAudio, - pendingSessions: [...voicePlaybackService.pendingSessions], - }, { - playedAudio: [], - pendingSessions: [resource.toString()], - }); + assert.strictEqual(controller.isConnected.get(), true); + assert.strictEqual(controller.targetSession.get()?.toString(), materializedSession.toString()); }); - test('opening omni preserves the panel indicator for a session missing from the Agents list', () => { + test('stops tracking a removed chat widget', () => { const voiceClientService = new TestVoiceClientService(); - const voicePlaybackService = new RecordingVoicePlaybackService(); + const initialSession = URI.parse('agent-host-copilotcli:/initial-session'); + const removedSession = URI.parse('agent-host-copilotcli:/removed-session'); + const widget = store.add(new MaterializingChatWidget(initialSession)); + const widgetRemovals = store.add(new Emitter()); + const chatWidgetService = new TestChatWidgetService([widget], widgetRemovals.event); const controller = createController( voiceClientService, undefined, undefined, undefined, undefined, undefined, - undefined, undefined, undefined, undefined, undefined, voicePlaybackService, + undefined, undefined, undefined, undefined, undefined, undefined, chatWidgetService, ); - const sessionId = URI.parse('vscode-chat://hidden-pending-response').toString(); - (Reflect.get(controller, '_pendingResponseSummaries') as Map).set(sessionId, 'Hidden response.'); - const markPendingResponse = Reflect.get(controller, '_markPendingResponse') as (sessionId: string, pending: boolean) => void; - markPendingResponse.call(controller, sessionId, true); - controller.setOmniInputOpen(true); + widgetRemovals.fire(widget); + Reflect.set(controller, '_lastShownSessionId', undefined); + widget.materialize(removedSession); - assert.deepStrictEqual([...voicePlaybackService.pendingSessions], [sessionId]); + assert.strictEqual(Reflect.get(controller, '_lastShownSessionId'), undefined); }); - test('open omni returns queued narration to panel ownership when its session leaves the Agents list', async () => { - const voiceClientService = new TestVoiceClientService(); - const voicePlaybackService = new RecordingVoicePlaybackService(); - const controller = createController( - voiceClientService, undefined, undefined, undefined, undefined, undefined, - undefined, undefined, undefined, undefined, undefined, voicePlaybackService, - ); - const sessionId = URI.parse('vscode-chat://archived-queued-response').toString(); - showSessionsInAgentsList(controller, sessionId); - await connectWithOmniOpen(controller, voiceClientService); - (Reflect.get(controller, '_omniNarrationQueue') as unknown[]).push({ - sessionId, - kind: 'response', - text: 'Queued response.', - ordinal: 1, - }); - (Reflect.get(controller, '_omniClaimedResponseSummaries') as Map).set(sessionId, 'Queued response.'); - const agentSessionsService = Reflect.get(controller, 'agentSessionsService') as IAgentSessionsService; - const listedSession = (agentSessionsService.model.sessions as unknown as { resource: URI; isArchived: () => boolean }[]) - .find(session => session.resource.toString() === sessionId)!; - listedSession.isArchived = () => true; - const drainOmniInbox = Reflect.get(controller, '_drainOmniInbox') as () => void; + test('grounds the active session with its selected model and attachment names', () => { + const chatService = new ControllableChatService(); + const resource = URI.parse('vscode-chat://regular/session-aware'); + const lastRequest = { + id: 'request-1', + response: { + isPendingConfirmation: observableValue('pending', undefined), + isIncomplete: observableValue('incomplete', false), + response: { value: [], getMarkdown: () => '' }, + }, + }; + const model = { + sessionResource: resource, + title: 'Session awareness', + lastMessageDate: Date.now(), + getRequests: () => [lastRequest], + lastRequestObs: observableValue('lastRequest', lastRequest), + inputModel: { + state: observableValue('inputState', { + selectedModel: { + identifier: 'copilot/gpt-5', + metadata: { name: 'GPT-5', vendor: 'copilot' }, + }, + attachments: [{ kind: 'file', name: 'voiceSessionController.ts' }, { kind: 'file', name: 'README.md' }], + }), + }, + } as unknown as IChatModel; + chatService.setModels([model]); + const controller = createController(new TestVoiceClientService(), undefined, undefined, undefined, undefined, undefined, chatService); + controller.setActiveSessionShown(resource); + const buildSessionContext = Reflect.get(controller, '_buildSessionContext') as () => IVoiceSessionContext; - drainOmniInbox.call(controller); - - assert.deepStrictEqual({ - narrations: voiceClientService.requests, - pendingSessions: [...voicePlaybackService.pendingSessions], - }, { - narrations: [], - pendingSessions: [sessionId], - }); - }); - - test('open omni claims background audio while the voice session awaits initialization', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const controller = createController(voiceClientService, ttsPlaybackService); - const resource = URI.parse('vscode-chat://background-during-connect'); - showSessionsInAgentsList(controller, resource.toString()); - await controller.connect(mainWindow); - controller.setOmniInputOpen(true); - voiceClientService.fireConnectionState(true); - await voiceClientService.sessionCommandSent.p; - - voiceClientService.fireAudioResponse({ - audio: 'The background task finished while voice mode was connecting.', - isFirstChunk: true, - isFinal: true, - codingSessionId: resource.toString(), - responseId: 'background-during-connect-response', - transcript: 'The background task finished while voice mode was connecting.', - }); - - assert.deepStrictEqual(ttsPlaybackService.playedAudio, [ - 'The background task finished while voice mode was connecting.', - ]); - }); - - test('opening omni drops stale panel deferrals before narrating global work', async () => { - const voiceClientService = new TestVoiceClientService(); - const controller = createController(voiceClientService); - const staleSession = URI.parse('vscode-chat://stale-panel-deferral').toString(); - const responseSession = URI.parse('vscode-chat://omni-response-after-stale').toString(); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as ( - sessionId: string, - state: string, - detail: string | undefined, - summary: string | undefined, - shown: string | undefined, - ) => void; - showSessionsInAgentsList(controller, responseSession); - await controller.connect(mainWindow); - voiceClientService.fireConnectionState(true); - await voiceClientService.sessionCommandSent.p; - voiceClientService.fireSessionInit(); - (Reflect.get(controller, '_deferredNarrations') as Map).set(staleSession, { - narrationId: 'stale-panel-narration', - kind: 'confirmation', - text: 'No longer pending.', - reuseNarrationId: true, - }); - - controller.setOmniInputOpen(true); - handleStateChange.call(controller, responseSession, 'idle', undefined, 'Global Omni response.', undefined); - - assert.strictEqual((Reflect.get(controller, '_deferredNarrations') as Map).size, 0); - assert.strictEqual(voiceClientService.requests.at(-1)?.text, 'Global Omni response.'); - }); - - test('open omni serializes actionable items and responses across background sessions', async () => { - const voiceClientService = new TestVoiceClientService(); - const voicePlaybackService = new RecordingVoicePlaybackService(); - const controller = createController( - voiceClientService, undefined, undefined, undefined, undefined, undefined, - undefined, undefined, undefined, undefined, undefined, voicePlaybackService, - ); - const confirmationSession = URI.parse('vscode-chat://background-confirmation').toString(); - const responseSession = URI.parse('vscode-chat://background-response').toString(); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as ( - sessionId: string, - state: string, - detail: string | undefined, - summary: string | undefined, - shown: string | undefined, - confirmationType?: VoiceConfirmationType, - ) => void; - const markNarrationHeard = Reflect.get(controller, '_markNarrationHeard') as (narrationId: string) => void; - showSessionsInAgentsList(controller, confirmationSession, responseSession); - await connectWithOmniOpen(controller, voiceClientService); - - handleStateChange.call(controller, confirmationSession, 'waiting_for_confirmation', 'Allow running the tests?', undefined, 'vscode-chat://shown-elsewhere', 'tool'); - handleStateChange.call(controller, responseSession, 'idle', undefined, 'The other task is complete.', 'vscode-chat://shown-elsewhere'); - - assert.deepStrictEqual({ - requests: voiceClientService.requests.map(request => ({ sessionId: request.sessionId, kind: request.kind, text: request.text })), - queued: (Reflect.get(controller, '_omniNarrationQueue') as unknown[]).length, - pendingSessions: [...voicePlaybackService.pendingSessions], - }, { - requests: [{ sessionId: confirmationSession, kind: 'confirmation', text: 'Allow running the tests?' }], - queued: 1, - pendingSessions: [], - }); - - markNarrationHeard.call(controller, voiceClientService.requests[0].narrationId); - await Promise.resolve(); - - assert.deepStrictEqual({ - requests: voiceClientService.requests.map(request => ({ sessionId: request.sessionId, kind: request.kind, text: request.text })), - queued: (Reflect.get(controller, '_omniNarrationQueue') as unknown[]).length, - }, { - requests: [ - { sessionId: confirmationSession, kind: 'confirmation', text: 'Allow running the tests?' }, - { sessionId: responseSession, kind: 'response', text: 'The other task is complete.' }, - ], - queued: 0, - }); - }); - - test('open omni narrates structured questions from a background session exactly once', async () => { - const voiceClientService = new TestVoiceClientService(); - const chatService = new ControllableChatService(); - const controller = createController(voiceClientService, undefined, undefined, undefined, undefined, undefined, chatService); - const resource = URI.parse('vscode-chat://background-question'); - const carousel = new ChatQuestionCarouselData([{ - id: 'deployment_target', - type: 'singleSelect', - title: 'deployment_target', - message: 'Where should the app deploy?', - options: [ - { id: 'staging', label: 'Staging', value: 'staging' }, - { id: 'production', label: 'Production', value: 'production' }, - ], - }], true, 'resolve-deployment'); - const model = pendingResponsePartModel(resource, carousel, 'questions: deployment_target'); - chatService.setModels([model]); - const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { - state: string; - detail?: string; - confirmation_type?: VoiceConfirmationType; - }; - const stateInfo = getAgentStateInfo.call(controller, model); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as ( - sessionId: string, - state: string, - detail: string | undefined, - summary: string | undefined, - shown: string | undefined, - confirmationType?: VoiceConfirmationType, - ) => void; - const markNarrationHeard = Reflect.get(controller, '_markNarrationHeard') as (narrationId: string) => void; - showSessionsInAgentsList(controller, resource.toString()); - await connectWithOmniOpen(controller, voiceClientService); - - handleStateChange.call(controller, resource.toString(), stateInfo.state, stateInfo.detail, undefined, 'vscode-chat://different-session', stateInfo.confirmation_type); - markNarrationHeard.call(controller, voiceClientService.requests[0].narrationId); - handleStateChange.call(controller, resource.toString(), stateInfo.state, stateInfo.detail, undefined, 'vscode-chat://different-session', stateInfo.confirmation_type); - - assert.deepStrictEqual(voiceClientService.requests.map(request => ({ - kind: request.kind, - text: request.text, - pendingId: request.pendingId, - })), [{ - kind: 'question', - text: 'Where should the app deploy? Options: 1, Staging. 2, Production. You can also give your own answer. Or say skip.', - pendingId: voiceClientService.requests[0].pendingId, - }]); - assert.ok(voiceClientService.requests[0].pendingId); - }); - - test('visible omni question card announces while omni owns the draft target', async () => { - const voiceClientService = new TestVoiceClientService(); - const chatService = new ControllableChatService(); - const controller = createController(voiceClientService, undefined, undefined, undefined, undefined, undefined, chatService); - const resource = URI.parse('vscode-chat://visible-omni-question'); - const carousel = new ChatQuestionCarouselData([{ - id: 'runtime', - type: 'singleSelect', - title: 'runtime', - message: 'Which runtime should be used?', - options: [ - { id: 'node', label: 'Node.js', value: 'node' }, - { id: 'deno', label: 'Deno', value: 'deno' }, - ], - }], true, 'select-runtime'); - chatService.setModels([pendingResponsePartModel(resource, carousel, 'questions: runtime')]); - showSessionsInAgentsList(controller, resource.toString()); - await connectWithOmniOpen(controller, voiceClientService); - controller.setDraftTarget(); - - controller.announceSessionInOmni(resource); - - assert.deepStrictEqual(voiceClientService.requests.map(request => ({ - kind: request.kind, - text: request.text, - })), [{ - kind: 'question', - text: 'Which runtime should be used? Options: 1, Node.js. 2, Deno. You can also give your own answer. Or say skip.', - }]); - }); - - test('direct omni question answers immediately synchronize voice context', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const chatService = new ControllableChatService(); - const controller = createController(voiceClientService, ttsPlaybackService, undefined, undefined, undefined, undefined, chatService); - const resource = URI.parse('vscode-chat://direct-omni-question-answer'); - const carousel = new ChatQuestionCarouselData([{ - id: 'runtime', - type: 'singleSelect', - title: 'runtime', - message: 'Which runtime should be used?', - options: [{ id: 'node', label: 'Node.js', value: 'node' }], - }], true, 'select-runtime'); - chatService.setModels([pendingResponsePartModel(resource, carousel, 'questions: runtime')]); - showSessionsInAgentsList(controller, resource.toString()); - await connectWithOmniOpen(controller, voiceClientService); - controller.announceSessionInOmni(resource); - const narrationId = voiceClientService.requests[0].narrationId; - voiceClientService.fireAudioResponse({ - audio: 'Which runtime should be used?', - isFirstChunk: true, - isFinal: false, - codingSessionId: resource.toString(), - responseId: narrationId, - transcript: 'Which runtime should be used?', - narrationKind: 'question', - }); - const stopCountBeforeAnswer = ttsPlaybackService.stopCount; - - carousel.dismiss({ runtime: { selectedValue: 'node' } }); - controller.notifyPendingItemResolved(resource); - - const context = voiceClientService.wireEvents.at(-1); - assert.deepStrictEqual({ - type: context?.type, - pending: context?.type === 'session_context' - ? context.context.sessions.find(session => session.id === resource.toString())?.pending - : undefined, - inFlightNarrations: (Reflect.get(controller, '_pendingSolicitedNarrations') as Map).size, - stoppedActiveQuestion: ttsPlaybackService.stopCount === stopCountBeforeAnswer + 1, - }, { - type: 'session_context', - pending: undefined, - inFlightNarrations: 0, - stoppedActiveQuestion: true, - }); - }); - - test('open omni queues background narration while passive listening has detected speech', async () => { - const voiceClientService = new TestVoiceClientService(); - const controller = createController(voiceClientService); - const sessionId = URI.parse('vscode-chat://background-while-speaking').toString(); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as ( - sessionId: string, - state: string, - detail: string | undefined, - summary: string | undefined, - shown: string | undefined, - ) => void; - const drainOmniInbox = Reflect.get(controller, '_drainOmniInbox') as () => void; - showSessionsInAgentsList(controller, sessionId); - await connectWithOmniOpen(controller, voiceClientService); - Reflect.set(controller, '_pttHeld', true); - Reflect.set(controller, '_pttCurrentTurnPassive', true); - Reflect.set(controller, '_speechDetectedInTurn', true); - - handleStateChange.call(controller, sessionId, 'idle', undefined, 'This arrived while the user was speaking.', undefined); - - assert.deepStrictEqual({ - requests: voiceClientService.requests.length, - queued: (Reflect.get(controller, '_omniNarrationQueue') as unknown[]).length, - }, { requests: 0, queued: 1 }); - - Reflect.set(controller, '_pttHeld', false); - Reflect.set(controller, '_speechDetectedInTurn', false); - drainOmniInbox.call(controller); - - assert.deepStrictEqual(voiceClientService.requests.map(request => ({ kind: request.kind, text: request.text })), [{ - kind: 'response', - text: 'This arrived while the user was speaking.', - }]); - }); - - test('open omni preserves arrival order between queued narration and direct response audio', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const controller = createController(voiceClientService, ttsPlaybackService); - const confirmationSession = URI.parse('vscode-chat://queued-confirmation').toString(); - const responseSession = URI.parse('vscode-chat://queued-direct-response').toString(); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as ( - sessionId: string, - state: string, - detail: string | undefined, - summary: string | undefined, - shown: string | undefined, - confirmationType?: VoiceConfirmationType, - ) => void; - const drainOmniInbox = Reflect.get(controller, '_drainOmniInbox') as () => void; - const markNarrationHeard = Reflect.get(controller, '_markNarrationHeard') as (narrationId: string) => void; - showSessionsInAgentsList(controller, confirmationSession, responseSession); - await connectWithOmniOpen(controller, voiceClientService); - Reflect.set(controller, '_pttHeld', true); - Reflect.set(controller, '_pttCurrentTurnPassive', true); - Reflect.set(controller, '_speechDetectedInTurn', true); - - handleStateChange.call(controller, confirmationSession, 'waiting_for_confirmation', 'Allow the queued action?', undefined, undefined, 'tool'); - voiceClientService.fireAudioResponse({ - audio: 'The later response is complete.', - isFirstChunk: true, - isFinal: true, - codingSessionId: responseSession, - responseId: 'later-direct-response', - transcript: 'The later response is complete.', - }); - - assert.deepStrictEqual({ - requests: voiceClientService.requests.length, - playedAudio: ttsPlaybackService.playedAudio, - queuedNarrations: (Reflect.get(controller, '_omniNarrationQueue') as unknown[]).length, - deferredResponses: (Reflect.get(controller, '_deferredResponses') as Map).size, - }, { - requests: 0, - playedAudio: [], - queuedNarrations: 1, - deferredResponses: 1, - }); - - Reflect.set(controller, '_pttHeld', false); - Reflect.set(controller, '_speechDetectedInTurn', false); - drainOmniInbox.call(controller); - assert.deepStrictEqual({ - requests: voiceClientService.requests.map(request => request.text), - playedAudio: ttsPlaybackService.playedAudio, - }, { - requests: ['Allow the queued action?'], - playedAudio: [], - }); - - markNarrationHeard.call(controller, voiceClientService.requests[0].narrationId); - await Promise.resolve(); - assert.deepStrictEqual(ttsPlaybackService.playedAudio, ['The later response is complete.']); - }); - - test('open omni plays a solicited narration whose audio arrives just after the user stops speaking', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const controller = createController(voiceClientService, ttsPlaybackService); - const sessionId = URI.parse('vscode-chat://solicited-after-release').toString(); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as ( - sessionId: string, - state: string, - detail: string | undefined, - summary: string | undefined, - shown: string | undefined, - confirmationType?: VoiceConfirmationType, - ) => void; - showSessionsInAgentsList(controller, sessionId); - await connectWithOmniOpen(controller, voiceClientService); - - // The confirmation is narrated (requested) for a background session, - // creating an in-flight solicited narration. - handleStateChange.call(controller, sessionId, 'waiting_for_confirmation', 'Allow running the build?', undefined, sessionId, 'tool'); - const [request] = voiceClientService.requests; - - // Its audio arrives AFTER the user has finished speaking. Nothing else - // will trigger a drain, so the audio must play live now rather than being - // stranded in the deferred buffer (the reproduced bug: the narration's own - // pending entry made it defer itself, then no drain ever ran). - voiceClientService.fireAudioResponse({ - audio: 'Allow running the build?', - isFirstChunk: true, - isFinal: true, - codingSessionId: sessionId, - responseId: request.narrationId, - transcript: 'Allow running the build?', - }); - - assert.deepStrictEqual(ttsPlaybackService.playedAudio, ['Allow running the build?']); - }); - - test('open omni plays a solicited narration whose audio was deferred while the user was speaking', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const controller = createController(voiceClientService, ttsPlaybackService); - const sessionId = URI.parse('vscode-chat://deferred-solicited-narration').toString(); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as ( - sessionId: string, - state: string, - detail: string | undefined, - summary: string | undefined, - shown: string | undefined, - confirmationType?: VoiceConfirmationType, - ) => void; - const drainOmniInbox = Reflect.get(controller, '_drainOmniInbox') as () => void; - showSessionsInAgentsList(controller, sessionId); - await connectWithOmniOpen(controller, voiceClientService); - - // The confirmation is narrated (requested) while the user is not speaking, - // creating an in-flight solicited narration for this session. - handleStateChange.call(controller, sessionId, 'waiting_for_confirmation', 'Allow running the tests?', undefined, sessionId, 'tool'); - const [request] = voiceClientService.requests; - - // The user starts speaking; the narration's own audio then arrives. It must - // be held (queued) rather than played over the user's speech. - Reflect.set(controller, '_pttHeld', true); - Reflect.set(controller, '_pttCurrentTurnPassive', false); - Reflect.set(controller, '_speechDetectedInTurn', true); - voiceClientService.fireAudioResponse({ - audio: 'Allow running the tests?', - isFirstChunk: true, - isFinal: true, - codingSessionId: sessionId, - responseId: request.narrationId, - transcript: 'Allow running the tests?', - }); - - assert.deepStrictEqual(ttsPlaybackService.playedAudio, []); - - // When the user stops speaking, the drain must play the buffered narration - // instead of deadlocking on it (the pending narration waits for the drain - // that would otherwise be blocked by that same pending narration). - Reflect.set(controller, '_pttHeld', false); - Reflect.set(controller, '_speechDetectedInTurn', false); - drainOmniInbox.call(controller); - - assert.deepStrictEqual(ttsPlaybackService.playedAudio, ['Allow running the tests?']); - }); - - test('closing omni transfers unheard items to panel ownership for narration on refocus', async () => { - const voiceClientService = new TestVoiceClientService(); - const chatService = new ControllableChatService(); - const voicePlaybackService = new RecordingVoicePlaybackService(); - const controller = createController( - voiceClientService, undefined, undefined, undefined, undefined, undefined, - chatService, undefined, undefined, undefined, undefined, voicePlaybackService, - ); - const confirmationResource = URI.parse('vscode-chat://abandoned-confirmation'); - const responseSession = URI.parse('vscode-chat://abandoned-response').toString(); - const tool = waitingTerminalTool('abandoned-tool'); - const model = pendingResponsePartModel(confirmationResource, tool, 'Needs approval'); - chatService.setModels([model]); - const getAgentStateInfo = Reflect.get(controller, '_getAgentStateInfo') as (model: IChatModel) => { - state: string; - detail?: string; - confirmation_type?: VoiceConfirmationType; - }; - const stateInfo = getAgentStateInfo.call(controller, model); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as ( - sessionId: string, - state: string, - detail: string | undefined, - summary: string | undefined, - shown: string | undefined, - confirmationType?: VoiceConfirmationType, - ) => void; - const reconcileIndicators = Reflect.get(controller, '_reconcileConfirmationIndicators') as (sessionIds: Set) => void; - await connectWithOmniOpen(controller, voiceClientService); - - handleStateChange.call(controller, confirmationResource.toString(), stateInfo.state, stateInfo.detail, undefined, undefined, stateInfo.confirmation_type); - reconcileIndicators.call(controller, new Set([confirmationResource.toString()])); - handleStateChange.call(controller, responseSession, 'idle', undefined, 'This response was queued behind the confirmation.', undefined); - controller.setOmniInputOpen(false); - - handleStateChange.call(controller, confirmationResource.toString(), stateInfo.state, stateInfo.detail, undefined, undefined, stateInfo.confirmation_type); - reconcileIndicators.call(controller, new Set([confirmationResource.toString()])); - handleStateChange.call(controller, responseSession, 'idle', undefined, 'This response was queued behind the confirmation.', undefined); - controller.activateSession(URI.parse(responseSession)); - - assert.strictEqual(voiceClientService.requests.at(-1)?.text, 'This response was queued behind the confirmation.'); - assert.strictEqual((Reflect.get(controller, '_omniNarrationQueue') as unknown[]).length, 0); - assert.ok(voicePlaybackService.pendingSessions.has(responseSession), 'the response stays pending until its refocus narration is heard'); - assert.ok(voicePlaybackService.pendingSessions.has(confirmationResource.toString()), 'the confirmation returns to normal panel ownership'); - }); - - test('without omni, focus transfers voice ownership and narrates a pending background response', async () => { - const voiceClientService = new TestVoiceClientService(); - const voicePlaybackService = new RecordingVoicePlaybackService(); - const chatWidgetService = new TestChatWidgetService(); - const controller = createController( - voiceClientService, undefined, undefined, undefined, undefined, undefined, - undefined, undefined, undefined, undefined, undefined, voicePlaybackService, chatWidgetService, - ); - const focusedSession = URI.parse('vscode-chat://focused-panel-session'); - const backgroundSession = URI.parse('vscode-chat://background-panel-session'); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as ( - sessionId: string, - state: string, - detail: string | undefined, - summary: string | undefined, - shown: string | undefined, - ) => void; - await controller.connect(mainWindow); - voiceClientService.fireConnectionState(true); - await voiceClientService.sessionCommandSent.p; - voiceClientService.fireSessionInit(); - controller.setTargetSession(focusedSession); - - handleStateChange.call(controller, focusedSession.toString(), 'idle', undefined, 'Focused response.', focusedSession.toString()); - handleStateChange.call(controller, backgroundSession.toString(), 'idle', undefined, 'Background response.', focusedSession.toString()); - - assert.deepStrictEqual(voiceClientService.requests.map(request => request.text), ['Focused response.']); - assert.ok(voicePlaybackService.pendingSessions.has(backgroundSession.toString())); - - chatWidgetService.focus(backgroundSession); - (Reflect.get(controller, '_onFocusedSessionChanged') as () => void).call(controller); - - assert.deepStrictEqual(voiceClientService.requests.map(request => request.text), ['Focused response.', 'Background response.']); - assert.strictEqual(controller.targetSession.get()?.toString(), backgroundSession.toString()); - }); - - test('materializing an untitled chat preserves Voice Mode ownership', async () => { - const voiceClientService = new TestVoiceClientService(); - const untitledSession = URI.parse('agent-host-copilotcli:/untitled-voice-session'); - const materializedSession = URI.parse('agent-host-copilotcli:/materialized-voice-session'); - const widget = store.add(new MaterializingChatWidget(untitledSession)); - const chatWidgetService = new TestChatWidgetService([widget]); - const controller = createController( - voiceClientService, undefined, undefined, undefined, undefined, undefined, - undefined, undefined, undefined, undefined, undefined, undefined, chatWidgetService, - ); - await controller.connect(mainWindow); - voiceClientService.fireConnectionState(true); - await voiceClientService.sessionCommandSent.p; - voiceClientService.fireSessionInit(); - controller.setTargetSession(untitledSession); - - widget.materialize(materializedSession); - - assert.strictEqual(controller.isConnected.get(), true); - assert.strictEqual(controller.targetSession.get()?.toString(), materializedSession.toString()); - }); - - test('stops tracking a removed chat widget', () => { - const voiceClientService = new TestVoiceClientService(); - const initialSession = URI.parse('agent-host-copilotcli:/initial-session'); - const removedSession = URI.parse('agent-host-copilotcli:/removed-session'); - const widget = store.add(new MaterializingChatWidget(initialSession)); - const widgetRemovals = store.add(new Emitter()); - const chatWidgetService = new TestChatWidgetService([widget], widgetRemovals.event); - const controller = createController( - voiceClientService, undefined, undefined, undefined, undefined, undefined, - undefined, undefined, undefined, undefined, undefined, undefined, chatWidgetService, - ); - - widgetRemovals.fire(widget); - Reflect.set(controller, '_lastShownSessionId', undefined); - widget.materialize(removedSession); - - assert.strictEqual(Reflect.get(controller, '_lastShownSessionId'), undefined); - }); - - test('plays responses for an omni-routed target without a pending indicator', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const controller = createController(voiceClientService, ttsPlaybackService); - const resource = URI.parse('vscode-chat://omni-target'); - await controller.connect(mainWindow); - voiceClientService.fireConnectionState(true); - await voiceClientService.sessionCommandSent.p; - controller.setTargetSession(resource, 'existing_session'); - - voiceClientService.fireAudioResponse({ - audio: 'omni response', - isFirstChunk: true, - isFinal: true, - codingSessionId: resource.toString(), - responseId: 'omni-response', - transcript: 'Omni response.', - }); - (Reflect.get(controller, '_pendingResponseSummaries') as Map).set(resource.toString(), 'Omni response.'); - ttsPlaybackService.stopPlayback(); - - assert.deepStrictEqual({ - playedAudio: ttsPlaybackService.playedAudio, - followupSession: controller.getLastSpokenResponseSession()?.toString(), - deferredResponses: (Reflect.get(controller, '_deferredResponses') as Map).size, - pendingResponses: (Reflect.get(controller, '_pendingResponseSummaries') as Map).size, - }, { - playedAudio: ['omni response'], - followupSession: resource.toString(), - deferredResponses: 0, - pendingResponses: 0, - }); - }); - - test('queues an omni-routed response while other audio is playing', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const controller = createController(voiceClientService, ttsPlaybackService); - const resource = URI.parse('vscode-chat://omni-target'); - await controller.connect(mainWindow); - voiceClientService.fireConnectionState(true); - await voiceClientService.sessionCommandSent.p; - controller.setTargetSession(resource, 'existing_session'); - - voiceClientService.fireAudioResponse({ - audio: 'current audio', - isFirstChunk: true, - isFinal: true, - responseId: 'current-response', - transcript: 'Current audio.', - }); - voiceClientService.fireAudioResponse({ - audio: 'queued omni response', - isFirstChunk: true, - isFinal: true, - codingSessionId: resource.toString(), - responseId: 'omni-response', - transcript: 'Queued omni response.', - }); - - assert.deepStrictEqual({ - playedAudio: ttsPlaybackService.playedAudio, - queuedResponses: (Reflect.get(controller, '_audioQueue') as unknown[]).length, - pendingResponses: (Reflect.get(controller, '_pendingResponseSummaries') as Map).size, - }, { - playedAudio: ['current audio'], - queuedResponses: 1, - pendingResponses: 0, - }); - - ttsPlaybackService.stopPlayback(); - (Reflect.get(controller, '_processQueue') as () => void).call(controller); - - assert.deepStrictEqual(ttsPlaybackService.playedAudio, ['current audio', 'queued omni response']); - }); - - test('preparing a new route releases the recent target and cancels its stale audio', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const controller = createController(voiceClientService, ttsPlaybackService); - const resource = URI.parse('vscode-chat://recent-target'); - await controller.connect(mainWindow); - voiceClientService.fireConnectionState(true); - await voiceClientService.sessionCommandSent.p; - controller.setTargetSession(resource, 'existing_session'); - - voiceClientService.fireAudioResponse({ - audio: 'stale response', - isFirstChunk: true, - isFinal: true, - codingSessionId: resource.toString(), - responseId: 'stale-response', - transcript: 'Stale response.', - }); - controller.prepareForRoutingRequest(); - - assert.deepStrictEqual({ - target: (Reflect.get(controller, '_targetSession') as { get(): URI | undefined }).get(), - stopCount: ttsPlaybackService.stopCount, - queuedResponses: (Reflect.get(controller, '_audioQueue') as unknown[]).length, - }, { - target: undefined, - stopCount: 1, - queuedResponses: 0, - }); - }); - - test('narrates a completed omni-routed response when its session is not shown', () => { - const voiceClientService = new TestVoiceClientService(); - const controller = createController(voiceClientService); - const resource = URI.parse('vscode-chat://omni-target'); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as (sessionId: string, state: string, detail: string | undefined, summary: string | undefined, shown: string | undefined) => void; - controller.setTargetSession(resource, 'new_session'); - - handleStateChange.call(controller, resource.toString(), 'idle', undefined, 'The omni task is complete.', 'vscode-chat://different-session'); - - assert.deepStrictEqual({ - narrations: voiceClientService.requests.map(request => ({ sessionId: request.sessionId, kind: request.kind, text: request.text })), - pendingResponses: (Reflect.get(controller, '_pendingResponseSummaries') as Map).size, - }, { - narrations: [{ sessionId: resource.toString(), kind: 'response', text: 'The omni task is complete.' }], - pendingResponses: 0, - }); - }); - - test('narrates the completion summary for the current omni-routed request', () => { - const voiceClientService = new TestVoiceClientService(); - const chatService = new ControllableChatService(); - const controller = createController(voiceClientService, undefined, undefined, undefined, undefined, undefined, chatService); - const resource = URI.parse('vscode-chat://omni-target'); - const sessionId = resource.toString(); - const lastRequest = { - id: 'local-queued-request-id', - response: { - onDidChange: Event.None, - isPendingConfirmation: observableValue('pending', undefined), - isIncomplete: observableValue('incomplete', true), - response: { value: [], getMarkdown: () => '' }, - }, - }; - chatService.setModels([{ - sessionResource: resource, - title: 'Omni target', - getRequests: () => [lastRequest], - lastRequestObs: observableValue('lastRequest', lastRequest), - } as unknown as IChatModel]); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as (sessionId: string, state: string, detail: string | undefined, summary: string | undefined, shown: string | undefined) => void; - const cacheResponseSummary = Reflect.get(controller, '_cacheResponseSummary') as (sessionId: string, state: string, summary: string | undefined) => void; - controller.setTargetSession(resource, 'existing_session'); - controller.markRoutedRequestPending(resource, 'local-queued-request-id'); - - // Raw state observes thinking, but the settled narration changes collapse - // idle → thinking → idle and never emit a separate thinking callback. - cacheResponseSummary.call(controller, sessionId, 'thinking', undefined); - handleStateChange.call(controller, sessionId, 'idle', undefined, undefined, undefined); - handleStateChange.call(controller, sessionId, 'idle', undefined, 'The current omni request is complete.', undefined); - - assert.deepStrictEqual(voiceClientService.requests.map(request => ({ - sessionId: request.sessionId, - kind: request.kind, - text: request.text, - })), [{ - sessionId, - kind: 'response', - text: 'The current omni request is complete.', - }]); - }); - - test('narrates an omni chat completion with its backend coding-session resource', () => { - const voiceClientService = new TestVoiceClientService(); - const chatService = new ControllableChatService(); - const controller = createController(voiceClientService, undefined, undefined, undefined, undefined, undefined, chatService); - const chatResource = URI.parse('agent-host-copilotcli:/chat-1'); - const lastRequest = { - id: 'routed-request', - response: { - onDidChange: Event.None, - isPendingConfirmation: observableValue('pending', undefined), - isIncomplete: observableValue('incomplete', true), - response: { value: [], getMarkdown: () => '' }, - }, - }; - chatService.setModels([{ - sessionResource: chatResource, - title: 'Omni target', - getRequests: () => [lastRequest], - lastRequestObs: observableValue('lastRequest', lastRequest), - } as unknown as IChatModel]); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as (sessionId: string, state: string, detail: string | undefined, summary: string | undefined, shown: string | undefined) => void; - controller.setTargetSession(chatResource, 'existing_session'); - controller.markRoutedRequestPending(chatResource, 'routed-request'); - - handleStateChange.call(controller, chatResource.toString(), 'idle', undefined, 'The routed task is complete.', undefined); - - assert.deepStrictEqual(voiceClientService.requests.map(request => ({ - sessionId: request.sessionId, - kind: request.kind, - text: request.text, - })), [{ - sessionId: 'copilotcli:/chat-1', - kind: 'response', - text: 'The routed task is complete.', - }]); - }); - - test('keeps routed ownership until an omni completion is heard after approvals', () => { - const voiceClientService = new TestVoiceClientService(); - const chatService = new ControllableChatService(); - const controller = createController(voiceClientService, undefined, undefined, undefined, undefined, undefined, chatService); - const resource = URI.parse('vscode-chat://omni-target'); - const sessionId = resource.toString(); - const lastRequest = { - id: 'routed-request', - response: { - onDidChange: Event.None, - isPendingConfirmation: observableValue('pending', undefined), - isIncomplete: observableValue('incomplete', false), - response: { value: [], getMarkdown: () => 'The routed task is complete.' }, - }, - }; - chatService.setModels([{ - sessionResource: resource, - title: 'Omni target', - getRequests: () => [lastRequest], - lastRequestObs: observableValue('lastRequest', lastRequest), - } as unknown as IChatModel]); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as (sessionId: string, state: string, detail: string | undefined, summary: string | undefined, shown: string | undefined) => void; - const markNarrationHeard = Reflect.get(controller, '_markNarrationHeard') as (narrationId: string) => void; - - controller.setTargetSession(resource, 'existing_session'); - controller.markRoutedRequestPending(resource, lastRequest.id); - // Answering an approval releases the floating input's focus target. The - // routed request itself must keep voice ownership through the final reply. - controller.setOmniInputActive(true); - controller.setOmniInputActive(false); - handleStateChange.call(controller, sessionId, 'idle', undefined, 'The routed task is complete.', 'vscode-chat://different-session'); - - const [narration] = voiceClientService.requests; - assert.deepStrictEqual({ - narration: narration && { sessionId: narration.sessionId, kind: narration.kind, text: narration.text }, - routeBeforePlayback: Reflect.get(controller, '_routedRequests'), - }, { - narration: { sessionId, kind: 'response', text: 'The routed task is complete.' }, - routeBeforePlayback: new Map([[sessionId, { requestId: lastRequest.id, hasMatchedModelRequest: true, phase: 'queued' }]]), - }); - - markNarrationHeard.call(controller, narration.narrationId); - assert.strictEqual((Reflect.get(controller, '_routedRequests') as Map).size, 0); - }); - - test('does not mistake an approval acknowledgement for the routed completion', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const chatService = new ControllableChatService(); - const controller = createController(voiceClientService, ttsPlaybackService, undefined, undefined, undefined, undefined, chatService); - const resource = URI.parse('vscode-chat://omni-target'); - const sessionId = resource.toString(); - const lastRequest = { - id: 'routed-request', - response: { - onDidChange: Event.None, - isPendingConfirmation: observableValue('pending', undefined), - isIncomplete: observableValue('incomplete', true), - response: { value: [], getMarkdown: () => '' }, - }, - }; - chatService.setModels([{ - sessionResource: resource, - title: 'Omni target', - getRequests: () => [lastRequest], - lastRequestObs: observableValue('lastRequest', lastRequest), - } as unknown as IChatModel]); - const cacheResponseSummary = Reflect.get(controller, '_cacheResponseSummary') as (sessionId: string, state: string, summary: string | undefined) => void; - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as (sessionId: string, state: string, detail: string | undefined, summary: string | undefined, shown: string | undefined) => void; - - await controller.connect(mainWindow); - voiceClientService.fireConnectionState(true); - await voiceClientService.sessionCommandSent.p; - controller.setTargetSession(resource, 'existing_session'); - controller.markRoutedRequestPending(resource, lastRequest.id); - cacheResponseSummary.call(controller, sessionId, 'thinking', undefined); - - voiceClientService.fireAudioResponse({ - audio: 'approval accepted', - isFirstChunk: true, - isFinal: true, - codingSessionId: sessionId, - responseId: 'approval-acknowledgement', - transcript: 'Approval accepted.', - }); - handleStateChange.call(controller, sessionId, 'idle', undefined, 'The routed task is complete.', undefined); - ttsPlaybackService.stopPlayback(); - - assert.deepStrictEqual({ - narrations: voiceClientService.requests.map(request => ({ kind: request.kind, text: request.text })), - routeRetained: (Reflect.get(controller, '_routedRequests') as Map).has(sessionId), - }, { - narrations: [{ kind: 'response', text: 'The routed task is complete.' }], - routeRetained: true, - }); - }); - - test('narrates an omni-routed confirmation when its session is not shown', () => { - const voiceClientService = new TestVoiceClientService(); - const controller = createController(voiceClientService); - const resource = URI.parse('vscode-chat://omni-target'); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as (sessionId: string, state: string, detail: string | undefined, summary: string | undefined, shown: string | undefined, confirmationType?: VoiceConfirmationType) => void; - controller.setTargetSession(resource, 'existing_session'); - - handleStateChange.call(controller, resource.toString(), 'waiting_for_confirmation', 'Allow running the tests?', undefined, 'vscode-chat://different-session', 'tool'); - - assert.deepStrictEqual(voiceClientService.requests.map(request => ({ - sessionId: request.sessionId, - kind: request.kind, - text: request.text, - confirmationType: request.confirmationType, - })), [{ - sessionId: resource.toString(), - kind: 'confirmation', - text: 'Allow running the tests?', - confirmationType: 'tool', - }]); - }); - - test('an omni confirmation discards older response audio for its session', () => { - const voiceClientService = new TestVoiceClientService(); - const controller = createController(voiceClientService); - const resource = URI.parse('vscode-chat://omni-target'); - const sessionId = resource.toString(); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as (sessionId: string, state: string, detail: string | undefined, summary: string | undefined, shown: string | undefined, confirmationType?: VoiceConfirmationType) => void; - controller.setTargetSession(resource, 'existing_session'); - (Reflect.get(controller, '_pendingResponseSummaries') as Map).set(sessionId, 'The older response.'); - (Reflect.get(controller, '_lastResponseSummaryById') as Map).set(sessionId, 'The older response.'); - (Reflect.get(controller, '_audioQueue') as unknown[]).push({ - sessionId, - responseId: 'older-response', - finalized: true, - chunks: [{ audio: 'older audio', isFirstChunk: true, isFinal: true, transcript: 'The older response.' }], - }); - (Reflect.get(controller, '_deferredResponses') as Map).set(sessionId, [{ - responseId: 'older-deferred-response', - finalized: true, - chunks: [{ audio: 'older deferred audio', isFirstChunk: true, isFinal: true, transcript: 'An older deferred response.' }], - }]); - - handleStateChange.call(controller, sessionId, 'waiting_for_confirmation', 'Allow writing the file?', undefined, undefined, 'tool'); - - assert.deepStrictEqual({ - queuedResponses: (Reflect.get(controller, '_audioQueue') as unknown[]).length, - deferredResponses: (Reflect.get(controller, '_deferredResponses') as Map).size, - pendingResponses: (Reflect.get(controller, '_pendingResponseSummaries') as Map).size, - cachedSummaries: (Reflect.get(controller, '_lastResponseSummaryById') as Map).size, - narrations: voiceClientService.requests.map(request => ({ kind: request.kind, text: request.text })), - }, { - queuedResponses: 0, - deferredResponses: 0, - pendingResponses: 0, - cachedSummaries: 0, - narrations: [{ kind: 'confirmation', text: 'Allow writing the file?' }], - }); - }); - - test('an omni response summary is not requested while its direct audio is still playing', () => { - const voiceClientService = new TestVoiceClientService(); - const controller = createController(voiceClientService); - const resource = URI.parse('vscode-chat://omni-target'); - const sessionId = resource.toString(); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as (sessionId: string, state: string, detail: string | undefined, summary: string | undefined, shown: string | undefined) => void; - controller.setTargetSession(resource, 'existing_session'); - Reflect.set(controller, '_currentPlaybackSessionId', sessionId); - Reflect.set(controller, '_currentPlaybackNarration', undefined); - - handleStateChange.call(controller, sessionId, 'idle', undefined, 'The completed response.', undefined); - - assert.deepStrictEqual(voiceClientService.requests, []); - }); - - test('open omni narrates a completed response after same-session acknowledgement audio', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const controller = createController(voiceClientService, ttsPlaybackService); - const sessionId = URI.parse('vscode-chat://global-omni-response').toString(); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as (sessionId: string, state: string, detail: string | undefined, summary: string | undefined, shown: string | undefined) => void; - showSessionsInAgentsList(controller, sessionId); - await connectWithOmniOpen(controller, voiceClientService); - - voiceClientService.fireAudioResponse({ - audio: 'Okay.', - isFirstChunk: true, - isFinal: true, - codingSessionId: sessionId, - responseId: 'approval-acknowledgement', - transcript: 'Okay.', - }); - handleStateChange.call(controller, sessionId, 'idle', undefined, 'The requested work is complete.', undefined); - const beforePlaybackStopped = voiceClientService.requests.map(request => request.text); - - ttsPlaybackService.stopPlayback(); - - assert.deepStrictEqual({ - beforePlaybackStopped, - afterPlaybackStopped: voiceClientService.requests.map(request => ({ - sessionId: request.sessionId, - kind: request.kind, - text: request.text, - })), - }, { - beforePlaybackStopped: [], - afterPlaybackStopped: [{ - sessionId, - kind: 'response', - text: 'The requested work is complete.', - }], - }); - }); - - test('a queued routed request suppresses the session previous idle response', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const chatService = new ControllableChatService(); - const controller = createController(voiceClientService, ttsPlaybackService, undefined, undefined, undefined, undefined, chatService); - const resource = URI.parse('vscode-chat://omni-target'); - const sessionId = resource.toString(); - const lastRequest = { - id: 'previous-request', - response: { - onDidChange: Event.None, - isPendingConfirmation: observableValue('previousPending', undefined), - isIncomplete: observableValue('previousIncomplete', false), - response: { value: [], getMarkdown: () => 'The response from before the queued request.' }, - }, - }; - let lastRequestId = lastRequest.id; - chatService.setModels([{ - sessionResource: resource, - title: 'Omni target', - getRequests: () => [{ ...lastRequest, id: lastRequestId }], - lastRequestObs: observableValue('lastRequest', lastRequest), - } as unknown as IChatModel]); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as (sessionId: string, state: string, detail: string | undefined, summary: string | undefined, shown: string | undefined) => void; - await controller.connect(mainWindow); - voiceClientService.fireConnectionState(true); - await voiceClientService.sessionCommandSent.p; - controller.setTargetSession(resource, 'existing_session'); - controller.markRoutedRequestPending(resource); - controller.markRoutedRequestPending(resource, 'new-request'); - - handleStateChange.call(controller, sessionId, 'idle', undefined, 'The response from before the queued request.', undefined); - voiceClientService.fireAudioResponse({ - audio: 'old queued response', - isFirstChunk: true, - isFinal: true, - codingSessionId: sessionId, - responseId: 'old-queued-response', - transcript: 'The response from before the queued request.', - }); - handleStateChange.call(controller, sessionId, 'waiting_for_confirmation', undefined, undefined, undefined); - voiceClientService.fireAudioResponse({ - audio: 'old response after prompt', - isFirstChunk: true, - isFinal: true, - codingSessionId: sessionId, - responseId: 'old-response-after-prompt', - transcript: 'The response from before the queued request.', - }); - lastRequestId = 'new-request'; - handleStateChange.call(controller, sessionId, 'thinking', undefined, undefined, undefined); - voiceClientService.fireAudioResponse({ - audio: 'new queued response', - isFirstChunk: true, - isFinal: true, - codingSessionId: sessionId, - responseId: 'new-queued-response', - transcript: 'The new queued request is complete.', - }); - handleStateChange.call(controller, sessionId, 'idle', undefined, 'The new queued request is complete.', undefined); - - assert.deepStrictEqual({ - playedAudio: ttsPlaybackService.playedAudio, - narrations: voiceClientService.requests.map(request => request.text), - }, { - playedAudio: ['new queued response'], - narrations: [], - }); - }); - - test('a queued routed request does not inherit the previous request busy state', () => { - const chatService = new ControllableChatService(); - const controller = createController(new TestVoiceClientService(), undefined, undefined, undefined, undefined, undefined, chatService); - const resource = URI.parse('vscode-chat://busy-omni-target'); - const previousRequest = { - id: 'previous-request', - response: { - onDidChange: Event.None, - isPendingConfirmation: observableValue('previousPending', undefined), - isIncomplete: observableValue('previousIncomplete', true), - response: { value: [], getMarkdown: () => '' }, - }, - }; - chatService.setModels([{ - sessionResource: resource, - title: 'Busy omni target', - getRequests: () => [previousRequest], - lastRequestObs: observableValue('previousLastRequest', previousRequest), - } as unknown as IChatModel]); - - controller.markRoutedRequestPending(resource); - controller.markRoutedRequestPending(resource, 'new-request'); - - assert.deepStrictEqual( - Reflect.get(controller, '_routedRequests'), - new Map([[resource.toString(), { requestId: 'new-request', previousRequestId: 'previous-request', phase: 'queued' }]]), - ); - }); - - test('an older idle response does not clear a newly queued routed request', () => { - const voiceClientService = new TestVoiceClientService(); - const chatService = new ControllableChatService(); - const controller = createController(voiceClientService, undefined, undefined, undefined, undefined, undefined, chatService); - const resource = URI.parse('vscode-chat://omni-target'); - const previousResponse = completedResponseModel('The previous request is complete.'); - const lastRequest = previousResponse.getRequests().at(-1)!; - const model = { - sessionResource: resource, - title: 'Omni target', - getRequests: () => [lastRequest], - lastRequestObs: observableValue('lastRequest', lastRequest), - } as unknown as IChatModel; - chatService.setModels([model]); - - controller.markRoutedRequestPending(resource); - - assert.deepStrictEqual( - Reflect.get(controller, '_routedRequests'), - new Map([[resource.toString(), { requestId: undefined, previousRequestId: null, phase: 'queued' }]]), - ); - }); - - test('adopts the request id when a queued omni route appears in the model', () => { - const chatService = new ControllableChatService(); - const controller = createController(new TestVoiceClientService(), undefined, undefined, undefined, undefined, undefined, chatService); - const resource = URI.parse('vscode-chat://omni-target'); - let lastRequestId = 'previous-request'; - chatService.setModels([{ - sessionResource: resource, - title: 'Omni target', - getRequests: () => [{ id: lastRequestId }], - lastRequestObs: observableValue('lastRequest', undefined), - } as unknown as IChatModel]); - - controller.markRoutedRequestPending(resource); - lastRequestId = 'new-request'; - const cacheResponseSummary = Reflect.get(controller, '_cacheResponseSummary') as (sessionId: string, state: string, summary: string | undefined) => void; - cacheResponseSummary.call(controller, resource.toString(), 'thinking', undefined); - - assert.deepStrictEqual( - Reflect.get(controller, '_routedRequests'), - new Map([[resource.toString(), { requestId: undefined, modelRequestId: 'new-request', hasMatchedModelRequest: true, phase: 'running' }]]), - ); - }); - - test('keeps confirmations and the final response on a route whose durable model id differs from its transient send id', async () => { - const voiceClientService = new TestVoiceClientService(); - const chatService = new ControllableChatService(); - const controller = createController(voiceClientService, undefined, undefined, undefined, undefined, undefined, chatService); - const resource = URI.parse('agent-host-copilotcli://durable-route'); - let lastRequestId = 'previous-request'; - chatService.setModels([{ - sessionResource: resource, - title: 'Durable route', - getRequests: () => [{ id: lastRequestId }], - lastRequestObs: observableValue('lastRequest', undefined), - } as unknown as IChatModel]); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as ( - sessionId: string, - state: string, - detail: string | undefined, - summary: string | undefined, - shown: string | undefined, - confirmationType?: VoiceConfirmationType, - ) => void; - const markNarrationHeard = Reflect.get(controller, '_markNarrationHeard') as (narrationId: string) => void; - await connectWithOmniOpen(controller, voiceClientService); - controller.markRoutedRequestPending(resource); - controller.markRoutedRequestPending(resource, 'request_transient_123'); - - lastRequestId = 'b361715a-b9bf-4fe0-b763-d769ad8271a3'; - handleStateChange.call(controller, resource.toString(), 'waiting_for_confirmation', 'Allow the durable request?', undefined, 'vscode-chat://different-session', 'tool'); - const routeAfterConfirmation = (Reflect.get(controller, '_routedRequests') as Map).get(resource.toString()); - markNarrationHeard.call(controller, voiceClientService.requests[0].narrationId); - await Promise.resolve(); - handleStateChange.call(controller, resource.toString(), 'idle', undefined, 'The durable request is complete.', 'vscode-chat://different-session'); - - assert.deepStrictEqual({ - routeAfterConfirmation, - requests: voiceClientService.requests.map(request => ({ kind: request.kind, text: request.text })), - }, { - routeAfterConfirmation: { - requestId: 'request_transient_123', - modelRequestId: 'b361715a-b9bf-4fe0-b763-d769ad8271a3', - phase: 'waiting', - }, - requests: [ - { kind: 'confirmation', text: 'Allow the durable request?' }, - { kind: 'response', text: 'The durable request is complete.' }, - ], - }); - }); - - test('keeps an in-flight omni route live after the floating input releases focus', () => { - const controller = createController(new TestVoiceClientService()); - const resource = URI.parse('agent-host-copilotcli:/omni-target'); - const shouldDefer = Reflect.get(controller, '_shouldDeferForSession') as (sessionId: string) => boolean; - - controller.setTargetSession(resource, 'new_session'); - controller.markRoutedRequestPending(resource, 'new-request'); - controller.setOmniInputActive(true); - controller.setOmniInputActive(false); - - assert.strictEqual(shouldDefer.call(controller, resource.toString()), false); - }); - - test('loads an unloaded omni-routed session so its final response is observable', async () => { - const voiceClientService = new TestVoiceClientService(); - const chatService = new TrackingLoadChatService(); - const controller = createController(voiceClientService, undefined, undefined, undefined, undefined, undefined, chatService); - const resource = URI.parse('agent-host-copilotcli:/new-omni-target'); - - await connectWithOmniOpen(controller, voiceClientService); - controller.markRoutedRequestPending(resource, 'new-request'); - await Promise.resolve(); - - assert.deepStrictEqual(chatService.loaded, [resource.toString()]); - }); - - test('retains a resident omni-routed session until its final response is observable', async () => { - const voiceClientService = new TestVoiceClientService(); - const chatService = new TrackingLoadChatService(); - const controller = createController(voiceClientService, undefined, undefined, undefined, undefined, undefined, chatService); - const resource = URI.parse('agent-host-copilotcli:/resident-omni-target'); - chatService.setResident(resource); - - await connectWithOmniOpen(controller, voiceClientService); - controller.markRoutedRequestPending(resource, 'new-request'); - await Promise.resolve(); - - assert.deepStrictEqual(chatService.loaded, [resource.toString()]); - }); - - test('retains an eager model reference while an omni-routed request is running', () => { - const chatService = new ControllableChatService(); - const resource = URI.parse('agent-host-copilotcli:/running-omni-target'); - const lastRequest = { - id: 'running-request', - response: { - onDidChange: Event.None, - isPendingConfirmation: observableValue('pending', undefined), - isIncomplete: observableValue('incomplete', true), - response: { value: [], getMarkdown: () => '' }, - }, - }; - chatService.setModels([{ - sessionResource: resource, - getRequests: () => [lastRequest], - lastRequestObs: observableValue('lastRequest', lastRequest), - } as unknown as IChatModel]); - const controller = createController(new TestVoiceClientService(), undefined, undefined, undefined, undefined, undefined, chatService); - let disposeCount = 0; - const eagerRefs = Reflect.get(controller, '_eagerModelRefs') as Map; - const releaseUnused = Reflect.get(controller, '_releaseUnusedEagerModelRefs') as (stillWaiting: ReadonlySet) => void; - eagerRefs.set(resource.toString(), { - object: {}, - dispose: () => disposeCount++, - } as unknown as IChatModelReference); - controller.markRoutedRequestPending(resource, 'running-request'); - controller.markRoutedRequestPending(resource, 'running-request'); - releaseUnused.call(controller, new Set()); - - assert.deepStrictEqual({ - disposeCount, - route: (Reflect.get(controller, '_routedRequests') as Map).get(resource.toString()), - }, { - disposeCount: 0, - route: { requestId: 'running-request', hasMatchedModelRequest: true, phase: 'running' }, - }); - - controller.clearRoutedRequest(resource); - assert.strictEqual(disposeCount, 1); - }); - - test('opening omni keeps completed response tracking bounded', () => { - const chatService = new ControllableChatService(); - const models = Array.from({ length: 300 }, (_, index) => { - const resource = URI.parse(`vscode-chat://completed-${index}`); - const response = { - id: `response-${index}`, - onDidChange: Event.None, - isPendingConfirmation: observableValue('pending', undefined), - isIncomplete: observableValue('incomplete', false), - isComplete: true, - isCanceled: false, - response: { value: [], getMarkdown: () => `Completed ${index}` }, - }; - const lastRequest = { id: `request-${index}`, response }; - return { - sessionResource: resource, - title: `Completed ${index}`, - lastRequest, - lastRequestObs: observableValue('lastRequest', lastRequest), - getRequests: () => [lastRequest], - } as unknown as IChatModel; - }); - chatService.setModels(models); - const controller = createController(new TestVoiceClientService(), undefined, undefined, undefined, undefined, undefined, chatService); - - controller.setOmniInputOpen(true); - - assert.strictEqual((Reflect.get(controller, '_omniCompletedResponseIds') as Set).size, 256); - }); - - test('an omni response is never requested again after it has been heard', () => { - const voiceClientService = new TestVoiceClientService(); - const controller = createController(voiceClientService); - const resource = URI.parse('vscode-chat://omni-target'); - const sessionId = resource.toString(); - const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string) => boolean; - controller.setTargetSession(resource, 'existing_session'); - (Reflect.get(controller, '_lastHeardTranscriptById') as Map).set(sessionId, 'the completed response with extra detail'); - - assert.strictEqual(narrate.call(controller, sessionId, 'response', 'The completed response.'), false); - assert.deepStrictEqual(voiceClientService.requests, []); - }); - - test('an awaited reply does not replay an already heard omni response', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const controller = createController(voiceClientService, ttsPlaybackService); - const resource = URI.parse('vscode-chat://omni-target'); - const sessionId = resource.toString(); - await controller.connect(mainWindow); - voiceClientService.fireConnectionState(true); - await voiceClientService.sessionCommandSent.p; - controller.setTargetSession(resource, 'existing_session'); - (Reflect.get(controller, '_lastHeardTranscriptById') as Map).set(sessionId, 'the old completed response'); - (Reflect.get(controller, '_setAwaitingReply') as () => void).call(controller); - - voiceClientService.fireAudioResponse({ - audio: 'old response audio', - isFirstChunk: true, - isFinal: true, - codingSessionId: sessionId, - responseId: 'old-response-rerender', - transcript: 'The old completed response.', - }); - - assert.deepStrictEqual(ttsPlaybackService.playedAudio, []); - }); - - test('heard omni responses remain deduplicated across voice reconnects', () => { - const controller = createController(new TestVoiceClientService()); - const sessionId = 'vscode-chat://omni-target'; - (Reflect.get(controller, '_lastHeardTranscriptById') as Map).set(sessionId, 'the completed response'); - - controller.disconnect('explicit'); - - assert.strictEqual((Reflect.get(controller, '_lastHeardTranscriptById') as Map).get(sessionId), 'the completed response'); - }); - - test('does not mark an omni-routed confirmation as pending in the sessions list', () => { - const controller = createController(new TestVoiceClientService()); - const resource = URI.parse('vscode-chat://omni-target'); - const reconcileIndicators = Reflect.get(controller, '_reconcileConfirmationIndicators') as (sessionIds: Set) => void; - controller.setTargetSession(resource, 'existing_session'); - - reconcileIndicators.call(controller, new Set([resource.toString()])); - - assert.strictEqual((Reflect.get(controller, '_confirmationPendingSessions') as Set).size, 0); - }); - - test('supersedes stale confirmation narration for an omni-routed session', () => { - const voiceClientService = new TestVoiceClientService(); - const controller = createController(voiceClientService); - const resource = URI.parse('vscode-chat://omni-target'); - const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: VoiceNarrationKind, text: string) => boolean; - controller.setTargetSession(resource, 'existing_session'); - - assert.strictEqual(narrate.call(controller, resource.toString(), 'confirmation', 'Allow the old action?'), true); - const oldNarrationId = voiceClientService.requests[0].narrationId; - assert.strictEqual(narrate.call(controller, resource.toString(), 'confirmation', 'Allow the updated action?'), true); - - assert.deepStrictEqual({ - requests: voiceClientService.requests.map(request => request.text), - cancelledOldNarration: (Reflect.get(controller, '_cancelledPendingNarrationIds') as Set).has(oldNarrationId), - pendingNarrations: [...(Reflect.get(controller, '_pendingSolicitedNarrations') as Map).values()].map(pending => pending.text), - }, { - requests: ['Allow the old action?', 'Allow the updated action?'], - cancelledOldNarration: true, - pendingNarrations: ['Allow the updated action?'], - }); - }); - - test('grounds the active session with its selected model and attachment names', () => { - const chatService = new ControllableChatService(); - const resource = URI.parse('vscode-chat://regular/session-aware'); - const lastRequest = { - id: 'request-1', - response: { - isPendingConfirmation: observableValue('pending', undefined), - isIncomplete: observableValue('incomplete', false), - response: { value: [], getMarkdown: () => '' }, - }, - }; - const model = { - sessionResource: resource, - title: 'Session awareness', - lastMessageDate: Date.now(), - getRequests: () => [lastRequest], - lastRequestObs: observableValue('lastRequest', lastRequest), - inputModel: { - state: observableValue('inputState', { - selectedModel: { - identifier: 'copilot/gpt-5', - metadata: { name: 'GPT-5', vendor: 'copilot' }, - }, - attachments: [{ kind: 'file', name: 'voiceSessionController.ts' }, { kind: 'file', name: 'README.md' }], - }), - }, - } as unknown as IChatModel; - chatService.setModels([model]); - const controller = createController(new TestVoiceClientService(), undefined, undefined, undefined, undefined, undefined, chatService); - controller.setActiveSessionShown(resource); - const buildSessionContext = Reflect.get(controller, '_buildSessionContext') as () => IVoiceSessionContext; - - const [session] = buildSessionContext.call(controller).sessions; + const [session] = buildSessionContext.call(controller).sessions; assert.deepStrictEqual({ session_type: session.session_type, @@ -6376,7 +4769,7 @@ suite('VoiceSessionController', () => { isPartial: false, }, acceptedInputs: ['actually scratch that and check the code in the repository'], - toolResults: [{ callId: 'send-follow-up', result: 'ok', codingSessionId: 'file:///chat-session' }], + toolResults: [{ callId: 'send-follow-up', result: 'ok' }], }); }); @@ -6559,12 +4952,12 @@ suite('VoiceSessionController', () => { deferredNarrations: deferredNarrations.size, }, { requests: [{ - sessionId: 'copilot:/session-1', + sessionId, kind: 'response', text: 'Done', narrationId: 'narration-1', }, { - sessionId: 'copilot:/session-1', + sessionId, kind: 'response', text: 'Done', narrationId: 'narration-2', @@ -6718,7 +5111,7 @@ suite('VoiceSessionController', () => { assert.strictEqual(narrate.call(controller, 'agent-host-copilot:/session-1', 'response', 'Done'), true); assert.deepStrictEqual(voiceClientService.requests, [{ - sessionId: 'copilot:/session-1', + sessionId: 'agent-host-copilot:/session-1', kind: 'response', text: 'Done', narrationId: 'narration-1', @@ -6852,317 +5245,14 @@ suite('VoiceSessionController', () => { }); await voiceClientService.toolResultReceived; - assert.deepStrictEqual(commandService.acceptedInputs, ['send this when listening stops']); - }); - - test('omni waits for the routed response before returning to listening', async () => { - const voiceClientService = new TestVoiceClientService(); - const commandService = new TestCommandService(true); - const mic = new RecordingMicCaptureService(); - const controller = createController( - voiceClientService, - undefined, - commandService, - undefined, - mic, - new TestConfigurationService({ 'agents.voice.handsFree': true, [VOICE_AGENT_PROGRESS_SETTING]: true }), - ); - await controller.connect(mainWindow); - (Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }).set(true, undefined); - controller.setOmniInputActive(true); - clock.tick(1); - - voiceClientService.fireToolCall({ - callId: 'omni-send', - name: 'send_to_chat', - args: { text: 'continue the related task' }, - }); - await voiceClientService.toolResultReceived; - - assert.deepStrictEqual({ - state: controller.voiceState.get(), - status: controller.statusText.get(), - micStarts: mic.pttDownCalls.length, - }, { - state: 'processing', - status: 'Waiting for response...', - micStarts: 0, - }); - }); - - test('omni drops the voice dispatch acknowledgement and plays the completed response narration', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const commandService = new TestCommandService(); - const controller = createController(voiceClientService, ttsPlaybackService, commandService); - const sessionId = URI.parse('chat-session').toString(); - const handleStateChange = Reflect.get(controller, '_handleNarratableStateChange') as ( - sessionId: string, - state: string, - detail: string | undefined, - summary: string | undefined, - shown: string | undefined, - ) => void; - showSessionsInAgentsList(controller, sessionId); - await connectWithOmniOpen(controller, voiceClientService); - - voiceClientService.fireToolCall({ - callId: 'omni-dispatch-ack', - name: 'send_to_chat', - args: { text: 'continue the related task' }, - }); - await voiceClientService.toolResultReceived; - assert.notStrictEqual(Reflect.get(controller, '_pendingOmniDispatchAcknowledgement'), undefined); - voiceClientService.fireAudioResponse({ - audio: 'The command is done.', - isFirstChunk: true, - isFinal: true, - codingSessionId: sessionId, - transcript: 'The command is done.', - }); - assert.deepStrictEqual({ - pendingAcknowledgement: Reflect.get(controller, '_pendingOmniDispatchAcknowledgement'), - playedAudio: ttsPlaybackService.playedAudio, - }, { - pendingAcknowledgement: undefined, - playedAudio: [], - }); - - handleStateChange.call(controller, sessionId, 'idle', undefined, 'The actual task is complete.', undefined); - const narrationId = voiceClientService.requests[0].narrationId; - voiceClientService.fireAudioResponse({ - audio: 'The actual task is complete.', - isFirstChunk: true, - isFinal: true, - codingSessionId: sessionId, - responseId: narrationId, - transcript: 'The actual task is complete.', - narrationKind: 'response', - }); - - assert.deepStrictEqual(ttsPlaybackService.playedAudio, ['The actual task is complete.']); - }); - - test('omni waits for the dispatch acknowledgement before narrating a confirmation', async () => { - const voiceClientService = new TestVoiceClientService(); - const commandService = new TestCommandService(true); - const chatService = new ControllableChatService(); - const resource = URI.parse('agent-host-copilotcli:/omni-confirmation-after-dispatch'); - const backendResource = URI.parse('copilotcli:/omni-confirmation-after-dispatch'); - const response = { - onDidChange: Event.None, - isPendingConfirmation: observableValue<{ detail?: string } | undefined>('pending', { detail: 'Needs approval' }), - isIncomplete: observableValue('incomplete', false), - response: { value: [] as readonly { kind: string }[], getMarkdown: () => '' }, - }; - const lastRequest = { id: 'confirmation-request', response }; - chatService.setModels([{ - sessionResource: resource, - title: 'Chat', - getRequests: () => [lastRequest], - lastRequestObs: observableValue('lastRequest', lastRequest), - } as unknown as IChatModel]); - const controller = createController(voiceClientService, undefined, commandService, undefined, undefined, undefined, chatService); - showSessionsInAgentsList(controller, resource.toString()); - await connectWithOmniOpen(controller, voiceClientService); - - voiceClientService.fireToolCall({ - callId: 'omni-confirmation-dispatch', - name: 'send_to_chat', - args: { text: 'run the tests' }, - }); - controller.announceSessionInOmni(resource); - assert.strictEqual(voiceClientService.requests.length, 0); - - voiceClientService.fireAudioResponse({ - audio: 'I sent that request.', - isFirstChunk: true, - isFinal: true, - transcript: 'I sent that request.', - }); - await Promise.resolve(); - - const contextBeforeNarration = voiceClientService.wireEvents - .filter(event => event.type === 'session_context') - .at(-1); - assert.deepStrictEqual(voiceClientService.requests.map(request => ({ - sessionId: request.sessionId, - kind: request.kind, - text: request.text, - })), [{ - sessionId: backendResource.toString(), - kind: 'confirmation', - text: 'tool approval: GitHub Copilot needs your approval to continue.', - }]); - assert.strictEqual( - contextBeforeNarration?.type === 'session_context' - && contextBeforeNarration.context.sessions.some(session => session.id === backendResource.toString()), - true, - ); - - const firstRequest = voiceClientService.requests[0]; - voiceClientService.fireNarrationAck({ - narrationId: firstRequest.narrationId, - codingSessionId: backendResource.toString(), - disposition: 'invalid', - reason: 'stale_context', - }); - clock.tick(499); - assert.strictEqual(voiceClientService.requests.length, 1); - clock.tick(1); - assert.deepStrictEqual(voiceClientService.requests.map(request => ({ - sessionId: request.sessionId, - kind: request.kind, - text: request.text, - })), [ - { sessionId: backendResource.toString(), kind: 'confirmation', text: 'tool approval: GitHub Copilot needs your approval to continue.' }, - { sessionId: backendResource.toString(), kind: 'confirmation', text: 'tool approval: GitHub Copilot needs your approval to continue.' }, - ]); - }); - - test('a solicited response before the dispatch acknowledgement does not strand a confirmation', async () => { - const voiceClientService = new TestVoiceClientService(); - const ttsPlaybackService = new TestTtsPlaybackService(); - const commandService = new TestCommandService(true); - const chatService = new ControllableChatService(); - const resource = URI.parse('vscode-chat://omni-response-before-ack'); - const response = { - onDidChange: Event.None, - isPendingConfirmation: observableValue<{ detail?: string } | undefined>('pending', { detail: 'Needs approval' }), - isIncomplete: observableValue('incomplete', false), - response: { value: [] as readonly { kind: string }[], getMarkdown: () => '' }, - }; - const lastRequest = { id: 'confirmation-request', response }; - chatService.setModels([{ - sessionResource: resource, - title: 'Chat', - getRequests: () => [lastRequest], - lastRequestObs: observableValue('lastRequest', lastRequest), - } as unknown as IChatModel]); - const controller = createController(voiceClientService, ttsPlaybackService, commandService, undefined, undefined, undefined, chatService); - showSessionsInAgentsList(controller, resource.toString()); - await connectWithOmniOpen(controller, voiceClientService); - controller.setTargetSession(resource, 'existing_session'); - - voiceClientService.fireToolCall({ - callId: 'omni-response-before-ack', - name: 'send_to_chat', - args: { text: 'run the tests' }, - }); - await voiceClientService.toolResultReceived; - assert.notStrictEqual(Reflect.get(controller, '_pendingOmniDispatchAcknowledgement'), undefined); - controller.announceSessionInOmni(resource); - const narrate = Reflect.get(controller, '_narrate') as (sessionId: string, kind: string, text: string) => boolean; - narrate.call(controller, resource.toString(), 'response', 'An earlier response.'); - const responseNarration = voiceClientService.requests[0]; - - voiceClientService.fireAudioResponse({ - audio: 'An earlier response.', - isFirstChunk: true, - isFinal: true, - codingSessionId: resource.toString(), - responseId: responseNarration.narrationId, - narrationKind: 'response', - transcript: 'An earlier response.', - }); - assert.deepStrictEqual({ - requestCount: voiceClientService.requests.length, - pendingAcknowledgement: Reflect.get(controller, '_pendingOmniDispatchAcknowledgement'), - deferredConfirmations: [...(Reflect.get(controller, '_pendingAfterOmniDispatchAcknowledgement') as Map)], - currentNarratable: (Reflect.get(controller, '_currentNarratable') as (resource: URI) => unknown).call(controller, resource), - }, { - requestCount: 1, - pendingAcknowledgement: { sessionKey: resource.toString() }, - deferredConfirmations: [[resource.toString(), { - kind: 'confirmation', - text: 'tool approval: GitHub Copilot needs your approval to continue.', - confirmationType: 'generic', - }]], - currentNarratable: { - kind: 'confirmation', - text: 'tool approval: GitHub Copilot needs your approval to continue.', - confirmationType: 'generic', - }, - }); - - voiceClientService.fireAudioResponse({ - audio: 'I sent that request.', - isFirstChunk: true, - isFinal: true, - codingSessionId: resource.toString(), - transcript: 'I sent that request.', - }); - await Promise.resolve(); - ttsPlaybackService.stopPlayback(); - await Promise.resolve(); - - assert.deepStrictEqual(voiceClientService.requests.map(request => request.kind), ['response', 'confirmation']); - }); - - test('resolves a backend session id before dispatching a spoken approval', async () => { - const voiceClientService = new TestVoiceClientService(); - const controller = createController(voiceClientService); - const resource = URI.parse('agent-host-copilotcli:/spoken-approval'); - controller.setTargetSession(resource, 'existing_session'); - await controller.connect(mainWindow); - const toolCall: IVoiceToolCall = { - callId: 'spoken-approval', - name: 'respond_to_session', - args: { - coding_session_id: 'copilotcli:/spoken-approval', - response: { type: 'approve' }, - }, - }; - - voiceClientService.fireToolCall(toolCall); - await Promise.resolve(); - - assert.strictEqual(toolCall.args?.['coding_session_id'], resource.toString()); - }); - - test('focused omni chat routes voice input instead of the panel session', async () => { - const voiceClientService = new TestVoiceClientService(); - const commandService = new TestCommandService(true); - const controller = createController( - voiceClientService, - undefined, - commandService, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - ); - - const sendTranscriptionToChat = Reflect.get(controller, '_sendTranscriptionToChat') as (text: string) => Promise; - await sendTranscriptionToChat.call(controller, 'run the focused omni request'); - - assert.deepStrictEqual({ - omniInputs: commandService.acceptedOmniInputs, - panelInputs: commandService.acceptedInputs, - }, { - omniInputs: ['run the focused omni request'], - panelInputs: [], - }); - }); - - test('rejected omni routing does not fall back to the panel session', async () => { - const voiceClientService = new TestVoiceClientService(); - const commandService = new TestCommandService(false); - const controller = createController(voiceClientService, undefined, commandService); - controller.setOmniInputActive(true); - - const sendTranscriptionToChat = Reflect.get(controller, '_sendTranscriptionToChat') as (text: string) => Promise; - const result = await sendTranscriptionToChat.call(controller, 'do not reroute this request'); - assert.deepStrictEqual({ - result, - panelInputs: commandService.acceptedInputs, + acceptedInputs: commandService.acceptedInputs, + toolResults: voiceClientService.toolResults, + awaitingReply: Reflect.get(controller, '_awaitingReplyAudio'), }, { - result: false, - panelInputs: [], + acceptedInputs: ['send this when listening stops'], + toolResults: [{ callId: 'manual-transcription', result: 'ok' }], + awaitingReply: true, }); }); @@ -7185,36 +5275,14 @@ suite('VoiceSessionController', () => { created: chatService.created.length, sent: chatService.sent, acceptedInputs: commandService.acceptedInputs, + toolResults: voiceClientService.toolResults, + awaitingReply: Reflect.get(controller, '_awaitingReplyAudio'), }, { created: 1, sent: [{ resource: 'chat-session://new/1', message: 'refactor the upload service' }], acceptedInputs: [], - }); - }); - - test('send_to_chat with new_session bypasses a focused omni input', async () => { - const voiceClientService = new TestVoiceClientService(); - const commandService = new TestCommandService(true); - const chatService = new NewSessionChatService(); - const controller = createController(voiceClientService, undefined, commandService, undefined, undefined, undefined, chatService); - await controller.connect(mainWindow); - (Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }).set(true, undefined); - - voiceClientService.fireToolCall({ - callId: 'new-session-omni-focused', - name: 'send_to_chat', - args: { text: 'refactor the upload service', new_session: true }, - }); - await voiceClientService.toolResultReceived; - - assert.deepStrictEqual({ - sent: chatService.sent, - omniInputs: commandService.acceptedOmniInputs, - panelInputs: commandService.acceptedInputs, - }, { - sent: [{ resource: 'chat-session://new/1', message: 'refactor the upload service' }], - omniInputs: [], - panelInputs: [], + toolResults: [{ callId: 'new-session-send', result: 'ok' }], + awaitingReply: true, }); }); @@ -7239,11 +5307,19 @@ suite('VoiceSessionController', () => { sent: chatService.sent, acceptedInputs: commandService.acceptedInputs, target: target?.toString(), + toolResults: voiceClientService.toolResults, + awaitingReply: Reflect.get(controller, '_awaitingReplyAudio'), + voiceState: controller.voiceState.get(), + status: controller.statusText.get(), }, { created: 1, sent: [], acceptedInputs: [], target: 'chat-session://new/1', + toolResults: [{ callId: 'new-session-empty', result: 'error' }], + awaitingReply: false, + voiceState: 'idle', + status: 'Hold to speak...', }); }); @@ -7266,10 +5342,43 @@ suite('VoiceSessionController', () => { created: chatService.created.length, sent: chatService.sent, acceptedInputs: commandService.acceptedInputs, + toolResults: voiceClientService.toolResults, + awaitingReply: Reflect.get(controller, '_awaitingReplyAudio'), }, { created: 0, sent: [], acceptedInputs: ['refactor the upload service'], + toolResults: [{ callId: 'same-session-send', result: 'ok' }], + awaitingReply: true, + }); + }); + + test('send_to_chat reports an error and clears awaiting reply when pane delivery fails', async () => { + const voiceClientService = new TestVoiceClientService(); + const commandService = new RejectingAcceptCommandService(); + const controller = createController(voiceClientService, undefined, commandService); + await controller.connect(mainWindow); + (Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }).set(true, undefined); + + voiceClientService.fireToolCall({ + callId: 'failed-pane-send', + name: 'send_to_chat', + args: { text: 'refactor the upload service' }, + }); + await voiceClientService.toolResultReceived; + + assert.deepStrictEqual({ + acceptedInputs: commandService.acceptedInputs, + toolResults: voiceClientService.toolResults, + awaitingReply: Reflect.get(controller, '_awaitingReplyAudio'), + voiceState: controller.voiceState.get(), + status: controller.statusText.get(), + }, { + acceptedInputs: [], + toolResults: [{ callId: 'failed-pane-send', result: 'error' }], + awaitingReply: false, + voiceState: 'idle', + status: 'Hold to speak...', }); }); @@ -7403,20 +5512,6 @@ suite('VoiceSessionController', () => { assert.strictEqual(mic.pttDownCalls.length, 0); }); - test('open omni keeps auto-listening when focus moves to another window', () => { - const voiceClientService = new TestVoiceClientService(); - const mic = new RecordingMicCaptureService(); - const controller = createController(voiceClientService, undefined, undefined, undefined, mic); - (Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }).set(true, undefined); - Reflect.set(controller, '_window', { document: { hasFocus: () => false } }); - controller.setOmniInputActive(true); - - const enterAutoListen = Reflect.get(controller, '_enterAutoListen') as () => void; - enterAutoListen.call(controller); - - assert.strictEqual(mic.pttDownCalls.length, 1); - }); - test('window blur aborts an open passive turn so the background window stops recording', () => { const voiceClientService = new TestVoiceClientService(); const mic = new RecordingMicCaptureService(); @@ -7433,27 +5528,6 @@ suite('VoiceSessionController', () => { assert.strictEqual(Reflect.get(controller, '_pttHeld'), false); }); - test('open omni preserves a passive reply turn after window blur', () => { - const voiceClientService = new TestVoiceClientService(); - const mic = new RecordingMicCaptureService(); - const controller = createController(voiceClientService, undefined, undefined, undefined, mic); - (Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }).set(true, undefined); - controller.setOmniInputActive(true); - Reflect.set(controller, '_pttCurrentTurnId', 'omni-passive-turn'); - Reflect.set(controller, '_pttCurrentTurnPassive', true); - Reflect.set(controller, '_pttHeld', true); - - (Reflect.get(controller, '_onWindowBlur') as () => void).call(controller); - - assert.deepStrictEqual({ - abortCalls: mic.abortCalls, - pttHeld: Reflect.get(controller, '_pttHeld'), - }, { - abortCalls: 0, - pttHeld: true, - }); - }); - test('window blur does not abort a deliberate (non-passive) turn', () => { const voiceClientService = new TestVoiceClientService(); const mic = new RecordingMicCaptureService(); @@ -7526,22 +5600,19 @@ suite('VoiceSessionController', () => { assert.strictEqual(Reflect.get(controller, '_pttHeld'), true); }); - test('a hands-free open-mic turn is ended on the wire before playback', () => { + test('a passive open-mic turn is torn down for playback since it never latched', () => { const voiceClientService = new TestVoiceClientService(); const mic = new RecordingMicCaptureService(); const controller = createController(voiceClientService, undefined, undefined, undefined, mic); (Reflect.get(controller, '_isConnected') as { set(value: boolean, tx: undefined): void }).set(true, undefined); Reflect.set(controller, '_pttCurrentTurnId', 'passive-turn'); - Reflect.set(controller, '_pttCurrentTurnPassive', false); + Reflect.set(controller, '_pttCurrentTurnPassive', true); Reflect.set(controller, '_pttHeld', true); - Reflect.set(controller, '_pttToggleMode', true); - Reflect.set(controller, '_speechDetectedInTurn', true); (Reflect.get(controller, '_prepareForPlayback') as () => void).call(controller); assert.strictEqual(mic.abortCalls, 1); - assert.strictEqual(voiceClientService.pttEndCalls, 1); assert.strictEqual(Reflect.get(controller, '_pttHeld'), false); }); @@ -7854,6 +5925,7 @@ suite('VoiceSessionController live transcription', () => { const chatEntitlementService = new TestChatEntitlementService(); chatEntitlementService.entitlement = ChatEntitlement.Pro; instantiationService.stub(IChatEntitlementService, chatEntitlementService); + const controller = store.add(instantiationService.createInstance(VoiceSessionController)); controller['_isConnected'].set(true, undefined); controller['_userLogin'] = 'test-user'; diff --git a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceToolDispatchService.test.ts b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceToolDispatchService.test.ts index 7354924f4a027e..badfd137ac523b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceToolDispatchService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/voiceClient/voiceToolDispatchService.test.ts @@ -8,7 +8,7 @@ import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { AgentSessionStatus, IAgentSessionsModel } from '../../../browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsModel } from '../../../browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../browser/agentSessions/agentSessionsService.js'; import { IVoiceModelSelectionResult, IVoiceToolDispatchDelegate, resolveVoiceModel, VoiceToolDispatchService } from '../../../browser/voiceClient/voiceToolDispatchService.js'; import { IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; @@ -76,22 +76,12 @@ suite('VoiceToolDispatchService - session actions', () => { const agentSessionsService = new class extends mock() { override get model(): IAgentSessionsModel { return { - sessions: (options.agentSessionResources ?? []).map(resource => ({ - isArchived: () => false, - resource, - label: 'Agent session', - status: AgentSessionStatus.NeedsInput, - timing: {}, - changes: undefined, - })), + sessions: (options.agentSessionResources ?? []).map(resource => ({ isArchived: () => false, resource })), } as IAgentSessionsModel; } }; const chatService = new class extends mock() { override readonly chatModels = observableValue('chatModels', options.chatModels ?? []); - override getSession(resource: URI): IChatModel | undefined { - return this.chatModels.get().find(model => model.sessionResource.toString() === resource.toString()); - } }; const service = new VoiceToolDispatchService( agentSessionsService, @@ -216,23 +206,6 @@ suite('VoiceToolDispatchService - session actions', () => { deletions: 0, }); }); - - test('reports Agent Host sessions using the backend session id', async () => { - const resource = URI.parse('agent-host-copilotcli:/waiting-session'); - const { service } = createActionHarness({ currentResource: resource, agentSessionResources: [resource] }); - - const result = await dispatch(service, 'get_session_info'); - - assert.deepStrictEqual(result.sessions[0], { - id: 'copilotcli:/waiting-session', - label: 'Agent session', - session_type: 'agent', - state: 'waiting_for_input', - is_active: true, - insertions: 0, - deletions: 0, - }); - }); }); suite('VoiceToolDispatchService - respondToSession', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts index d4bc72388061b8..d9af51dceb224e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatWidget.test.ts @@ -14,7 +14,6 @@ import { SaveReason } from '../../../../../common/editor.js'; import { ISaveAllEditorsOptions, ISaveEditorsResult } from '../../../../../services/editor/common/editorService.js'; import { TestEditorService } from '../../../../../test/browser/workbenchTestServices.js'; import { acceptAndAwaitSentRequest, ChatWidget, getImmediateSilentSlashCommandPart, layoutChatWidgetForInputHeight, saveAllBeforeChatSend, shouldShowChatTip, shouldShowChatWelcome } from '../../../browser/widget/chatWidget.js'; -import { ChatInputPart } from '../../../browser/widget/input/chatInputPart.js'; import { ChatSendResult, ChatSendResultSent, IChatSendRequestData } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration } from '../../../common/constants.js'; import { ChatRequestSlashCommandPart, ChatRequestTextPart, IParsedChatRequest } from '../../../common/requestParser/chatParserTypes.js'; @@ -121,57 +120,6 @@ suite('ChatWidget', () => { }); }); - test('reasserts custom submit pending after the dictation finalization boundary', async () => { - const events: string[] = []; - const widget = { - _readOnly: false, - input: { - hasPendingProgrammaticModelSelection: false, - setSubmitPending: (pending: boolean, routing?: boolean) => events.push(`pending:${pending}:${routing ?? pending}`), - }, - viewOptions: { submitHandler: () => true }, - inputEditor: {}, - viewModel: undefined, - _acceptInput: async () => { - events.push('accept'); - return undefined; - }, - }; - const acceptInput = ChatWidget.prototype.acceptInput as unknown as (this: typeof widget) => Promise; - - await acceptInput.call(widget); - - assert.deepStrictEqual(events, [ - 'pending:true:true', - 'pending:true:true', - 'accept', - ]); - }); - - test('refreshes the execute toolbar only when submit pending state changes', () => { - const contextKey = (initialValue: boolean) => { - let value = initialValue; - return { - get: () => value, - set: (newValue: boolean) => value = newValue, - }; - }; - let refreshes = 0; - const inputPart = { - inputSubmitPending: contextKey(false), - inputRouting: contextKey(false), - executeToolbar: { refresh: () => refreshes++ }, - }; - const setSubmitPending = ChatInputPart.prototype.setSubmitPending as unknown as (this: typeof inputPart, pending: boolean, routing?: boolean) => void; - - setSubmitPending.call(inputPart, false); - setSubmitPending.call(inputPart, true, true); - setSubmitPending.call(inputPart, true, true); - setSubmitPending.call(inputPart, false); - - assert.strictEqual(refreshes, 2); - }); - test('transcript overlays suppress the welcome state', () => { assert.deepStrictEqual({ unavailable: shouldShowChatWelcome(undefined, false), @@ -334,7 +282,7 @@ suite('ChatWidget - acceptAndAwaitSentRequest', () => { const deferred = new DeferredPromise(); let accepted = 0; - const pending = acceptAndAwaitSentRequest({ kind: 'queued', requestId: 'queued-request', deferred: deferred.p }, () => accepted++); + const pending = acceptAndAwaitSentRequest({ kind: 'queued', deferred: deferred.p }, () => accepted++); // The queued request has not run yet, so `pending` is still unresolved here. const acceptedWhileQueued = accepted === 1; @@ -360,7 +308,7 @@ suite('ChatWidget - acceptAndAwaitSentRequest', () => { const deferred = new DeferredPromise(); let accepted = 0; - const pending = acceptAndAwaitSentRequest({ kind: 'queued', requestId: 'queued-request', deferred: deferred.p }, () => accepted++); + const pending = acceptAndAwaitSentRequest({ kind: 'queued', deferred: deferred.p }, () => accepted++); await deferred.complete({ kind: 'rejected', reason: 'Session is read-only' }); assert.deepStrictEqual({ accepted, sent: await pending }, { accepted: 1, sent: undefined }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts index 78131d4a9b5e2e..5806f829489e3d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputNotificationWidget.test.ts @@ -249,7 +249,6 @@ suite('ChatInputNotificationWidget', () => { const deferredNotificationsEnabled = observableValue('deferredNotificationsEnabled', true); let hasSessions = false; const harness = { - options: {}, environmentService: { isSessionsWindow: false }, chatService: { hasSessions: () => hasSessions }, _deferredNotificationsEnabled: deferredNotificationsEnabled, @@ -284,7 +283,6 @@ suite('ChatInputNotificationWidget', () => { test('Agents window bypasses the workbench first-session gate', () => { const deferredNotificationsEnabled = observableValue('deferredNotificationsEnabled', false); const harness = { - options: {}, environmentService: { isSessionsWindow: true }, chatService: { hasSessions: () => false }, _deferredNotificationsEnabled: deferredNotificationsEnabled, @@ -300,25 +298,6 @@ suite('ChatInputNotificationWidget', () => { assert.strictEqual(deferredNotificationsEnabled.get(), true); }); - test('widget option disables deferred notifications', () => { - const deferredNotificationsEnabled = observableValue('deferredNotificationsEnabled', true); - const harness = { - options: { deferredNotificationsEnabled: false }, - environmentService: { isSessionsWindow: true }, - chatService: { hasSessions: () => true }, - _deferredNotificationsEnabled: deferredNotificationsEnabled, - _isFirstWorkbenchSession: undefined as boolean | undefined, - }; - const update = Reflect.get(ChatInputPart.prototype, 'updateDeferredNotificationsEligibility') as ( - this: typeof harness, - event?: { previousSessionResource: URI | undefined; currentSessionResource: URI | undefined }, - ) => void; - - update.call(harness); - - assert.strictEqual(deferredNotificationsEnabled.get(), false); - }); - test('renders markdown descriptions as rich content', () => { const notificationService = createNotificationService(); const instantiationService = store.add(workbenchInstantiationService(undefined, store)); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerConfiguration.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerConfiguration.test.ts index 05ce6360b339ff..94c044ea38be30 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerConfiguration.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/modelPicker/modelPickerConfiguration.test.ts @@ -4,8 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { IAnchor } from '../../../../../../../../base/browser/ui/contextview/contextview.js'; -import { AnchorPosition } from '../../../../../../../../base/common/layout.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../../base/test/common/utils.js'; import { ExtensionIdentifier } from '../../../../../../../../platform/extensions/common/extensions.js'; import { ActionListItemKind, IActionListItem, IActionListOptions } from '../../../../../../../../platform/actionWidget/browser/actionList.js'; @@ -174,73 +172,6 @@ suite('ModelPickerConfiguration', () => { }); }); - test('uses the host action widget placement and visibility lifecycle', () => { - const model = createModel(); - const container = document.createElement('div'); - const button = document.createElement('a'); - const anchor: IAnchor = { x: 10, y: 20, width: 30, height: 1 }; - const visibility: boolean[] = []; - let shownPlacement: { anchor: unknown; container: unknown; anchorPosition: AnchorPosition | undefined } | undefined; - let onHide: (() => void) | undefined; - const actionWidgetService = { - show: ( - _id: string, - _supportsPreview: boolean, - _items: IActionListItem[], - delegate: { onHide: () => void }, - shownAnchor: unknown, - shownContainer: unknown, - _actions: unknown, - _accessibilityProvider: unknown, - options: IActionListOptions, - ) => { - onHide = delegate.onHide; - shownPlacement = { - anchor: shownAnchor, - container: shownContainer, - anchorPosition: options.anchorPosition, - }; - }, - focusItemById: () => { }, - updateItems: () => { }, - hide: () => onHide?.(), - } as unknown as IActionWidgetService; - const access: IModelConfigurationAccess = { - getModelConfiguration: () => ({}), - setModelConfiguration: async () => { }, - getModelConfigurationActions: () => [], - }; - const controller = new ModelPickerConfiguration({ - getSelectedModel: () => model, - getConfigurationAccess: () => access, - isDisabled: () => false, - shouldShowCacheBreakHint: () => false, - getCacheBreakLearnMoreLink: () => undefined, - dismissCacheBreakHint: () => { }, - onDidChangeVisibility: visible => { visibility.push(visible); }, - getActionWidgetContainer: () => container, - getActionWidgetAnchor: () => anchor, - getAnchorPosition: () => AnchorPosition.BELOW, - }, actionWidgetService, { publicLog2: () => { } } as unknown as ITelemetryService); - - controller.show(button); - controller.show(button); - controller.show(button); - controller.dispose(); - - assert.deepStrictEqual({ - shownPlacement, - visibility, - }, { - shownPlacement: { - anchor, - container, - anchorPosition: AnchorPosition.BELOW, - }, - visibility: [true, false, true, false], - }); - }); - // A producer that cannot resolve a default leaves it `undefined`, which used // to be stringified straight into the label as "undefined 272K". The group is // dropped from the label instead, while its options stay selectable. diff --git a/src/vs/workbench/contrib/chat/test/common/chatInputWindow.test.ts b/src/vs/workbench/contrib/chat/test/common/chatInputWindow.test.ts deleted file mode 100644 index a67bc127304743..00000000000000 --- a/src/vs/workbench/contrib/chat/test/common/chatInputWindow.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { getChatInputWindowBounds } from '../../common/chatInputWindow.js'; - -suite('ChatInputWindow', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('centers the initial bounds in the invoking window', () => { - assert.deepStrictEqual( - getChatInputWindowBounds({ x: 1200, y: 200, width: 1001, height: 801 }, 421, 111), - { x: 1490, y: 545, width: 421, height: 111 }, - ); - }); - - test('restores a moved position relative to the invoking window', () => { - assert.deepStrictEqual( - getChatInputWindowBounds({ x: 1200, y: 200, width: 1001, height: 801 }, 421, 111, { x: 80, y: 140 }), - { x: 1280, y: 340, width: 421, height: 111 }, - ); - }); -}); 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 8949146a416b59..de80441ae886be 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 @@ -1035,7 +1035,6 @@ suite('ChatService', () => { const model = testService.getSession(sessionResource) as ChatModel; assert.strictEqual(model.getPendingRequests().length, 1, 'queued message should wait while the streamed turn is in progress'); - assert.strictEqual(queued.requestId, model.getPendingRequests()[0].request.id, 'queued result should identify the pending request it created'); isCompleteObs.set(true, undefined); await invoked.p; diff --git a/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts b/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts deleted file mode 100644 index fee859afb5bf55..00000000000000 --- a/src/vs/workbench/contrib/chat/test/common/sessionRouter.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { buildRouterMessages, heuristicScore, isHighConfidenceSessionRoute, ISessionRouteRequest, parseRouterResponse, ROUTER_FIELD_CLIP_LENGTH } from '../../common/sessionRouter.js'; - -suite('SessionRouter helpers', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - const request: ISessionRouteRequest = { - utterance: 'fix the flaky voice reconnect test', - sessions: [ - { sessionId: 's1', label: 'voice narration', repo: 'meganrogge/momentum-map', status: 'idle' }, - { sessionId: 's2', label: 'docs cleanup', repo: 'microsoft/vscode-docs' } - ] - }; - - test('buildRouterMessages embeds utterance and every session id', () => { - const messages = buildRouterMessages(request); - assert.strictEqual(messages.length, 2); - assert.strictEqual(messages[0].role, 'system'); - assert.strictEqual(messages[1].role, 'user'); - assert.ok(messages[1].content.includes('fix the flaky voice reconnect test')); - assert.ok(messages[1].content.includes('id=s1')); - assert.ok(messages[1].content.includes('id=s2')); - assert.ok(messages[0].content.includes('whether it warrants a new session')); - assert.ok(messages[0].content.includes('prefer a new session for a distinct task')); - }); - - test('buildRouterMessages embeds enriched conversation content', () => { - const messages = buildRouterMessages({ - utterance: 'ship it', - sessions: [{ - sessionId: 's1', - label: 'voice narration', - description: 'Adds dictation onboarding', - firstRequest: 'add a voice onboarding dialog', - lastRequest: 'tweak the countdown copy', - lastResponse: 'Updated the countdown to read "sending in Ns".' - }] - }); - const user = messages[1].content; - assert.ok(user.includes('summary=')); - assert.ok(user.includes('firstRequest=')); - assert.ok(user.includes('lastRequest=')); - assert.ok(user.includes('lastResponse=')); - }); - - test('parseRouterResponse extracts, clamps, filters and sorts', () => { - const raw = '```json\n[{"sessionId":"s2","confidence":0.2},{"sessionId":"s1","confidence":1.7,"reason":"voice"},{"sessionId":"ghost","confidence":0.9}]\n```'; - const result = parseRouterResponse(raw, new Set(['s1', 's2'])); - assert.deepStrictEqual(result, [ - { sessionId: 's1', confidence: 1, reason: 'voice' }, - { sessionId: 's2', confidence: 0.2, reason: undefined } - ]); - }); - - test('parseRouterResponse returns undefined when nothing usable', () => { - assert.strictEqual(parseRouterResponse('no json here', new Set(['s1'])), undefined); - assert.strictEqual(parseRouterResponse('[{"sessionId":"unknown","confidence":0.5}]', new Set(['s1'])), undefined); - assert.strictEqual(parseRouterResponse('[{"sessionId":"s1","confidence":"high"}]', new Set(['s1'])), undefined); - }); - - test('parseRouterResponse skips malformed confidences in an otherwise valid response', () => { - assert.deepStrictEqual( - parseRouterResponse('[{"sessionId":"s1"},{"sessionId":"s2","confidence":0.7}]', new Set(['s1', 's2'])), - [{ sessionId: 's2', confidence: 0.7, reason: undefined }], - ); - }); - - test('high-confidence routes must exceed 80 percent', () => { - assert.deepStrictEqual([ - isHighConfidenceSessionRoute({ sessionId: 'below', confidence: 0.79 }), - isHighConfidenceSessionRoute({ sessionId: 'boundary', confidence: 0.8 }), - isHighConfidenceSessionRoute({ sessionId: 'above', confidence: 0.81 }), - ], [false, false, true]); - }); - - test('heuristicScore ranks the token-overlapping session first', () => { - const ranked = heuristicScore(request); - assert.strictEqual(ranked[0].sessionId, 's1'); - assert.ok(ranked[0].confidence > ranked[1].confidence); - }); - - test('heuristicScore matches on enriched content, not just the label', () => { - const ranked = heuristicScore({ - utterance: 'update the authentication token refresh logic', - sessions: [ - { sessionId: 's1', label: 'session one', lastRequest: 'fix the authentication token refresh logic' }, - { sessionId: 's2', label: 'session two', lastRequest: 'restyle the settings page' } - ] - }); - assert.strictEqual(ranked[0].sessionId, 's1'); - assert.ok(ranked[0].confidence > ranked[1].confidence); - }); - - test('heuristicScore ignores generic shared words', () => { - const ranked = heuristicScore({ - utterance: 'work on this with the agent', - sessions: [{ sessionId: 's1', label: 'the agent for this work' }] - }); - assert.strictEqual(ranked[0].confidence, 0); - }); - - test('buildRouterMessages clips overlong content fields', () => { - const longResponse = 'x '.repeat(400); - const user = buildRouterMessages({ - utterance: 'hi', - sessions: [{ sessionId: 's1', label: 'l', lastResponse: longResponse }] - })[1].content; - const match = /lastResponse=("(?:[^"\\]|\\.)*")/.exec(user); - assert.ok(match, 'expected a lastResponse field'); - const value: string = JSON.parse(match![1]); - assert.ok(value.length <= ROUTER_FIELD_CLIP_LENGTH + 3, `expected clipped, got length ${value.length}`); - assert.ok(value.endsWith('...')); - }); -}); diff --git a/src/vs/workbench/contrib/chat/test/common/voiceClient/voicePendingId.test.ts b/src/vs/workbench/contrib/chat/test/common/voiceClient/voicePendingId.test.ts index baab8865bd4d57..7b97ca87ffd856 100644 --- a/src/vs/workbench/contrib/chat/test/common/voiceClient/voicePendingId.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/voiceClient/voicePendingId.test.ts @@ -9,7 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; -import { derivePendingId, getVoiceToolApprovalCommand, isPendingIdResolved, markPendingIdResolved, peekPendingId } from '../../../common/voiceClient/voiceClientService.js'; +import { derivePendingId, getVoiceToolApprovalCommand, isPendingIdResolved, markPendingIdResolved, peekPendingId, restoreResolvedPendingId } from '../../../common/voiceClient/voiceClientService.js'; suite('derivePendingId', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -109,7 +109,7 @@ suite('derivePendingId', () => { }, { presentationUpdateMatches: true, changedCommandDiffers: true, - afterInteractionDiffers: false, + afterInteractionDiffers: true, currentPartNoLongerResolvesOldId: true, }); @@ -184,7 +184,6 @@ suite('derivePendingId', () => { }); test('rehydrated copies share one active tool occurrence', () => { - const requestId = 'req-rehydrated-active'; const tool = () => { const state = observableValue('toolState', { type: IChatToolInvocation.StateKind.WaitingForConfirmation, @@ -195,9 +194,9 @@ suite('derivePendingId', () => { }; const first = tool(); const rehydrated = tool(); - const pendingId = derivePendingId(requestId, first.part); + const pendingId = derivePendingId('req-1', first.part); - assert.strictEqual(peekPendingId(requestId, rehydrated.part), pendingId); + assert.strictEqual(peekPendingId('req-1', rehydrated.part), pendingId); for (const copy of [first, rehydrated]) { copy.state.set({ @@ -268,12 +267,12 @@ suite('derivePendingId', () => { assert.strictEqual(peekPendingId('req-retire', rehydrated.part), undefined); assert.strictEqual(derivePendingId('req-retire', rehydrated.part), pendingId); - // Rehydrating the same request/tool/command after interaction remains - // retired. A genuine retry must use a new request or tool-call id. + // A new invocation published after the interaction is a new occurrence, + // even when the provider reuses the tool-call id and command. const rearmed = tool(); const rearmedId = derivePendingId('req-retire', rearmed.part); - assert.strictEqual(rearmedId, pendingId); - assert.strictEqual(peekPendingId('req-retire', rearmed.part), undefined); + assert.notStrictEqual(rearmedId, pendingId); + assert.strictEqual(peekPendingId('req-retire', rearmed.part), rearmedId); for (const copy of [first, rehydrated, rearmed]) { copy.state.set({ @@ -284,6 +283,36 @@ suite('derivePendingId', () => { } }); + test('restores the retired identity for a late rehydrated copy', () => { + const tool = () => { + const state = observableValue('toolState', { + type: IChatToolInvocation.StateKind.WaitingForConfirmation, + parameters: { command: 'echo high' }, + confirm: () => { }, + }); + return { part: { kind: 'toolInvocation', toolCallId: 'late-tool-call', state } as unknown as IChatToolInvocation, state }; + }; + const original = tool(); + const pendingId = derivePendingId('req-late-copy', original.part); + assert.strictEqual(markPendingIdResolved(pendingId), true); + original.state.set({ + type: IChatToolInvocation.StateKind.Cancelled, + reason: ToolConfirmKind.Skipped, + parameters: {}, + }, undefined); + + const lateCopy = tool(); + assert.strictEqual(restoreResolvedPendingId('req-late-copy', lateCopy.part), pendingId); + assert.strictEqual(derivePendingId('req-late-copy', lateCopy.part), pendingId); + assert.strictEqual(isPendingIdResolved(pendingId), true); + + lateCopy.state.set({ + type: IChatToolInvocation.StateKind.Cancelled, + reason: ToolConfirmKind.Skipped, + parameters: {}, + }, undefined); + }); + test('one copy leaving pending retires the shared occurrence', () => { const tool = () => { const state = observableValue('toolState', { diff --git a/src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts b/src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts index 9e379279c639d7..e05b71bd72c6c3 100644 --- a/src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts +++ b/src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts @@ -38,7 +38,6 @@ import { createKeybindingCommandQuery } from '../../../services/preferences/brow import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; import { CHAT_OPEN_ACTION_ID } from '../../chat/browser/actions/chatActions.js'; import { ASK_QUICK_QUESTION_ACTION_ID } from '../../chat/browser/actions/chatQuickInputActions.js'; -import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { IChatAgentService } from '../../chat/common/participants/chatAgents.js'; import { ChatAgentLocation } from '../../chat/common/constants.js'; @@ -281,7 +280,7 @@ export class ShowAllCommandsAction extends Action2 { title: localize2('showTriggerActions', 'Show All Commands'), keybinding: { weight: KeybindingWeight.WorkbenchContrib, - when: ChatContextKeys.inChatInputWindow.negate(), + when: undefined, primary: !isFirefox ? (KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyP) : undefined, secondary: [KeyCode.F1] }, diff --git a/src/vs/workbench/services/host/browser/browserHostService.ts b/src/vs/workbench/services/host/browser/browserHostService.ts index a7aa47ec1647ce..3815713423ed81 100644 --- a/src/vs/workbench/services/host/browser/browserHostService.ts +++ b/src/vs/workbench/services/host/browser/browserHostService.ts @@ -9,7 +9,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; import { IEditorService } from '../../editor/common/editorService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IWindowSettings, IWindowOpenable, IOpenWindowOptions, isFolderToOpen, isWorkspaceToOpen, isFileToOpen, IOpenEmptyWindowOptions, IPathData, IFileToOpen, IOpenedMainWindow, IOpenedAuxiliaryWindow, IRectangle } from '../../../../platform/window/common/window.js'; +import { IWindowSettings, IWindowOpenable, IOpenWindowOptions, isFolderToOpen, isWorkspaceToOpen, isFileToOpen, IOpenEmptyWindowOptions, IPathData, IFileToOpen, IOpenedMainWindow, IOpenedAuxiliaryWindow } from '../../../../platform/window/common/window.js'; import { isResourceEditorInput, pathsToEditors } from '../../../common/editor.js'; import { whenEditorClosed } from '../../../browser/editor.js'; import { IWorkspace, IWorkspaceProvider } from '../../../browser/web.api.js'; @@ -585,15 +585,6 @@ export class BrowserHostService extends Disposable implements IHostService { return undefined; } - async getWindowPosition(targetWindow: Window): Promise { - return { - x: targetWindow.screenX, - y: targetWindow.screenY, - width: targetWindow.outerWidth, - height: targetWindow.outerHeight, - }; - } - getWindows(options: { includeAuxiliaryWindows: true }): Promise>; getWindows(options: { includeAuxiliaryWindows: false }): Promise>; async getWindows(options: { includeAuxiliaryWindows: boolean }): Promise> { diff --git a/src/vs/workbench/services/host/browser/host.ts b/src/vs/workbench/services/host/browser/host.ts index ed8a6bd9a885b0..a43eb767fbf440 100644 --- a/src/vs/workbench/services/host/browser/host.ts +++ b/src/vs/workbench/services/host/browser/host.ts @@ -116,9 +116,6 @@ export interface IHostService { */ getCursorScreenPoint(): Promise<{ readonly point: IPoint; readonly display: IRectangle } | undefined>; - /** Get the native bounds of a window or `undefined` if unavailable. */ - getWindowPosition(targetWindow: Window): Promise; - /** * Get the list of opened windows, optionally including auxiliary windows. */ diff --git a/src/vs/workbench/services/host/electron-browser/nativeHostService.ts b/src/vs/workbench/services/host/electron-browser/nativeHostService.ts index abb9bc2c62470b..86a3d521e1bef4 100644 --- a/src/vs/workbench/services/host/electron-browser/nativeHostService.ts +++ b/src/vs/workbench/services/host/electron-browser/nativeHostService.ts @@ -181,10 +181,6 @@ class WorkbenchHostService extends Disposable implements IHostService { return this.nativeHostService.getCursorScreenPoint(); } - getWindowPosition(targetWindow: Window): Promise { - return this.nativeHostService.getWindowPosition({ targetWindowId: getWindowId(targetWindow) }); - } - getWindows(options: { includeAuxiliaryWindows: true }): Promise>; getWindows(options: { includeAuxiliaryWindows: false }): Promise>; getWindows(options: { includeAuxiliaryWindows: boolean }): Promise> { diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index a0f5cbd74aeb30..798a009ff7dfe5 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -195,7 +195,6 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I reg.defineInstance(IVoiceSessionController, new class extends mock() { override readonly targetSession = constObservable(undefined); override readonly hasDraftTarget = constObservable(false); - override readonly omniInputOpen = constObservable(false); }()); reg.defineInstance(IChatPetService, new class extends mock() { override readonly enabled = observableValue('chatPetEnabled', false); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts index 0f518c37ebef63..1748c2b79272f4 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts @@ -28,7 +28,7 @@ import { SessionActionFeedback } from '../../../../../sessions/contrib/sessions/ // eslint-disable-next-line local/code-import-patterns import { SessionsTitleBarWidget } from '../../../../../sessions/contrib/sessions/browser/sessionsTitleBarWidget.js'; // eslint-disable-next-line local/code-import-patterns -import { BlockedSessionsCIFixModel, IBlockedSessionsCIFixModel } from '../../../../../sessions/contrib/sessions/browser/blockedSessionsCIFixModel.js'; +import { BlockedSessionsCIFixModel } from '../../../../../sessions/contrib/sessions/browser/blockedSessionsCIFixModel.js'; import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; @@ -115,18 +115,10 @@ function renderTitleBar(ctx: ComponentFixtureContext, state: ITitleBarState): vo ?? Array.from({ length: state.blockedCount ?? 0 }, (): IBlockedSpec => ({ reason: BlockedSessionReason.NeedsInput })); const { blocked, approvalModel } = buildBlocked(specs); - // A no-op CI-fix model seam: the fixture never clicks "Fix CI", so it only - // needs to report no sessions hidden. Supplying it avoids the real model, - // which would depend on services not registered in this fixture. - const ciFixModel = new class extends mock() { - override readonly hiddenSessions: IObservable> = constObservable>(new Set()); - }(); - const instantiationService = createEditorServices(disposableStore, { colorTheme: ctx.theme, additionalServices: (reg) => { registerWorkbenchServices(reg); - reg.defineInstance(IBlockedSessionsCIFixModel, ciFixModel); reg.defineInstance(ISessionsService, new class extends mock() { override readonly activeSession: IObservable = constObservable(state.activeSession); override readonly visibleSessions: IObservable = constObservable([]); @@ -176,6 +168,13 @@ function renderTitleBar(ctx: ComponentFixtureContext, state: ITitleBarState): vo override readonly blockedSessionsWithReasons: IObservable = constObservable(blocked); }(); + // A no-op CI-fix model seam: the fixture never clicks "Fix CI", so it only + // needs to report no sessions hidden. Supplying it avoids the real model, + // which would depend on services not registered in this fixture. + const ciFixModel = new class extends mock() { + override readonly hiddenSessions: IObservable> = constObservable>(new Set()); + }(); + const widget = disposableStore.add(instantiationService.createInstance(SessionsTitleBarWidget, action, undefined, sessionActionFeedback, approvalModel, blockedSessionsModel, ciFixModel)); widget.render(widgetHost); } diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 3f3a4b42732f7b..956a1493778a69 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -1371,7 +1371,6 @@ export class TestHostService implements IHostService { async focus(): Promise { } async moveTop(): Promise { } async getCursorScreenPoint(): Promise { return undefined; } - async getWindowPosition(): Promise { return undefined; } async getWindows(options: unknown) { return []; } diff --git a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts index c869d501041c28..bb80b086e45a85 100644 --- a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts @@ -95,7 +95,6 @@ export class TestNativeHostService implements INativeHostService { async getWindows(): Promise { return []; } async getActiveWindowId(): Promise { return undefined; } async getActiveWindowPosition(): Promise { return undefined; } - async getWindowPosition(): Promise { return undefined; } async getNativeWindowHandle(windowId: number): Promise { return undefined; } openWindow(options?: IOpenEmptyWindowOptions): Promise; diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 562b6f7554d735..cca4ad4de676a4 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -234,7 +234,6 @@ import './contrib/inlineChat/browser/inlineChat.contribution.js'; // Copilot Voice import './contrib/agentsVoice/browser/agentsVoice.contribution.js'; - import './contrib/mcp/browser/mcp.contribution.js'; import './contrib/mcp/browser/mcp.view.contribution.js'; import './contrib/chat/browser/chatSessions/chatSessions.contribution.js'; From bda0cbac88057b08176a8825c047a56171956f96 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 18 Aug 2026 15:58:10 -0700 Subject: [PATCH 06/14] list: preserve selection during shift-click (#331501) * list: preserve selection during shift-click Keep retained virtualized rows when the user extends an existing text selection. - Do not release the active selection range before Shift+Click updates the DOM selection. - Add a regression test for extending a selection after its anchor scrolls offscreen. Fixes https://github.com/microsoft/vscode/issues/302390 (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * list: retain shift-drag movement tracking Keep selection retention separate from per-gesture movement tracking. - Re-arm selection drag listeners when Shift extends an active selection. - Dispose selection and movement stores through the list lifecycle. - Exercise Shift+drag events and active-selection disposal in the regression test. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/browser/ui/list/listView.ts | 87 +++++++++++-------- .../test/browser/ui/list/listView.test.ts | 50 +++++++++++ 2 files changed, 100 insertions(+), 37 deletions(-) diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index dd774050479e0f..aee77ac1234f98 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -13,7 +13,7 @@ import { distinct, equals, splice } from '../../../common/arrays.js'; import { Delayer, disposableTimeout } from '../../../common/async.js'; import { memoize } from '../../../common/decorators.js'; import { Emitter, Event, IValueWithChangeEvent } from '../../../common/event.js'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../common/lifecycle.js'; import { IRange, Range } from '../../../common/range.js'; import { INewScrollDimensions, Scrollable, ScrollbarVisibility, ScrollEvent } from '../../../common/scrollable.js'; import { ISpliceable } from '../../../common/sequence.js'; @@ -327,6 +327,7 @@ export class ListView implements IListView { private accessibilityProvider: ListViewAccessibilityProvider; private scrollWidth: number | undefined; + private readonly disposables = new DisposableStore(); private dnd: IListViewDragAndDrop; private canDrop: boolean = false; private currentDragData: IDragAndDropData | undefined; @@ -334,12 +335,11 @@ export class ListView implements IListView { private currentDragFeedbackPosition: ListDragOverEffectPosition | undefined; private currentDragFeedbackDisposable: IDisposable = Disposable.None; private onDragLeaveTimeout: IDisposable = Disposable.None; - private currentSelectionDisposable: IDisposable = Disposable.None; + private readonly currentSelectionDisposable = this.disposables.add(new MutableDisposable()); + private readonly currentSelectionMovementDisposable = this.disposables.add(new MutableDisposable()); private currentSelectionBounds: IRange | undefined; private activeElement: HTMLElement | undefined; - private readonly disposables: DisposableStore = new DisposableStore(); - private readonly _onDidChangeContentHeight = this.disposables.add(new Emitter()); private readonly _onDidChangeContentWidth = this.disposables.add(new Emitter()); readonly onDidChangeContentHeight: Event = Event.latch(this._onDidChangeContentHeight.event, undefined, this.disposables); @@ -1242,61 +1242,72 @@ export class ListView implements IListView { } private onPotentialSelectionStart(e: MouseEvent) { - this.currentSelectionDisposable.dispose(); const doc = getDocument(this.domNode); + this.currentSelectionMovementDisposable.clear(); + + if (e.shiftKey && this.currentSelectionBounds && doc.getSelection()?.isCollapsed === false) { + this.currentSelectionMovementDisposable.value = this.createSelectionMovementStore(doc); + return; + } + + this.currentSelectionDisposable.clear(); // Set up both the 'movement store' for watching the mouse, and the // 'selection store' which lasts as long as there's a selection, even // after the usr has stopped modifying it. - const selectionStore = this.currentSelectionDisposable = new DisposableStore(); - const movementStore = selectionStore.add(new DisposableStore()); + const selectionStore = new DisposableStore(); + this.currentSelectionDisposable.value = selectionStore; + this.currentSelectionMovementDisposable.value = this.createSelectionMovementStore(doc); // The selection events we get from the DOM are fairly limited and we lack a 'selection end' event. // Selection events also don't tell us where the input doing the selection is. So, make a poor // assumption that a user is using the mouse, and base our events on that. - movementStore.add(addDisposableListener(this.domNode, 'selectstart', () => { - movementStore.add(addDisposableListener(doc, 'mousemove', e => { - if (doc.getSelection()?.isCollapsed === false) { - this.setupDragAndDropScrollTopAnimation(e); + selectionStore.add(toDisposable(() => { + this.currentSelectionMovementDisposable.clear(); + const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight); + this.currentSelectionBounds = undefined; + this.render(previousRenderRange, this.lastRenderTop, this.lastRenderHeight, undefined, undefined); + })); + selectionStore.add(addDisposableListener(doc, 'selectionchange', () => { + const selection = doc.getSelection(); + // if the selection changed _after_ mouseup, it's from clearing the list or similar, so teardown + if (!selection || selection.isCollapsed) { + if (!this.currentSelectionMovementDisposable.value) { + this.currentSelectionDisposable.clear(); } - })); + return; + } - // The selection is cleared either on mouseup if there's no selection, or on next mousedown - // when `this.currentSelectionDisposable` is reset. - selectionStore.add(toDisposable(() => { - const previousRenderRange = this.getRenderRange(this.lastRenderTop, this.lastRenderHeight); - this.currentSelectionBounds = undefined; - this.render(previousRenderRange, this.lastRenderTop, this.lastRenderHeight, undefined, undefined); - })); - selectionStore.add(addDisposableListener(doc, 'selectionchange', () => { - const selection = doc.getSelection(); - // if the selection changed _after_ mouseup, it's from clearing the list or similar, so teardown - if (!selection || selection.isCollapsed) { - if (movementStore.isDisposed) { - selectionStore.dispose(); - } - return; + let start = this.getIndexOfListElement(selection.anchorNode as HTMLElement); + let end = this.getIndexOfListElement(selection.focusNode as HTMLElement); + if (start !== undefined && end !== undefined) { + if (end < start) { + [start, end] = [end, start]; } + this.currentSelectionBounds = { start, end }; + } + })); + } - let start = this.getIndexOfListElement(selection.anchorNode as HTMLElement); - let end = this.getIndexOfListElement(selection.focusNode as HTMLElement); - if (start !== undefined && end !== undefined) { - if (end < start) { - [start, end] = [end, start]; - } - this.currentSelectionBounds = { start, end }; + private createSelectionMovementStore(doc: Document): IDisposable { + const movementStore = new DisposableStore(); + movementStore.add(addDisposableListener(this.domNode, 'selectstart', () => { + movementStore.add(addDisposableListener(doc, 'mousemove', e => { + if (doc.getSelection()?.isCollapsed === false) { + this.setupDragAndDropScrollTopAnimation(e); } })); })); - movementStore.add(addDisposableListener(doc, 'mouseup', () => { - movementStore.dispose(); + this.currentSelectionMovementDisposable.clear(); this.teardownDragAndDropScrollTopAnimation(); if (doc.getSelection()?.isCollapsed !== false) { - selectionStore.dispose(); + this.currentSelectionDisposable.clear(); } })); + + return movementStore; } private getIndexOfListElement(element: HTMLElement | null): number | undefined { @@ -1859,6 +1870,8 @@ export class ListView implements IListView { // Dispose dispose() { + this.currentSelectionDisposable.clear(); + for (const item of this.items) { item.dragStartDisposable.dispose(); item.checkedDisposable.dispose(); diff --git a/src/vs/base/test/browser/ui/list/listView.test.ts b/src/vs/base/test/browser/ui/list/listView.test.ts index bb37d185e0053c..98f603971a0762 100644 --- a/src/vs/base/test/browser/ui/list/listView.test.ts +++ b/src/vs/base/test/browser/ui/list/listView.test.ts @@ -527,4 +527,54 @@ suite('ListView', function () { listView.dispose(); } }); + + test('preserves offscreen rows when extending user selection with shift click', function () { + const element = document.createElement('div'); + document.body.appendChild(element); + + const delegate: IListVirtualDelegate = { + getHeight() { return 20; }, + getTemplateId() { return 'template'; } + }; + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate(container) { return container; }, + renderElement(element, _index, container) { container.textContent = String(element); }, + disposeTemplate() { } + }; + + const listView = new ListView(element, delegate, [renderer], { userSelection: true }); + const selection = document.getSelection()!; + try { + listView.layout(60, 200); + listView.splice(0, 0, range(10)); + + const firstRow = listView.domElement(0)!; + const lastSelectedRow = listView.domElement(2)!; + firstRow.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + firstRow.dispatchEvent(new Event('selectstart', { bubbles: true })); + + const selectionRange = document.createRange(); + selectionRange.setStart(firstRow.firstChild!, 0); + selectionRange.setEnd(lastSelectedRow.firstChild!, lastSelectedRow.textContent!.length); + selection.removeAllRanges(); + selection.addRange(selectionRange); + document.dispatchEvent(new Event('selectionchange')); + document.dispatchEvent(new MouseEvent('mouseup')); + + listView.setScrollTop(100); + const extensionRow = listView.domElement(7)!; + extensionRow.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, shiftKey: true })); + extensionRow.dispatchEvent(new Event('selectstart', { bubbles: true })); + document.dispatchEvent(new MouseEvent('mousemove', { clientY: 30 })); + document.dispatchEvent(new MouseEvent('mouseup')); + + assert.strictEqual(listView.domElement(0), firstRow); + } finally { + listView.dispose(); + selection.removeAllRanges(); + document.dispatchEvent(new Event('selectionchange')); + element.remove(); + } + }); }); From ac01a2dd3567a46a74bdf9fa48174ceed847c3f5 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:00:12 +0000 Subject: [PATCH 07/14] Add Start Dictation entry point to editor and terminal context menus (#331369) * Initial plan * Add Start Dictation entry to editor and terminal context menus 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> --- .../browser/dictation/editorDictation.ts | 14 +++++++- .../contrib/terminal/browser/terminalMenus.ts | 36 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts index 6cb40de5b298bb..614554fc065c6c 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.ts @@ -28,7 +28,7 @@ import { EditOperation } from '../../../../../editor/common/core/editOperation.j import { Selection } from '../../../../../editor/common/core/selection.js'; import { Position } from '../../../../../editor/common/core/position.js'; import { Range } from '../../../../../editor/common/core/range.js'; -import { registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { assertReturnsDefined } from '../../../../../base/common/types.js'; import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; import { toAction } from '../../../../../base/common/actions.js'; @@ -71,6 +71,18 @@ export class EditorDictationStartAction extends EditorAction2 { secondary: isWindows ? [ KeyMod.Alt | KeyCode.Backquote ] : undefined + }, + menu: { + id: MenuId.EditorContext, + group: '1_modification', + order: 6, + // Only surface in the context menu when a dictation engine is + // available and the editor is editable. No persistent toolbar + // button is added; the entry point stays confined to the menu. + when: ContextKeyExpr.and( + ContextKeyExpr.or(HasSpeechProvider, BuiltinDictationConfigured), + EditorContextKeys.readOnly.toNegated() + ) } }); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts b/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts index 4e645b1cd3f198..576424a1298a9b 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts @@ -19,6 +19,7 @@ import { terminalStrings } from '../common/terminalStrings.js'; import { ACTIVE_GROUP, AUX_WINDOW_GROUP, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { HasSpeechProvider } from '../../speech/common/speechService.js'; +import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { hasKey } from '../../../../base/common/types.js'; import { TerminalContribContextKeyStrings } from '../terminalContribExports.js'; @@ -38,6 +39,17 @@ export const enum TerminalMenuBarGroup { Configure = '7_configure' } +/** + * True when a dictation engine is available for the terminal: either the + * built-in on-device engine (with AI features enabled) or the speech + * extension's provider. Used to gate the "Start Dictation" context menu entry + * so it only shows when dictation can actually be started. + */ +const TerminalDictationAvailable = ContextKeyExpr.or( + HasSpeechProvider, + ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.speechToTextConfigured) +); + export function setupTerminalMenus(): void { MenuRegistry.appendMenuItems( [ @@ -182,6 +194,18 @@ export function setupTerminalMenus(): void { order: 3 } }, + { + id: MenuId.TerminalInstanceContext, + item: { + command: { + id: TerminalCommandId.StartVoice, + title: localize('workbench.action.terminal.startVoiceContext', "Start Dictation"), + }, + group: TerminalContextMenuGroup.Edit, + order: 4, + when: ContextKeyExpr.and(TerminalDictationAvailable, TerminalContextKeys.terminalDictationInProgress.toNegated()) + } + }, ] ); @@ -291,6 +315,18 @@ export function setupTerminalMenus(): void { order: 3 } }, + { + id: MenuId.TerminalEditorInstanceContext, + item: { + command: { + id: TerminalCommandId.StartVoice, + title: localize('workbench.action.terminal.startVoiceContext', "Start Dictation"), + }, + group: TerminalContextMenuGroup.Edit, + order: 4, + when: ContextKeyExpr.and(TerminalDictationAvailable, TerminalContextKeys.terminalDictationInProgress.toNegated()) + } + }, { id: MenuId.TerminalEditorInstanceContext, item: { From 01653d63e5a24ef10a338661bd44ab1d86676e56 Mon Sep 17 00:00:00 2001 From: Bryan Chen <41454397+bryanchen-d@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:15:42 -0700 Subject: [PATCH 08/14] Add UI scenario validation skill (#331558) Document how to reproduce a UI scenario through the automation MCP and capture video, screenshots, a trace, and an HTML report, and let evidence capture skip the in-window step banner so the recording shows unmodified product UI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 --- .../skills/ui-scenario-validation/SKILL.md | 87 +++++++++++++++++++ test/mcp/src/evidence.ts | 6 ++ 2 files changed, 93 insertions(+) create mode 100644 .github/skills/ui-scenario-validation/SKILL.md diff --git a/.github/skills/ui-scenario-validation/SKILL.md b/.github/skills/ui-scenario-validation/SKILL.md new file mode 100644 index 00000000000000..8f7fb400fc0297 --- /dev/null +++ b/.github/skills/ui-scenario-validation/SKILL.md @@ -0,0 +1,87 @@ +--- +name: ui-scenario-validation +description: Use when reproducing a UI bug or verifying a fix by driving a real VS Code window end to end and capturing evidence. Launches VS Code through the automation MCP, performs the scenario as a user would, and produces a video, per-step screenshots, a Playwright trace, and an HTML report to attach to an issue or pull request. +--- + +# UI Scenario Validation + +Drives a real VS Code instance through a scenario and records reproducible evidence. + +Use this to reproduce a reported bug, to show that a fix works, or to attach a recording to a +test-plan item. For deterministic regression coverage that runs on every build, write a smoke test +instead (see the `smoke-tests` skill) — this skill is for one-off, issue-derived validation. + +## Prerequisites + +```bash +npm install # once +npm run electron # download the Electron runtime +npm run transpile-client # or `npm run watch` in another terminal +npm --prefix test/mcp run compile +``` + +The automation MCP server is `test/mcp` (`out/stdio.js`). Add it to your MCP configuration so the +`vscode_automation_*` tools are available; append `--web --headless` to the args to record the web +build instead of Electron. + +## Record a clean capture + +Set `VSCODE_EVIDENCE_CLEAN_CAPTURE=1` in the MCP server environment. + +Evidence capture can draw a step banner into the window it is recording. That banner is part of the +DOM of the product under test, so it can shift layout and affect focus and selectors. With clean +capture enabled the recording shows unmodified UI, and step boundaries are still recorded in +`manifest.json` with timestamps and screenshots. + +## Run a scenario + +1. Choose a **disposable** workspace folder. Never point a scenario at real work: the run types, + clicks, and may modify files. Nothing in the recording should contain credentials, tokens, or + private conversations. +2. Call `vscode_automation_evidence_start` **before** any other automation tool, passing the + scenario id, title, the source issue URL, and the workspace path. It launches VS Code with an + isolated profile and starts video plus tracing. +3. For each step: + - call `vscode_automation_evidence_step` with `status: started` and a one-line intent; + - inspect the accessibility snapshot before choosing a selector; + - prefer feature-specific automation tools, then semantic selectors, then coordinates; + - perform the action the way a user would; + - **validate through a separate observable signal** — an action completing is not a result; + - call the step again with `passed`, `failed`, or `skipped` plus concise details. +4. Call `vscode_automation_evidence_finish` with the overall outcome. This stops VS Code and + finalizes the video, trace, screenshots, `manifest.json`, and `report.html`. + +Stop at the first failed required step unless the scenario says otherwise, and mark steps that need +unavailable hardware, accounts, or services as `skipped` rather than passed. + +## What makes evidence trustworthy + +- Assert on DOM state, accessibility, focus, or text — screenshots support a claim, they do not + establish one. +- If the bug is a race, make the timing explicit (for example a forced delay or a repeated loop) so + the recording shows the window in which it occurs rather than relying on luck. +- Record the failing behavior before the fix when you can. A passing run alone does not show that + the scenario would have caught the bug. + +## Report + +Evidence is written to `.build/vscode-playwright-mcp/evidence//`: + +| File | Contents | +|------|----------| +| `report.html` | Step table, outcome, embedded video | +| `manifest.json` | Step timestamps, statuses, artifact paths, environment | +| `videos/` | Screen recording of the run | +| `*.png` | Per-step screenshots | +| `logs/` | Playwright trace, window and server logs | + +Summarize the outcome, list failed or skipped steps, link `report.html`, and state the OS, VS Code +commit, and source issue. Attach the video to the issue or pull request by dragging it into the +comment box. + +## Automated validation on a pull request + +`microsoft/vscode-engineering` runs the same harness in CI: labelling a pull request +`~requires-ui-validation` researches the change, runs a checked-in scenario adapter against the +exact merge candidate, and posts the per-step result with chaptered video. Use this skill when a +scenario is not yet covered there, or to iterate locally before proposing one. diff --git a/test/mcp/src/evidence.ts b/test/mcp/src/evidence.ts index 48f8bcdc38db58..bb45143f2e9181 100644 --- a/test/mcp/src/evidence.ts +++ b/test/mcp/src/evidence.ts @@ -391,6 +391,12 @@ export class EvidenceService { } private async showOverlay(id: string, title: string, status: string): Promise { + if (process.env.VSCODE_EVIDENCE_CLEAN_CAPTURE === '1') { + // The overlay is appended to the DOM of the product under test, so it can + // shift layout and influence focus or selectors. Callers that annotate the + // recording afterwards opt out to keep the capture faithful. + return; + } const app = this.appService.application; if (!app) { throw new Error('VS Code is not running.'); From dbe7b7f42debf9b2e88ab3f658637baf970d7642 Mon Sep 17 00:00:00 2001 From: Aaron Munger <2019016+amunger@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:21:50 -0700 Subject: [PATCH 09/14] agentHost: report completed model calls per turn (#331551) * agentHost: report completed model calls per turn Count stable provider response boundaries across Copilot, Claude, and Codex so turn telemetry can analyze agent loop depth without counting streaming fragments or duplicate usage updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: refine model call counting Document the Codex usage-based approximation and avoid overcounting split Copilot responses when call-level identifiers are unavailable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/common/agent.ts | 19 ++- .../node/agentHostTelemetryReporter.ts | 6 +- .../agentHost/node/agentHostTurnTracker.ts | 7 ++ .../agentHost/node/agentSideEffects.ts | 24 +++- .../node/claude/claudeMapSessionEvents.ts | 11 +- .../node/claude/claudeSubagentSignals.ts | 3 + .../agentHost/node/codex/codexAgent.ts | 42 ++++++- .../node/codex/codexMapAppServerEvents.ts | 23 ++++ .../node/copilot/copilotAgentSession.ts | 27 +++- .../test/node/agentHostTurnTelemetry.test.ts | 75 +++++++++++ .../agentHost/test/node/claudeAgent.test.ts | 2 +- .../test/node/claudeMapSessionEvents.test.ts | 45 ++++++- .../test/node/claudeSubagentSignals.test.ts | 5 +- .../codex/codexMapAppServerEvents.test.ts | 21 +++- .../test/node/copilotAgentSession.test.ts | 116 +++++++++++++----- 15 files changed, 373 insertions(+), 53 deletions(-) diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index c853d7bba0c97a..2a352dcc39a344 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -780,12 +780,12 @@ export interface IAgentModelInfo { * Most signals carry a protocol {@link SessionAction} directly via the * `kind: 'action'` shape, eliminating a parallel event ontology. A small * number of cases that have no clean protocol action (permission - * auto-approval, subagent session creation, steering message - * acknowledgment) remain as discriminated non-action signals so the host - * can perform side effects before — or instead of — dispatching an action. + * auto-approval, subagent session creation, steering acknowledgment, and + * host-owned model-call telemetry) remain as discriminated non-action signals. */ export type AgentSignal = | IAgentActionSignal + | IAgentModelCallCompletedSignal | IAgentToolPendingConfirmationSignal | IAgentSubagentStartedSignal | IAgentSubagentResumedSignal @@ -810,6 +810,19 @@ export interface IAgentActionSignal { readonly parentToolCallId?: string; } +/** Reports one completed upstream model response for host-owned turn telemetry. */ +export interface IAgentModelCallCompletedSignal { + readonly kind: 'model_call_completed'; + /** Target chat channel URI. For inner subagent calls this is the parent chat channel. */ + readonly resource: URI; + /** Provider-reported turn identifier. The host remaps it when routing to a subagent chat. */ + readonly turnId: string; + /** Stable provider message or response identifier used to suppress duplicate notifications. */ + readonly modelCallId: string; + /** If set, route the model call to the subagent session belonging to this tool call. */ + readonly parentToolCallId?: string; +} + /** * A tool has finished collecting parameters and needs the host to decide * whether it should run (or, mid-execution, re-confirm). The host applies diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index 26bb635152237b..bb42ed8fe11db3 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -186,6 +186,7 @@ export interface IAgentHostTurnCompletedEvent extends IAgentHostInitiatorTelemet isMultiRoot: boolean; folderCount: number; billedNanoAiu: number | undefined; + modelCallCount: number; } export type IAgentHostTurnCompletedClassification = IAgentHostInitiatorClassification & { @@ -207,8 +208,9 @@ export type IAgentHostTurnCompletedClassification = IAgentHostInitiatorClassific isMultiRoot: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the session spans more than one working directory.' }; folderCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of effective working directories for the session at turn completion.' }; billedNanoAiu: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The AI credit usage billed for the turn in nano-AIU, when reported by the provider.' }; + modelCallCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of completed upstream model responses attributed directly to the turn.' }; owner: 'roblourens'; - comment: 'Tracks agent host turn completion, including performance, configuration context, and billed AI credit usage when reported by the provider.'; + comment: 'Tracks agent host turn completion, including performance, configuration context, completed model responses, and billed AI credit usage when reported by the provider.'; }; export interface IAgentHostTurnFailedEvent extends IAgentHostInitiatorTelemetry { @@ -269,6 +271,7 @@ export interface IAgentHostTurnCompletedReport extends IAgentHostTurnAttributedR isMultiRoot: boolean; folderCount: number; billedNanoAiu: number | undefined; + modelCallCount: number; } /** @@ -1146,6 +1149,7 @@ export class AgentHostTelemetryReporter { isMultiRoot: report.isMultiRoot, folderCount: report.folderCount, billedNanoAiu: report.billedNanoAiu, + modelCallCount: report.modelCallCount, }); if (report.failure) { const { providerCallId, serviceRequestId } = readAgentErrorTelemetryMeta(report.failure.error); diff --git a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts index bea1d2416b5016..1c66fc71c6f989 100644 --- a/src/vs/platform/agentHost/node/agentHostTurnTracker.ts +++ b/src/vs/platform/agentHost/node/agentHostTurnTracker.ts @@ -60,6 +60,7 @@ interface ITurnTiming { readonly permissionLevel: string | undefined; readonly interactionMode: SessionMode | undefined; readonly clientContext: IAgentHostClientTelemetryContext; + readonly completedModelCallIds: Set; firstProgressMs: number | undefined; currentStage: AgentHostTurnFailureStage; @@ -155,6 +156,7 @@ export class AgentHostTurnTracker extends Disposable { permissionLevel, interactionMode, clientContext, + completedModelCallIds: new Set(), firstProgressMs: undefined, currentStage: 'validation', quietStopWatch: StopWatch.create(false), @@ -313,6 +315,10 @@ export class AgentHostTurnTracker extends Disposable { } } + modelCallCompleted(session: string, turnId: string, modelCallId: string): void { + this._turnTimings.get(this._key(session, turnId))?.completedModelCallIds.add(modelCallId); + } + getModelTelemetryContext(session: string, turnId: string): { model: string | undefined; modelTelemetryKind: AgentHostModelTelemetryKind | undefined } | undefined { const timing = this._turnTimings.get(this._key(session, turnId)); return timing ? { model: timing.model, modelTelemetryKind: timing.modelTelemetryKind } : undefined; @@ -348,6 +354,7 @@ export class AgentHostTurnTracker extends Disposable { isMultiRoot: workspace?.isMultiRoot ?? false, folderCount: workspace?.folderCount ?? 0, billedNanoAiu: usage?.billedNanoAiu, + modelCallCount: timing.completedModelCallIds.size, }); // Paired recovery event: the turn was reported as hung but did finish, diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 648cac93a03ad0..b43656c0559a7c 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -21,7 +21,7 @@ import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostMarkdownPlanRich import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { readAgentModelByokIdentifier } from '../common/agentModelByokMeta.js'; -import { AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentToolPendingConfirmationSignal } from '../common/agent.js'; +import { AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentToolPendingConfirmationSignal, type IAgentModelCallCompletedSignal } from '../common/agent.js'; import { readToolCallMeta, toToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; @@ -843,7 +843,7 @@ export class AgentSideEffects extends Disposable { }); return; } - const signalResource = signal.kind === 'action' ? signal.resource.toString() : signal.chat.toString(); + const signalResource = signal.kind === 'action' || signal.kind === 'model_call_completed' ? signal.resource.toString() : signal.chat.toString(); if (signal.kind === 'action' && !isChatAction(signal.action) && isAhpChatChannel(signalResource)) { throw new Error(`Session action ${signal.action.type} must not be dispatched on chat channel ${signalResource}`); } @@ -862,7 +862,11 @@ export class AgentSideEffects extends Disposable { if (subagentSession) { const subTurnId = this._stateManager.getActiveTurnId(subagentSession.chatUri); if (subTurnId) { - this._dispatchActionForSession(signal, subagentSession.chatUri, subTurnId, 'remap', agent); + if (signal.kind === 'model_call_completed') { + this._recordModelCallCompleted(signal, subagentSession.chatUri, subTurnId, 'remap'); + } else { + this._dispatchActionForSession(signal, subagentSession.chatUri, subTurnId, 'remap', agent); + } } else { this._logService.error(`[AgentSideEffects] Dropping ${this._describeSignal(signal)} for inactive subagent ${sessionKey}/${parentToolCallId}`); if (signal.kind === 'pending_confirmation') { @@ -908,7 +912,11 @@ export class AgentSideEffects extends Disposable { const turnId = this._stateManager.getActiveTurnId(sessionKey); if (turnId) { - this._dispatchActionForSession(signal, sessionKey, turnId, 'preserve', agent); + if (signal.kind === 'model_call_completed') { + this._recordModelCallCompleted(signal, sessionKey, turnId, 'preserve'); + } else { + this._dispatchActionForSession(signal, sessionKey, turnId, 'preserve', agent); + } return; } @@ -1099,6 +1107,14 @@ export class AgentSideEffects extends Disposable { } } + private _recordModelCallCompleted(signal: IAgentModelCallCompletedSignal, sessionKey: ProtocolURI, turnId: string, turnIdRouting: AgentSignalTurnIdRouting): void { + if (signal.turnId !== turnId && turnIdRouting === 'preserve') { + this._logService.trace(`[AgentSideEffects] Dropping stale model_call_completed for ${sessionKey}: producerTurnId=${signal.turnId}, activeTurnId=${turnId}`); + return; + } + this._turnTracker.modelCallCompleted(sessionKey, turnId, signal.modelCallId); + } + /** * Completes a turn's telemetry, enriching it with the session's working- * directory shape. Normalizes a chat channel to its owning session URI diff --git a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts index e2a2ef30cc0e76..143edc009e79af 100644 --- a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts @@ -307,8 +307,15 @@ function mapAssistantCanonical( registry: SubagentRegistry, clientToolOwner?: (toolName: string) => string | undefined, ): AgentSignal[] { + const completedSignal: AgentSignal = { + kind: 'model_call_completed', + resource: chat, + turnId, + modelCallId: message.message.id, + }; + const completedSignals = message.aborted ? [] : [completedSignal]; if (parentToolUseId === null) { - const top: AgentSignal[] = []; + const top: AgentSignal[] = [...completedSignals]; for (const block of message.message.content) { if (block.type !== 'tool_use' || !SUBAGENT_SPAWNING_TOOL_NAMES.has(block.name)) { continue; @@ -317,7 +324,7 @@ function mapAssistantCanonical( } return top; } - return emitInnerAssistantSignals(message, chat, turnId, state, parentToolUseId, registry, clientToolOwner); + return [...completedSignals, ...emitInnerAssistantSignals(message, chat, turnId, state, parentToolUseId, registry, clientToolOwner)]; } /** diff --git a/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts b/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts index e010c91f5affec..f4b44e0d4a2df9 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts @@ -53,6 +53,9 @@ export function tagWithParent( if (s.kind === 'pending_confirmation') { return { ...s, parentToolCallId: parentToolUseId }; } + if (s.kind === 'model_call_completed') { + return { ...s, parentToolCallId: parentToolUseId }; + } return s; }); const spawn = registry.getSpawn(parentToolUseId); diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index d59cf326ea39c1..10993ac0b83bbd 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -68,7 +68,8 @@ import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { CodexAppServerClient, JsonRpcError, transportFromChildProcess, type ICodexAppServerClient, type ServerRequestHandlerResult } from './codexAppServerClient.js'; import { ICodexProxyService, type ICodexProxyHandle } from './codexProxyService.js'; -import { createCodexSessionMapState, extractUserInputText, finalizeCodexTurnMapState, mapAgentMessageDelta, mapCommandExecutionOutputDelta, mapFileChangeOutputDelta, mapFileChangePatchUpdated, mapItemCompleted, mapItemStarted, mapMcpToolCallProgress, mapReasoningSummaryPartAdded, mapReasoningSummaryTextDelta, mapReasoningTextDelta, mapTokenUsageUpdated, mapTurnCompleted, mapTurnStarted, type ICodexSessionMapState } from './codexMapAppServerEvents.js'; +import { createCodexSessionMapState, extractUserInputText, finalizeCodexTurnMapState, mapAgentMessageDelta, mapCommandExecutionOutputDelta, mapFileChangeOutputDelta, mapFileChangePatchUpdated, mapItemCompleted, mapItemStarted, mapMcpToolCallProgress, mapReasoningSummaryPartAdded, mapReasoningSummaryTextDelta, mapReasoningTextDelta, mapTokenUsageModelCallCompleted, mapTokenUsageUpdated, mapTurnCompleted, mapTurnStarted, type ICodexSessionMapState } from './codexMapAppServerEvents.js'; +import type { ThreadTokenUsageUpdatedNotification } from './protocol/generated/v2/ThreadTokenUsageUpdatedNotification.js'; import { unwrapShellInvocation } from './codexShellCommand.js'; import { planForkedTurnIdMap, resolveForkBoundary } from './codexForkPlan.js'; import { resolveCodexInput } from './codexPromptResolver.js'; @@ -637,6 +638,8 @@ interface ICodexSession { customizationDirectory: URI | undefined; /** Workbench-facing turn id for the active turn. */ currentTurnId: string | undefined; + /** Cumulative token-usage identity last observed for model-call deduplication. */ + lastModelCallUsageId?: string; /** Local monotonic timer for the active workbench-facing turn. */ turnStopWatch: StopWatch | undefined; /** Codex app-server turn id for the active turn. */ @@ -1955,7 +1958,7 @@ export class CodexAgent extends Disposable implements IAgent { this._register(client.onNotification('item/reasoning/summaryPartAdded', params => this._dispatchByThread(params.threadId, s => mapReasoningSummaryPartAdded(s.mapState, this._withHostTurnId(s, params))))); this._register(client.onNotification('item/reasoning/summaryTextDelta', params => this._dispatchByThread(params.threadId, s => mapReasoningSummaryTextDelta(s.mapState, this._withHostTurnId(s, params))))); this._register(client.onNotification('item/reasoning/textDelta', params => this._dispatchByThread(params.threadId, s => mapReasoningTextDelta(s.mapState, this._withHostTurnId(s, params))))); - this._register(client.onNotification('thread/tokenUsage/updated', params => this._dispatchByThread(params.threadId, s => s.currentTurnId ? mapTokenUsageUpdated(this._withHostTurnId(s, params), s.model?.id) : []))); + this._register(client.onNotification('thread/tokenUsage/updated', params => this._dispatchTokenUsageUpdated(params))); this._register(client.onNotification('item/completed', params => this._dispatchItemCompleted(params))); this._register(client.onNotification('turn/completed', params => this._dispatchTurnCompleted(params))); // Auto-review (guardian) surfacing. The guardian warning is shown as a @@ -2613,6 +2616,41 @@ export class CodexAgent extends Disposable implements IAgent { } } + private _dispatchTokenUsageUpdated(params: ThreadTokenUsageUpdatedNotification): void { + const subagent = this._subagentsByThreadId.get(params.threadId); + if (subagent) { + const mapped = this._withHostTurnId(subagent.session, params); + for (const action of mapTokenUsageUpdated(mapped, subagent.session.model?.id)) { + this._fireSubagent(subagent, action); + } + const modelCall = mapTokenUsageModelCallCompleted(mapped, subagent.session.chatChannel!); + if (subagent.session.lastModelCallUsageId !== modelCall.modelCallId) { + subagent.session.lastModelCallUsageId = modelCall.modelCallId; + this._onDidChatProgress.fire({ ...modelCall, parentToolCallId: subagent.toolCallId }); + } + return; + } + const sessionId = this._sessionIdByThreadId.get(params.threadId); + const session = sessionId ? this._sessions.get(sessionId) : undefined; + if (!session?.chatChannel) { + this._logService.trace(`[Codex] Ignoring token usage for inactive threadId=${params.threadId}`); + return; + } + const mapped = this._withHostTurnId(session, params); + const modelCall = mapTokenUsageModelCallCompleted(mapped, session.chatChannel); + const isNewModelCall = session.lastModelCallUsageId !== modelCall.modelCallId; + session.lastModelCallUsageId = modelCall.modelCallId; + if (!session.currentTurnId) { + return; + } + for (const action of mapTokenUsageUpdated(mapped, session.model?.id)) { + this._fire(session.sessionUri, action); + } + if (isNewModelCall) { + this._onDidChatProgress.fire(modelCall); + } + } + /** * `item/completed` dispatch. In addition to the normal per-thread mapping, * a parent session's completed `spawnAgent` collab tool call now carries diff --git a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts index b7ffe756ccf6e5..e0aa3a1251b580 100644 --- a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts +++ b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { generateUuid } from '../../../../base/common/uuid.js'; +import type { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; +import type { IAgentModelCallCompletedSignal } from '../../common/agent.js'; import { toToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { ActionType, type SessionAction, type ChatAction } from '../../common/state/sessionActions.js'; import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolResultContentType, TurnState, type ErrorInfo } from '../../common/state/sessionState.js'; @@ -503,6 +505,27 @@ export function mapTokenUsageUpdated(params: ThreadTokenUsageUpdatedNotification }]; } +/** + * Codex does not expose its exact response-completion event on resumed threads, so cumulative + * usage changes are the closest lifecycle signal available across the full session population. + */ +export function mapTokenUsageModelCallCompleted(params: ThreadTokenUsageUpdatedNotification, resource: URI): IAgentModelCallCompletedSignal { + const total = params.tokenUsage.total; + return { + kind: 'model_call_completed', + resource, + turnId: params.turnId, + modelCallId: [ + total.inputTokens, + total.cachedInputTokens, + total.cacheWriteInputTokens, + total.outputTokens, + total.reasoningOutputTokens, + total.totalTokens, + ].join(':'), + }; +} + /** * `item/started` for an `agentMessage` becomes a `ChatResponsePart` * action with an empty `MarkdownResponsePart` shell. Subsequent diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 0b354d74a2a99e..cf2d50c3663d0f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -579,6 +579,7 @@ class CopilotTurn { * agent only), for the restricted `toolCallDetails` telemetry. `toolCounts` is keyed by tool name. */ readonly toolCounts = new Map(); + readonly mainModelCallIds = new Set(); toolCallRounds = 0; totalToolCalls = 0; parallelToolCallRounds = 0; @@ -1022,6 +1023,16 @@ export class CopilotAgentSession extends Disposable { }); } + private _emitModelCallCompleted(turnId: string, modelCallId: string, parentToolCallId?: string): void { + this._onDidSessionProgress.fire({ + kind: 'model_call_completed', + resource: this._chatChannelUri, + turnId, + modelCallId, + parentToolCallId, + }); + } + /** * Promotes a pending steering message into its own protocol turn: * closes the in-flight turn (so its responseParts settle into history) @@ -3937,6 +3948,16 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onMessage(e => { this._logService.info(`[Copilot:${sessionId}] Full message received: ${e.data.content.length} chars`); this._resumeSubagentForEvent(e); + const stableModelCallId = e.data.apiCallId ?? e.data.clientRequestId; + const isCompleteModelCall = stableModelCallId !== undefined + || e.data.chunkCount === undefined + || e.data.chunkCount <= 1 + || e.data.chunkIndex === e.data.chunkCount - 1; + const modelCallId = stableModelCallId ?? e.data.messageId; + const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); + if (isCompleteModelCall && (!e.agentId || parentToolCallId)) { + this._emitModelCallCompleted(this._turnId, modelCallId, parentToolCallId); + } // Report the enhanced GH `request.options.tools` event for this model call — parity with // the Copilot extension, which emits it per LLM request. `assistant.message` is the // agent-host's per-model-call boundary; we correlate on its client-minted `x-request-id`. @@ -3953,7 +3974,10 @@ export class CopilotAgentSession extends Disposable { // too); the tool-count stats only apply to rounds that carried tool requests. const turn = this._currentTurn; if (turn) { - turn.toolCallRounds++; + if (isCompleteModelCall && !turn.mainModelCallIds.has(modelCallId)) { + turn.mainModelCallIds.add(modelCallId); + turn.toolCallRounds++; + } if (e.data.model) { turn.lastModel = e.data.model; } @@ -3982,7 +4006,6 @@ export class CopilotAgentSession extends Disposable { if (this._shouldDropUnmappedSubagentEvent(e, 'assistant.message')) { return; } - const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); const markdownScope = parentToolCallId ?? ''; if (e.data.content && !this._currentTurn?.markdownPartIds.has(markdownScope)) { const partId = generateUuid(); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index e5e8699fc0ccd7..332de78f134e09 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -159,6 +159,10 @@ suite('AgentSideEffects — turn tracker telemetry', () => { agent.fireProgress({ kind: 'action', resource: URI.parse(chatUri), action }); } + function fireModelCallCompleted(turnId: string, modelCallId: string, chatUri = defaultChatUri): void { + agent.fireProgress({ kind: 'model_call_completed', resource: URI.parse(chatUri), turnId, modelCallId }); + } + function completedEvents(): { eventName: string; data: unknown }[] { return telemetry.events.filter(e => e.eventName === 'agentHost.turnCompleted'); } @@ -292,6 +296,77 @@ suite('AgentSideEffects — turn tracker telemetry', () => { }]); }); + test('counts unique completed model responses on the turn', () => { + setupSession(); + startTurn('turn-model-calls'); + + fireModelCallCompleted('turn-model-calls', 'call-1'); + fireModelCallCompleted('turn-model-calls', 'call-1'); + fireModelCallCompleted('turn-model-calls', 'call-2'); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-model-calls', duration: 1000 }); + + assert.strictEqual((completedEvents()[0].data as Record).modelCallCount, 2); + }); + + test('does not attribute a stale model response to the active turn', () => { + setupSession(); + startTurn('turn-old'); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-old', duration: 1000 }); + startTurn('turn-active'); + + fireModelCallCompleted('turn-old', 'late-call'); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-active', duration: 1000 }); + + assert.deepStrictEqual(completedEvents().map(event => { + const data = event.data as Record; + return { turnId: data.turnId, modelCallCount: data.modelCallCount }; + }), [ + { turnId: 'turn-old', modelCallCount: 0 }, + { turnId: 'turn-active', modelCallCount: 0 }, + ]); + }); + + test('attributes subagent model responses only to the subagent turn', () => { + setupSession(); + startTurn('turn-parent'); + const subagentChatUri = buildSubagentChatUri(sessionUri, 'call-subagent'); + stateManager.addChat(sessionKey, subagentChatUri); + fire({ + type: ActionType.ChatToolCallStart, + turnId: 'turn-parent', + toolCallId: 'call-subagent', + toolName: 'task', + displayName: 'Task', + }); + agent.fireProgress({ + kind: 'subagent_started', + chat: URI.parse(defaultChatUri), + toolCallId: 'call-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + }); + + const subagentTurnId = stateManager.getActiveTurnId(subagentChatUri); + assert.ok(subagentTurnId); + agent.fireProgress({ + kind: 'model_call_completed', + resource: URI.parse(defaultChatUri), + turnId: 'turn-parent', + modelCallId: 'subagent-model-call', + parentToolCallId: 'call-subagent', + }); + fire({ type: ActionType.ChatTurnComplete, turnId: subagentTurnId, duration: 1000 }, subagentChatUri); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-parent', duration: 1000 }); + + assert.deepStrictEqual(completedEvents().map(event => { + const data = event.data as Record; + return { isSubagentSession: data.isSubagentSession, modelCallCount: data.modelCallCount }; + }), [ + { isSubagentSession: true, modelCallCount: 1 }, + { isSubagentSession: false, modelCallCount: 0 }, + ]); + }); + test('emits turnCompleted with the multi-root working-directory shape', () => { setupSession(true, ['file:///work/app', 'file:///work/api']); startTurn('turn-mr', 'hello'); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index f00ca2aacecad2..65626daff0e9c2 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -4532,7 +4532,7 @@ suite('ClaudeAgent', () => { const sessionUri = created.session; const observed: AgentSignal[] = []; disposables.add(agent.onDidChatProgress(s => { - const resource = s.kind === 'action' ? s.resource : s.chat; + const resource = s.kind === 'action' || s.kind === 'model_call_completed' ? s.resource : s.chat; if ((parseDefaultChatUri(resource) ?? resource.toString()) === sessionUri.toString()) { observed.push(s); } diff --git a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts index 229557d89e19a8..0e86edb50e7e0f 100644 --- a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts @@ -92,6 +92,37 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(signals, []); }); + test('canonical assistant message reports one completed model call', () => { + const signals = mapSDKMessageToAgentSignals( + makeAssistantMessage(SESSION_ID, []), + SESSION, + TURN_ID, + new ClaudeMapperState(), + new NullLogService(), + r(), + ); + + assert.deepStrictEqual(signals, [{ + kind: 'model_call_completed', + resource: SESSION, + turnId: TURN_ID, + modelCallId: 'msg_test', + }]); + }); + + test('aborted canonical assistant message does not report a completed model call', () => { + const signals = mapSDKMessageToAgentSignals( + { ...makeAssistantMessage(SESSION_ID, []), aborted: true as const }, + SESSION, + TURN_ID, + new ClaudeMapperState(), + new NullLogService(), + r(), + ); + + assert.deepStrictEqual(signals, []); + }); + test('error_during_execution result emits a ChatError carrying duration and _meta', () => { const marker = encodeForwardedChatError({ fetchError: { type: 'quotaExceeded', capiError: { code: 'quota_exceeded', message: 'You have exceeded your monthly quota' } } }); const signals = mapSDKMessageToAgentSignals( @@ -778,7 +809,12 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { r(), ); - assert.deepStrictEqual(signals, []); + assert.deepStrictEqual(signals, [{ + kind: 'model_call_completed', + resource: SESSION, + turnId: TURN_ID, + modelCallId: 'msg_test', + }]); assert.deepStrictEqual(log.warns, []); }); @@ -796,7 +832,12 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { r(), ); - assert.deepStrictEqual(signals, []); + assert.deepStrictEqual(signals, [{ + kind: 'model_call_completed', + resource: SESSION, + turnId: TURN_ID, + modelCallId: 'msg_test', + }]); assert.deepStrictEqual(log.warns, []); }); diff --git a/src/vs/platform/agentHost/test/node/claudeSubagentSignals.test.ts b/src/vs/platform/agentHost/test/node/claudeSubagentSignals.test.ts index 4d5128c2f4a687..ea8a9ad81e6c0b 100644 --- a/src/vs/platform/agentHost/test/node/claudeSubagentSignals.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSubagentSignals.test.ts @@ -229,6 +229,7 @@ suite('claudeSubagentSignals — Phase 12 emission', () => { const kinds = fromAssistant.map(s => s.kind); const allParentIds = [...fromAssistant, ...fromToolResult].filter(s => s.kind === 'action').map(s => s.kind === 'action' ? s.parentToolCallId : null); + const modelCallParentId = fromAssistant.find(s => s.kind === 'model_call_completed')?.parentToolCallId; const completeAction = fromToolResult.find(s => s.kind === 'action' && s.action.type === ActionType.ChatToolCallComplete); const completePastTense = completeAction?.kind === 'action' && completeAction.action.type === ActionType.ChatToolCallComplete ? completeAction.action.result.pastTenseMessage @@ -239,16 +240,18 @@ suite('claudeSubagentSignals — Phase 12 emission', () => { toolUseEdge: registry.getParentSpawn('toolu_inner_glob')?.toolUseId, fromToolResultHasComplete: completeAction !== undefined, everyActionTaggedWithParent: allParentIds.every(p => p === PARENT), + modelCallParentId, // D6 parity: inner-tool past-tense must use the rich helper // (seeded by `seedParsedInput` at start time), not fall back to // the generic "{displayName} finished" — replay always renders // rich text, so a generic live message would silently diverge. completePastTense, }, { - fromAssistantKinds: ['subagent_started', 'action', 'action', 'action'], + fromAssistantKinds: ['subagent_started', 'model_call_completed', 'action', 'action', 'action'], toolUseEdge: PARENT, fromToolResultHasComplete: true, everyActionTaggedWithParent: true, + modelCallParentId: PARENT, completePastTense: { markdown: 'Find files matching `**/*.ts`' }, }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts index 661a4af6f7558e..f60b37f5877093 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { readAgentMessageDelegationMeta } from '../../../common/meta/agentMessageDelegationMeta.js'; -import { createCodexSessionMapState, extractUserInputText, finalizeCodexTurnMapState, mapAgentMessageDelta, mapCommandExecutionOutputDelta, mapFileChangePatchUpdated, mapItemCompleted, mapItemStarted, mapMcpToolCallProgress, mapReasoningSummaryPartAdded, mapReasoningSummaryTextDelta, mapReasoningTextDelta, mapTokenUsageUpdated, mapTurnCompleted, mapTurnStarted, resetCodexTurnMapState, turnStateFromStatus } from '../../../node/codex/codexMapAppServerEvents.js'; +import { createCodexSessionMapState, extractUserInputText, finalizeCodexTurnMapState, mapAgentMessageDelta, mapCommandExecutionOutputDelta, mapFileChangePatchUpdated, mapItemCompleted, mapItemStarted, mapMcpToolCallProgress, mapReasoningSummaryPartAdded, mapReasoningSummaryTextDelta, mapReasoningTextDelta, mapTokenUsageModelCallCompleted, mapTokenUsageUpdated, mapTurnCompleted, mapTurnStarted, resetCodexTurnMapState, turnStateFromStatus } from '../../../node/codex/codexMapAppServerEvents.js'; import { ActionType, type ChatAction, type SessionAction } from '../../../common/state/sessionActions.js'; import { chatReducer } from '../../../common/state/protocol/reducers.js'; import { ChatOriginKind, MessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolResultContentType, TurnState, type ChatState } from '../../../common/state/sessionState.js'; @@ -246,6 +247,24 @@ suite('codexMapAppServerEvents', () => { }]); }); + test('thread/tokenUsage/updated identifies one completed model call from cumulative usage', () => { + const resource = URI.parse('agent-chat://codex/session'); + assert.deepStrictEqual(mapTokenUsageModelCallCompleted({ + threadId: 'thr_1', + turnId: 'turn_a', + tokenUsage: { + last: { inputTokens: 10, cachedInputTokens: 4, cacheWriteInputTokens: 0, outputTokens: 6, reasoningOutputTokens: 2, totalTokens: 16 }, + total: { inputTokens: 100, cachedInputTokens: 40, cacheWriteInputTokens: 0, outputTokens: 60, reasoningOutputTokens: 20, totalTokens: 160 }, + modelContextWindow: 200000, + }, + }, resource), { + kind: 'model_call_completed', + resource, + turnId: 'turn_a', + modelCallId: '100:40:0:60:20:160', + }); + }); + test('contextCompaction item maps to visible running and completed progress', () => { const state = createCodexSessionMapState(); const started = mapItemStarted(state, { diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 231bc6644bdcc7..72efdeba256a6c 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -6332,7 +6332,7 @@ suite('CopilotAgentSession', () => { test('tool-call aggregate emits once with cancelled result across abort and idle', async () => { const telemetryService = new CapturingTelemetryService(); - const { session, mockSession } = await createAgentSession(disposables, { + const { session, mockSession, signals } = await createAgentSession(disposables, { telemetryService, clientSnapshot: { tools: [{ name: 'grep' }, { name: 'edit' }], plugins: [], mcpServers: {} }, }); @@ -6343,51 +6343,99 @@ suite('CopilotAgentSession', () => { messageId: 'msg-tools', content: '', model: 'gpt-x', + apiCallId: 'api-tools', toolRequests: [ { toolCallId: 'tc-1', name: 'grep', arguments: {} }, { toolCallId: 'tc-2', name: 'edit', arguments: {} }, ], - } as SessionEventPayload<'assistant.message'>['data']); + } as SessionEventPayload<'assistant.message'>['data'], { id: 'evt-tools' }); mockSession.fire('assistant.message', { messageId: 'msg-final', content: 'done', model: 'gpt-x', - } as SessionEventPayload<'assistant.message'>['data']); + apiCallId: 'api-final', + } as SessionEventPayload<'assistant.message'>['data'], { id: 'evt-final' }); mockSession.fire('abort', { reason: 'user_abort' } as SessionEventPayload<'abort'>['data']); mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); - assert.deepStrictEqual(telemetryService.events.map(event => { - const data = event.data as Record; - return { - eventName: event.eventName, - provider: data.provider, - requestId: data.requestId, - responseType: data.responseType, - toolCounts: data.toolCounts, - model: data.model, - numRequests: data.numRequests, - turnIndex: data.turnIndex, - messageCharLen: data.messageCharLen, - availableToolCount: data.availableToolCount, - totalToolCalls: data.totalToolCalls, - parallelToolCallRounds: data.parallelToolCallRounds, - parallelToolCallsTotal: data.parallelToolCallsTotal, - }; - }), [{ - eventName: 'toolCallDetails', - provider: 'copilot', - requestId: 'turn-tool-details', - responseType: 'cancelled', - toolCounts: JSON.stringify({ grep: 1, edit: 1 }), + assert.deepStrictEqual({ + telemetry: telemetryService.events.map(event => { + const data = event.data as Record; + return { + eventName: event.eventName, + provider: data.provider, + requestId: data.requestId, + responseType: data.responseType, + toolCounts: data.toolCounts, + model: data.model, + numRequests: data.numRequests, + turnIndex: data.turnIndex, + messageCharLen: data.messageCharLen, + availableToolCount: data.availableToolCount, + totalToolCalls: data.totalToolCalls, + parallelToolCallRounds: data.parallelToolCallRounds, + parallelToolCallsTotal: data.parallelToolCallsTotal, + }; + }), + modelCalls: signals.filter(signal => signal.kind === 'model_call_completed').map(signal => ({ + turnId: signal.kind === 'model_call_completed' ? signal.turnId : undefined, + modelCallId: signal.kind === 'model_call_completed' ? signal.modelCallId : undefined, + })), + }, { + telemetry: [{ + eventName: 'toolCallDetails', + provider: 'copilot', + requestId: 'turn-tool-details', + responseType: 'cancelled', + toolCounts: JSON.stringify({ grep: 1, edit: 1 }), + model: 'gpt-x', + numRequests: 2, + turnIndex: 0, + messageCharLen: 11, + availableToolCount: 2, + totalToolCalls: 2, + parallelToolCallRounds: 1, + parallelToolCallsTotal: 2, + }], + modelCalls: [ + { turnId: 'turn-tool-details', modelCallId: 'api-tools' }, + { turnId: 'turn-tool-details', modelCallId: 'api-final' }, + ], + }); + }); + + test('split assistant messages count as one model call', async () => { + const telemetryService = new CapturingTelemetryService(); + const { session, mockSession, signals } = await createAgentSession(disposables, { + telemetryService, + clientSnapshot: { tools: [{ name: 'grep' }], plugins: [], mcpServers: {} }, + }); + session.resetTurnState('turn-split-message'); + await session.send('hello agent', undefined, 'turn-split-message'); + mockSession.fire('user.message', { content: 'hello agent' } as SessionEventPayload<'user.message'>['data']); + mockSession.fire('assistant.message', { + messageId: 'msg-part-1', + content: 'reasoning', model: 'gpt-x', - numRequests: 2, - turnIndex: 0, - messageCharLen: 11, - availableToolCount: 2, - totalToolCalls: 2, - parallelToolCallRounds: 1, - parallelToolCallsTotal: 2, - }]); + chunkIndex: 0, + chunkCount: 2, + } as SessionEventPayload<'assistant.message'>['data']); + mockSession.fire('assistant.message', { + messageId: 'msg-part-2', + content: 'answer', + model: 'gpt-x', + chunkIndex: 1, + chunkCount: 2, + } as SessionEventPayload<'assistant.message'>['data']); + mockSession.fire('session.idle', { aborted: false } as SessionEventPayload<'session.idle'>['data']); + + assert.deepStrictEqual({ + numRequests: (telemetryService.events.find(event => event.eventName === 'toolCallDetails')?.data as Record | undefined)?.numRequests, + modelCallIds: signals.filter(signal => signal.kind === 'model_call_completed').map(signal => signal.kind === 'model_call_completed' ? signal.modelCallId : undefined), + }, { + numRequests: 1, + modelCallIds: ['msg-part-2'], + }); }); test('tool approval waits for permission outcome and falls back only at completion', async () => { From 10aee5a8c94abcd8910eb99c9d49e371f560cbfd Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 18 Aug 2026 22:37:09 -0400 Subject: [PATCH 10/14] Disable reasoning for Luna dictation cleanup (#331577) * Use low reasoning for Luna dictation cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fecf27d4-dfc9-4401-99a4-493cbfe00932 * Disable reasoning for Luna dictation cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fecf27d4-dfc9-4401-99a4-493cbfe00932 --------- Copilot-Session: fecf27d4-dfc9-4401-99a4-493cbfe00932 --- .../speechToText/chatSpeechToTextService.ts | 5 +- .../browser/chatSpeechToTextService.test.ts | 50 +++++++++++++++++-- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts index 0666ca22f3733b..51a617b9ff5f15 100644 --- a/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts +++ b/src/vs/workbench/contrib/chat/browser/speechToText/chatSpeechToTextService.ts @@ -1464,6 +1464,9 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo this._sessionCleanupModel = selectedCleanupModel; phase = 'startRequest'; this._logService.trace(`[chat-stt] language model cleanup sending request (elapsedMs=${Date.now() - cleanupStartMs})`); + const requestOptions = selectedCleanupModel === LLM_CLEANUP_LUNA_MODEL_ID + ? { configuration: { reasoningEffort: 'none' } } + : {}; const response = await raceCancellation( this._languageModelsService.sendChatRequest( models[0], @@ -1472,7 +1475,7 @@ export class ChatSpeechToTextService extends Disposable implements IChatSpeechTo { role: ChatMessageRole.System, content: [{ type: 'text', value: systemPrompt }] }, { role: ChatMessageRole.User, content: [{ type: 'text', value: transcriptPayload }] }, ], - {}, + requestOptions, cts.token, ), cts.token, diff --git a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts index 37bfa482201876..88166cabc2416a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatSpeechToTextService.test.ts @@ -10,16 +10,13 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { ChatSpeechToTextService, createDictationCleanupSystemPrompt, isDictationEntitled, stripDictationFillers } from '../../browser/speechToText/chatSpeechToTextService.js'; import { resolveDictationLanguage } from '../../browser/speechToText/dictationLanguage.js'; import { ChatEntitlement } from '../../../../services/chat/common/chatEntitlementService.js'; -import { ILanguageModelChatResponse, ILanguageModelChatSelector } from '../../common/languageModels.js'; +import { ILanguageModelChatRequestOptions, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelsService } from '../../common/languageModels.js'; type CleanupTestService = { _configurationService: { getValue: () => string; }; - _languageModelsService: { - selectLanguageModels: (selector: ILanguageModelChatSelector) => Promise; - sendChatRequest: (...args: never[]) => Promise; - }; + _languageModelsService: Pick; _llmCleanupModelTreatment: string | undefined; _promptsService: { getDictationInstructions: (token: CancellationToken) => Promise; @@ -333,4 +330,47 @@ suite('ChatSpeechToTextService', () => { ]); }); + test('disables reasoning for Luna cleanup only', async () => { + const requestConfigurations: Array = []; + const createService = (configuredModel: string): CleanupTestService => { + const service = Object.create(ChatSpeechToTextService.prototype) as CleanupTestService; + service._configurationService = { + getValue: () => configuredModel, + }; + service._llmCleanupModelTreatment = undefined; + service._languageModelsService = { + selectLanguageModels: async () => ['test-model'], + sendChatRequest: async (_modelId, _from, _messages, options) => { + requestConfigurations.push(options.configuration); + return { + stream: (async function* () { + yield { type: 'text', value: 'cleaned transcript' } as const; + })(), + result: Promise.resolve(undefined), + }; + }, + }; + service._promptsService = { + getDictationInstructions: async () => undefined, + }; + service._logService = { + info: () => { }, + warn: () => { }, + trace: () => { }, + }; + return service; + }; + + await createService('gpt-5.6-luna')._cleanupWithLanguageModel('Luna transcript', CancellationToken.None); + const fallbackService = createService('gpt-5.6-luna'); + let selectionCall = 0; + fallbackService._languageModelsService.selectLanguageModels = async () => selectionCall++ === 0 ? [] : ['test-model']; + await fallbackService._cleanupWithLanguageModel('utility fallback transcript', CancellationToken.None); + + assert.deepStrictEqual(requestConfigurations, [ + { reasoningEffort: 'none' }, + undefined, + ]); + }); + }); From 1619605170d366372c621bb98d295bae03dfc859 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 18 Aug 2026 19:38:31 -0700 Subject: [PATCH 11/14] github-authentication - simplify avatar persistence (#331061) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../github-authentication/src/github.ts | 85 ++++--------------- .../src/test/github.test.ts | 43 ---------- 2 files changed, 16 insertions(+), 112 deletions(-) delete mode 100644 extensions/github-authentication/src/test/github.test.ts diff --git a/extensions/github-authentication/src/github.ts b/extensions/github-authentication/src/github.ts index 05bce7c2a9bd9c..d18ce1dd252b7b 100644 --- a/extensions/github-authentication/src/github.ts +++ b/extensions/github-authentication/src/github.ts @@ -14,16 +14,8 @@ import { crypto } from './node/crypto'; import { TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors'; import { GitHubSocialSignInProvider, isSocialSignInProvider } from './flows'; -/** - * The stored (JSON) form of a vscode.Uri pointing to the account's avatar. - */ -interface StoredAccountIcon { - scheme: string; - authority?: string; - path?: string; - query?: string; - fragment?: string; -} +// `vscode` doesn't publicly export `UriComponents`, so derive the exact shape from `Uri.from`. +type UriComponents = Parameters[0]; interface SessionData { id: string; @@ -33,31 +25,12 @@ interface SessionData { // Unfortunately, for some time the id was a number, so we need to support both. // This can be removed once we are confident that all users have migrated to the new id. id: string | number; - // `undefined` means the avatar has not been looked up yet, `null` means a lookup - // completed and found no avatar, and a `StoredAccountIcon` is a resolved avatar. - icon?: StoredAccountIcon | null; + icon?: UriComponents; }; scopes: string[]; accessToken: string; } -/** - * Whether a stored session's account icon still needs to be looked up. - */ -export function needsAccountIconLookup(session: SessionData): boolean { - return !session.account || session.account.icon === undefined; -} - -/** - * Serializes an account icon for storage, using `null` to mark a completed lookup that found no avatar. - */ -export function serializeAccountIcon(icon: vscode.Uri | undefined, hasNoAvatar: boolean): StoredAccountIcon | null | undefined { - if (icon) { - return { scheme: icon.scheme, authority: icon.authority, path: icon.path, query: icon.query, fragment: icon.fragment }; - } - return hasNoAvatar ? null : undefined; -} - export enum AuthProviderType { github = 'github', githubEnterprise = 'github-enterprise' @@ -165,7 +138,6 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid private readonly _telemetryReporter: ExperimentationTelemetry; private readonly _keychain: Keychain; private readonly _accountsSeen = new Set(); - private readonly _sessionsWithoutAvatars = new WeakSet(); private readonly _disposable: vscode.Disposable | undefined; private _sessionsPromise: Promise; @@ -310,25 +282,21 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid // the sessions to migrate away from the bad number usage. // TODO@TylerLeonhardt: Remove this after we are confident that all users have migrated to the new id. let seenNumberAccountId: boolean = false; - // Sessions that were stored before the account icon was introduced are re-stored - // once an icon has been fetched so that we don't refetch it on every read. - let seenIconUpdate: boolean = false; + // Re-store newly verified accounts so future reads do not need another lookup. + let seenAccountUpdate: boolean = false; // TODO: eventually remove this Set because we should only have one session per set of scopes. const scopesSeen = new Set(); const sessionPromises = sessionData.map(async (session: SessionData): Promise => { // For GitHub scope list, order doesn't matter so we immediately sort the scopes const scopesStr = [...session.scopes].sort().join(' '); let userInfo: { id: string; accountName: string; avatarUrl: string | undefined } | undefined; - if (needsAccountIconLookup(session)) { - const needsAccount = !session.account; + if (!session.account) { try { userInfo = await this._githubServer.getUserInfo(session.accessToken); - seenIconUpdate = true; - if (needsAccount) { - this._logger.info(`Verified session with the following scopes: ${scopesStr}`); - } + seenAccountUpdate = true; + this._logger.info(`Verified session with the following scopes: ${scopesStr}`); } catch (e) { - if (e.message === 'Unauthorized' && needsAccount) { + if (e.message === 'Unauthorized') { return undefined; } } @@ -346,13 +314,10 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid } else { accountId = userInfo?.id ?? ''; } - let icon: vscode.Uri | undefined; - if (session.account?.icon?.scheme) { - icon = vscode.Uri.from(session.account.icon); - } else if (userInfo?.avatarUrl) { - icon = vscode.Uri.parse(userInfo.avatarUrl); - } - const resolvedSession: vscode.AuthenticationSession = { + const icon = session.account?.icon + ? vscode.Uri.from(session.account.icon) + : userInfo?.avatarUrl ? vscode.Uri.parse(userInfo.avatarUrl) : undefined; + return { id: session.id, account: { label: session.account @@ -366,10 +331,6 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid scopes: session.scopes, accessToken: session.accessToken }; - if (!icon && (session.account?.icon === null || userInfo)) { - this._sessionsWithoutAvatars.add(resolvedSession); - } - return resolvedSession; }); const verifiedSessions = (await Promise.allSettled(sessionPromises)) @@ -378,7 +339,7 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid .filter((p?: T): p is T => Boolean(p)); this._logger.info(`Got ${verifiedSessions.length} verified sessions.`); - if (seenNumberAccountId || seenIconUpdate || verifiedSessions.length !== sessionData.length) { + if (seenNumberAccountId || seenAccountUpdate || verifiedSessions.length !== sessionData.length) { await this.storeSessions(verifiedSessions); } @@ -388,17 +349,7 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid private async storeSessions(sessions: vscode.AuthenticationSession[]): Promise { this._logger.info(`Storing ${sessions.length} sessions...`); this._sessionsPromise = Promise.resolve(sessions); - const storedSessions: SessionData[] = sessions.map(session => ({ - id: session.id, - account: { - label: session.account.label, - id: session.account.id, - icon: serializeAccountIcon(session.account.icon, this._sessionsWithoutAvatars.has(session)), - }, - scopes: [...session.scopes], - accessToken: session.accessToken - })); - await this._keychain.setToken(JSON.stringify(storedSessions)); + await this._keychain.setToken(JSON.stringify(sessions)); this._logger.info(`Stored ${sessions.length} sessions!`); } @@ -468,16 +419,12 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid private async tokenToSession(token: string, scopes: string[]): Promise { const userInfo = await this._githubServer.getUserInfo(token); - const session: vscode.AuthenticationSession = { + return { id: crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), ''), accessToken: token, account: { label: userInfo.accountName, id: userInfo.id, icon: userInfo.avatarUrl ? vscode.Uri.parse(userInfo.avatarUrl) : undefined }, scopes }; - if (!session.account.icon) { - this._sessionsWithoutAvatars.add(session); - } - return session; } public async removeSession(id: string) { diff --git a/extensions/github-authentication/src/test/github.test.ts b/extensions/github-authentication/src/test/github.test.ts deleted file mode 100644 index e3a776d9d17e0d..00000000000000 --- a/extensions/github-authentication/src/test/github.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import { needsAccountIconLookup, serializeAccountIcon } from '../github'; - -suite('account avatar caching', () => { - test('a pending session needs a lookup, and its serialized no-avatar result no longer needs one', () => { - const pendingSession = { - id: 'session1', - account: { id: 'account1', label: 'Some One' }, - scopes: [], - accessToken: 'token' - }; - const noAvatarIcon = serializeAccountIcon(undefined, true); - const cachedNoAvatarSession = { - id: 'session1', - account: { id: 'account1', label: 'Some One', icon: noAvatarIcon }, - scopes: [], - accessToken: 'token' - }; - - assert.deepStrictEqual( - [needsAccountIconLookup(pendingSession), noAvatarIcon, needsAccountIconLookup(cachedNoAvatarSession)], - [true, null, false] - ); - }); - - test('a resolved avatar URI is serialized as-is and is never replaced by null', () => { - const icon = vscode.Uri.parse('https://example.com/avatar.png'); - - assert.deepStrictEqual(serializeAccountIcon(icon, true), { - scheme: icon.scheme, - authority: icon.authority, - path: icon.path, - query: icon.query, - fragment: icon.fragment - }); - }); -}); From d584e015c079a68a97086bfcbf820329fa7c940c Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 18 Aug 2026 19:44:47 -0700 Subject: [PATCH 12/14] MCP: Recognize Workspace Config Under Settings (#331575) * MCP: Recognize MCP Configuration in Workspace Files * MCP: Recognize workspace config under settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../api/common/configurationExtensionPoint.ts | 38 +++++++++++-------- .../browser/preferencesRenderers.ts | 3 ++ 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index ed42f37d1a0c68..0e44c33261d5b2 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -10,7 +10,7 @@ import { IJSONSchema } from '../../../base/common/jsonSchema.js'; import { ExtensionsRegistry, IExtensionPointUser } from '../../services/extensions/common/extensionsRegistry.js'; import { IConfigurationNode, IConfigurationRegistry, Extensions, validateProperty, ConfigurationScope, OVERRIDE_PROPERTY_REGEX, IConfigurationDefaults, configurationDefaultsSchemaId, IConfigurationDelta, getDefaultValue, getAllConfigurationProperties, parseScope, EXTENSION_UNIFICATION_EXTENSION_IDS, overrideIdentifiersFromKey } from '../../../platform/configuration/common/configurationRegistry.js'; import { IJSONContributionRegistry, Extensions as JSONExtensions } from '../../../platform/jsonschemas/common/jsonContributionRegistry.js'; -import { workspaceSettingsSchemaId, launchSchemaId, tasksSchemaId, mcpSchemaId } from '../../services/configuration/common/configuration.js'; +import { workspaceSettingsSchemaId, launchSchemaId, tasksSchemaId, MCP_CONFIGURATION_KEY, mcpSchemaId } from '../../services/configuration/common/configuration.js'; import { hasKey, isObject, isUndefined } from '../../../base/common/types.js'; import { ExtensionIdentifierMap, IExtensionManifest } from '../../../platform/extensions/common/extensions.js'; import { IStringDictionary } from '../../../base/common/collections.js'; @@ -418,7 +418,27 @@ jsonRegistry.registerSchema('vscode://schemas/workspaceConfig', { type: 'object', default: {}, description: nls.localize('workspaceConfig.settings.description', "Workspace settings"), - $ref: workspaceSettingsSchemaId + allOf: [ + { $ref: workspaceSettingsSchemaId }, + { + properties: { + [MCP_CONFIGURATION_KEY]: { + type: 'object', + default: { + inputs: [], + servers: { + 'mcp-server-time': { + command: 'uvx', + args: ['mcp_server_time', '--local-timezone=America/Los_Angeles'] + } + } + }, + description: nls.localize('workspaceConfig.mcp.description', "Model Context Protocol server configurations"), + $ref: mcpSchemaId + } + } + } + ] }, 'launch': { type: 'object', @@ -432,20 +452,6 @@ jsonRegistry.registerSchema('vscode://schemas/workspaceConfig', { description: nls.localize('workspaceConfig.tasks.description', "Workspace task configurations"), $ref: tasksSchemaId }, - 'mcp': { - type: 'object', - default: { - inputs: [], - servers: { - 'mcp-server-time': { - command: 'uvx', - args: ['mcp_server_time', '--local-timezone=America/Los_Angeles'] - } - } - }, - description: nls.localize('workspaceConfig.mcp.description', "Model Context Protocol server configurations"), - $ref: mcpSchemaId - }, 'extensions': { type: 'object', default: {}, diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts b/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts index 8242ea4b6b73ce..f04786f5babb01 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts @@ -551,6 +551,9 @@ class UnsupportedSettingsRenderer extends Disposable implements languages.CodeAc } continue; } + if (setting.key === mcpConfigurationSection && this.settingsEditorModel instanceof WorkspaceConfigurationEditorModel) { + continue; + } const configuration = configurationRegistry[setting.key]; if (configuration) { this.handleUnstableSettingConfiguration(setting, configuration, markerData); From e527941497203de89dbb21891f932abf7ad5175a Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Tue, 18 Aug 2026 19:45:13 -0700 Subject: [PATCH 13/14] Hide redundant update badge (#331586) * Hide redundant update badge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix update badge test on macOS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/actions/layoutActions.ts | 12 ++--- src/vs/workbench/common/contextkeys.ts | 17 ++++++- .../contrib/update/browser/update.ts | 23 ++++++++-- .../workbench/contrib/update/common/update.ts | 9 +++- .../test/browser/updateTitleBarEntry.test.ts | 46 +++++++++++++++++++ 5 files changed, 93 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/browser/actions/layoutActions.ts b/src/vs/workbench/browser/actions/layoutActions.ts index 7211ba6846ee81..d03a5c11880b72 100644 --- a/src/vs/workbench/browser/actions/layoutActions.ts +++ b/src/vs/workbench/browser/actions/layoutActions.ts @@ -12,7 +12,6 @@ import { EditorActionsLocation, EditorTabsMode, IWorkbenchLayoutService, LayoutS import { ServicesAccessor, IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { KeyMod, KeyCode } from '../../../base/common/keyCodes.js'; import { isWindows, isLinux, isWeb, isMacintosh, isNative } from '../../../base/common/platform.js'; -import { IsMacNativeContext } from '../../../platform/contextkey/common/contextkeys.js'; import { KeybindingWeight } from '../../../platform/keybinding/common/keybindingsRegistry.js'; import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { IViewDescriptorService, ViewContainerLocation, IViewDescriptor, ViewContainerLocationToString } from '../../common/views.js'; @@ -23,7 +22,7 @@ import { IPaneCompositePartService } from '../../services/panecomposite/browser/ import { ToggleAuxiliaryBarAction } from '../parts/auxiliarybar/auxiliaryBarActions.js'; import { TogglePanelAction } from '../parts/panel/panelActions.js'; import { ICommandService } from '../../../platform/commands/common/commands.js'; -import { AuxiliaryBarVisibleContext, PanelAlignmentContext, PanelVisibleContext, SideBarVisibleContext, FocusedViewContext, InEditorZenModeContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, IsMainWindowFullscreenContext, PanelPositionContext, IsAuxiliaryWindowFocusedContext, IsSessionsWindowContext, TitleBarStyleContext, IsAuxiliaryWindowContext } from '../../common/contextkeys.js'; +import { AuxiliaryBarVisibleContext, PanelAlignmentContext, PanelVisibleContext, SideBarVisibleContext, FocusedViewContext, InEditorZenModeContext, IsMainEditorCenteredLayoutContext, MainEditorAreaVisibleContext, IsMainWindowFullscreenContext, PanelPositionContext, IsAuxiliaryWindowFocusedContext, IsSessionsWindowContext, TitleBarStyleContext, IsAuxiliaryWindowContext, CustomMenuBarVisibleContext } from '../../common/contextkeys.js'; import { Codicon } from '../../../base/common/codicons.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; @@ -31,7 +30,7 @@ import { registerIcon } from '../../../platform/theme/common/iconRegistry.js'; import { ICommandActionTitle } from '../../../platform/action/common/action.js'; import { mainWindow } from '../../../base/browser/window.js'; import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; -import { MenuSettings, TitlebarStyle } from '../../../platform/window/common/window.js'; +import { TitlebarStyle } from '../../../platform/window/common/window.js'; import { IPreferencesService } from '../../services/preferences/common/preferences.js'; import { QuickInputAlignmentContextKey } from '../../../platform/quickinput/browser/quickInput.js'; import { IEditorGroupsService } from '../../services/editor/common/editorGroupsService.js'; @@ -740,7 +739,7 @@ if (isWindows || isLinux || isWeb) { category: Categories.View, f1: true, precondition: IsSessionsWindowContext.negate(), - toggled: ContextKeyExpr.and(IsMacNativeContext.toNegated(), ContextKeyExpr.notEquals(`config.${MenuSettings.MenuBarVisibility}`, 'hidden'), ContextKeyExpr.notEquals(`config.${MenuSettings.MenuBarVisibility}`, 'toggle'), ContextKeyExpr.notEquals(`config.${MenuSettings.MenuBarVisibility}`, 'compact')), + toggled: CustomMenuBarVisibleContext, menu: [{ id: MenuId.MenubarAppearanceMenu, group: '2_workbench_layout', @@ -761,7 +760,7 @@ if (isWindows || isLinux || isWeb) { command: { id: 'workbench.action.toggleMenuBar', title: localize('miMenuBarNoMnemonic', "Menu Bar"), - toggled: ContextKeyExpr.and(IsMacNativeContext.toNegated(), ContextKeyExpr.notEquals(`config.${MenuSettings.MenuBarVisibility}`, 'hidden'), ContextKeyExpr.notEquals(`config.${MenuSettings.MenuBarVisibility}`, 'toggle'), ContextKeyExpr.notEquals(`config.${MenuSettings.MenuBarVisibility}`, 'compact')) + toggled: CustomMenuBarVisibleContext }, when: ContextKeyExpr.and(IsAuxiliaryWindowFocusedContext.toNegated(), ContextKeyExpr.notEquals(TitleBarStyleContext.key, TitlebarStyle.NATIVE), IsMainWindowFullscreenContext.negate()), group: '2_config', @@ -1356,10 +1355,9 @@ const CreateOptionLayoutItem = (id: string, active: ContextKeyExpression, label: }; }; -const MenuBarToggledContext = ContextKeyExpr.and(IsMacNativeContext.toNegated(), ContextKeyExpr.notEquals(`config.${MenuSettings.MenuBarVisibility}`, 'hidden'), ContextKeyExpr.notEquals(`config.${MenuSettings.MenuBarVisibility}`, 'toggle'), ContextKeyExpr.notEquals(`config.${MenuSettings.MenuBarVisibility}`, 'compact')) as ContextKeyExpression; const ToggleVisibilityActions: CustomizeLayoutItem[] = []; if (!isMacintosh || !isNative) { - ToggleVisibilityActions.push(CreateToggleLayoutItem('workbench.action.toggleMenuBar', MenuBarToggledContext, localize('menuBar', "Menu Bar"), menubarIcon)); + ToggleVisibilityActions.push(CreateToggleLayoutItem('workbench.action.toggleMenuBar', CustomMenuBarVisibleContext, localize('menuBar', "Menu Bar"), menubarIcon)); } ToggleVisibilityActions.push(...[ diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index 4603a38ce1e842..f3c34e5750a7b3 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -6,12 +6,14 @@ import { DisposableStore } from '../../base/common/lifecycle.js'; import { URI } from '../../base/common/uri.js'; import { localize } from '../../nls.js'; -import { IContextKeyService, IContextKey, RawContextKey } from '../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKeyService, IContextKey, RawContextKey } from '../../platform/contextkey/common/contextkey.js'; +import { IsMacNativeContext } from '../../platform/contextkey/common/contextkeys.js'; import { basename, dirname, extname, isEqual } from '../../base/common/resources.js'; import { ILanguageService } from '../../editor/common/languages/language.js'; import { IFileService } from '../../platform/files/common/files.js'; import { IModelService } from '../../editor/common/services/model.js'; import { Schemas } from '../../base/common/network.js'; +import { MenuSettings } from '../../platform/window/common/window.js'; import { EditorInput } from './editor/editorInput.js'; import { IEditorResolverService } from '../services/editor/common/editorResolverService.js'; import { DEFAULT_EDITOR_ASSOCIATION, EditorResourceAccessor, isDiffEditorInput } from './editor.js'; @@ -51,6 +53,19 @@ export const IsWindowAlwaysOnTopContext = new RawContextKey('isWindowAl export const IsAuxiliaryWindowContext = new RawContextKey('isAuxiliaryWindow', false, localize('isAuxiliaryWindow', "Window is an auxiliary window")); +export const MenuBarVisibleContext = ContextKeyExpr.or( + IsMacNativeContext, + ContextKeyExpr.and( + ContextKeyExpr.notEquals(`config.${MenuSettings.MenuBarVisibility}`, 'hidden'), + ContextKeyExpr.notEquals(`config.${MenuSettings.MenuBarVisibility}`, 'toggle'), + ContextKeyExpr.notEquals(`config.${MenuSettings.MenuBarVisibility}`, 'compact') + ) +)!; + +export const CustomMenuBarVisibleContext = ContextKeyExpr.and( + IsMacNativeContext.negate(), + MenuBarVisibleContext +)!; //#endregion diff --git a/src/vs/workbench/contrib/update/browser/update.ts b/src/vs/workbench/contrib/update/browser/update.ts index 02d318ec8ad1a0..52c517f2c085a2 100644 --- a/src/vs/workbench/contrib/update/browser/update.ts +++ b/src/vs/workbench/contrib/update/browser/update.ts @@ -32,6 +32,7 @@ import { Event } from '../../../../base/common/event.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { getInternalOrg } from '../../../../platform/assignment/common/assignment.js'; import { IVersion, tryParseVersion } from '../common/updateUtils.js'; +import { UpdateGlobalActivityBadgeVisibleContext } from '../common/update.js'; export const CONTEXT_UPDATE_STATE = new RawContextKey('updateState', StateType.Uninitialized); export const MAJOR_MINOR_UPDATE_AVAILABLE = new RawContextKey('majorMinorUpdateAvailable', false); @@ -226,7 +227,7 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu @IDialogService private readonly dialogService: IDialogService, @IUpdateService private readonly updateService: IUpdateService, @IActivityService private readonly activityService: IActivityService, - @IContextKeyService contextKeyService: IContextKeyService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, @IProductService private readonly productService: IProductService, @IHostService private readonly hostService: IHostService, ) { @@ -236,6 +237,14 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu this.majorMinorUpdateAvailableContextKey = MAJOR_MINOR_UPDATE_AVAILABLE.bindTo(contextKeyService); this._register(updateService.onStateChange(this.onUpdateStateChange, this)); + + const updateGlobalActivityBadgeContextKeys = new Set(UpdateGlobalActivityBadgeVisibleContext.keys()); + this._register(contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(updateGlobalActivityBadgeContextKeys)) { + this.updateBadge(this.updateService.state); + } + })); + this.onUpdateStateChange(this.updateService.state); /* @@ -280,7 +289,13 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu } } - let badge: IBadge | undefined = undefined; + this.updateBadge(state); + + this.state = state; + } + + private updateBadge(state: UpdateState): void { + let badge: IBadge | undefined; if (state.type === StateType.AvailableForDownload || state.type === StateType.Downloaded || state.type === StateType.Ready) { badge = new NumberBadge(1, () => nls.localize('updateIsReady', "New {0} update available.", this.productService.nameShort)); @@ -296,11 +311,9 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu this.badgeDisposable.clear(); - if (badge) { + if (badge && this.contextKeyService.contextMatchesRules(UpdateGlobalActivityBadgeVisibleContext)) { this.badgeDisposable.value = this.activityService.showGlobalActivity({ badge }); } - - this.state = state; } private registerGlobalActivityActions(): void { diff --git a/src/vs/workbench/contrib/update/common/update.ts b/src/vs/workbench/contrib/update/common/update.ts index 5b3a64943e855c..877cf51b97cc1d 100644 --- a/src/vs/workbench/contrib/update/common/update.ts +++ b/src/vs/workbench/contrib/update/common/update.ts @@ -4,16 +4,23 @@ *--------------------------------------------------------------------------------------------*/ import { ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { InEditorZenModeContext } from '../../../common/contextkeys.js'; +import { InEditorZenModeContext, MenuBarVisibleContext } from '../../../common/contextkeys.js'; export const ShowCurrentReleaseNotesActionId = 'update.showCurrentReleaseNotes'; export const ShowCurrentReleaseNotesFromCurrentFileActionId = 'developer.showCurrentFileAsReleaseNotes'; export const UpdateTitleBarContext = new RawContextKey('updateTitleBar', false); export const UpdateTitleBarChatInProgressContext = new RawContextKey('updateTitleBarChatRequestInProgress', false); + export const UpdateTitleBarEditorVisibleContext = ContextKeyExpr.and( UpdateTitleBarContext, InEditorZenModeContext.negate(), ContextKeyExpr.not('inDebugMode'), UpdateTitleBarChatInProgressContext.negate() )!; + +export const UpdateGlobalActivityBadgeVisibleContext = ContextKeyExpr.or( + UpdateTitleBarEditorVisibleContext.negate(), + MenuBarVisibleContext.negate(), + ContextKeyExpr.notEquals('config.workbench.activityBar.location', 'top') +)!; diff --git a/src/vs/workbench/contrib/update/test/browser/updateTitleBarEntry.test.ts b/src/vs/workbench/contrib/update/test/browser/updateTitleBarEntry.test.ts index 89d130aa6d7839..4c4a8d0c3ae542 100644 --- a/src/vs/workbench/contrib/update/test/browser/updateTitleBarEntry.test.ts +++ b/src/vs/workbench/contrib/update/test/browser/updateTitleBarEntry.test.ts @@ -8,19 +8,24 @@ import { mainWindow } from '../../../../../base/browser/window.js'; import { Action } from '../../../../../base/common/actions.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { toDisposable } from '../../../../../base/common/lifecycle.js'; +import { isMacintosh, isWeb } from '../../../../../base/common/platform.js'; import { IHoverOptions, IHoverWidget } from '../../../../../base/browser/ui/hover/hover.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { ICommandEvent, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IMeteredConnectionService } from '../../../../../platform/meteredConnection/common/meteredConnection.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IUpdateService, State } from '../../../../../platform/update/common/update.js'; +import { InEditorZenModeContext } from '../../../../common/contextkeys.js'; import { UpdateTitleBarEntry } from '../../browser/updateTitleBarEntry.js'; import { UpdateTooltip } from '../../browser/updateTooltip.js'; +import { UpdateGlobalActivityBadgeVisibleContext, UpdateTitleBarChatInProgressContext, UpdateTitleBarContext } from '../../common/update.js'; class TestCommandService extends mock() { private readonly _onDidExecuteCommand = new Emitter(); @@ -52,6 +57,12 @@ class TestHoverService extends mock() { } } +class TestContextKeyService extends MockContextKeyService { + override contextMatchesRules(rules: ContextKeyExpression): boolean { + return rules.evaluate({ getValue: key => this.getContextKeyValue(key) }); + } +} + suite('UpdateTitleBarEntry', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -96,6 +107,41 @@ suite('UpdateTitleBarEntry', () => { }); }); +suite('UpdateGlobalActivityBadgeVisibleContext', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('hides the badge when the Update and Manage actions are adjacent', () => { + const customMenuBarCanBeHidden = !isMacintosh || isWeb; + const scenarios = [ + { name: 'no update', updateVisible: false, menuBarVisibility: 'visible', activityBarLocation: 'top', expected: true }, + { name: 'adjacent', updateVisible: true, menuBarVisibility: 'visible', activityBarLocation: 'top', expected: false }, + { name: 'classic menu', updateVisible: true, menuBarVisibility: 'classic', activityBarLocation: 'top', expected: false }, + { name: 'hidden menu', updateVisible: true, menuBarVisibility: 'hidden', activityBarLocation: 'top', expected: customMenuBarCanBeHidden }, + { name: 'toggle menu', updateVisible: true, menuBarVisibility: 'toggle', activityBarLocation: 'top', expected: customMenuBarCanBeHidden }, + { name: 'compact menu', updateVisible: true, menuBarVisibility: 'compact', activityBarLocation: 'top', expected: customMenuBarCanBeHidden }, + { name: 'bottom activity bar', updateVisible: true, menuBarVisibility: 'visible', activityBarLocation: 'bottom', expected: true }, + { name: 'chat in progress', updateVisible: true, menuBarVisibility: 'visible', activityBarLocation: 'top', chatInProgress: true, expected: true }, + ]; + + const actual = scenarios.map(scenario => { + const contextKeyService = new TestContextKeyService(); + UpdateTitleBarContext.bindTo(contextKeyService).set(scenario.updateVisible); + UpdateTitleBarChatInProgressContext.bindTo(contextKeyService).set(scenario.chatInProgress ?? false); + InEditorZenModeContext.bindTo(contextKeyService); + contextKeyService.createKey('config.window.menuBarVisibility', scenario.menuBarVisibility); + contextKeyService.createKey('config.workbench.activityBar.location', scenario.activityBarLocation); + + return { + name: scenario.name, + visible: contextKeyService.contextMatchesRules(UpdateGlobalActivityBadgeVisibleContext), + }; + }); + + assert.deepStrictEqual(actual, scenarios.map(({ name, expected }) => ({ name, visible: expected }))); + }); +}); + suite('UpdateTooltip', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); From a1c7d1be7ebeddac39ee87a311d940b04b2e5da2 Mon Sep 17 00:00:00 2001 From: roblourens Date: Tue, 18 Aug 2026 19:46:58 -0700 Subject: [PATCH 14/14] agentHost: Collect and package debug logs on the host (#331573) * Collect and package Agent Host debug logs on the host "Export Agent Host Debug Logs" previously had the client guess the paths of host-owned log files and open them one by one. The Agent Host now discovers and packages its own diagnostics, including the Copilot SDK runtime logs obtained via the SDK's own collectLogs API rather than by searching the disk. Ownership is split: the host packages host-owned logs into an artifact, while the client keeps contributing the logs it owns (renderer, shared process, AHP transport JSONL, usage and customization sidecars). Native exports flatten the host archive into the single resulting zip; browser builds keep folder export. This is exposed as a private, non-spec AHP extension command (vscode/collectAgentHostDebugLogs) so the shape can settle before it is proposed for the protocol. Hosts that predate it answer MethodNotFound and the client falls back to the previous discovery path. Remote hosts return an artifact URI whose bytes are streamed back through a second private command (vscode/readAgentHostDebugLogsChunk) in bounded 1 MiB chunks, so a whole archive never has to be Base64-encoded into one JSON-RPC message. Only artifacts the collector itself produced are readable, so this is not a general-purpose file read. A local Agent Host returns a plain file URI and its bytes never cross IPC at all. Artifacts are size-capped, expire on a lease, and are cleaned up on shutdown. Host-side collection failures are non-fatal: the export falls back to client-side collection so it still produces the logs the client owns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Don't let host-side log collection hang the debug-logs export Manual end-to-end testing of "Export Agent Host Debug Logs" found the command could appear to do nothing at all: no save dialog, no error, no log output. Host-side collection goes through the agent host management channel, which waits for the host to reach a connected state. When the host never gets there -- easy to hit by running the command during startup, or when the protocol channel handshake times out -- that request stays pending forever, and since the export awaited it directly, the whole command hung silently. Bound the collection and fall back to client-side discovery when it does not finish in time, so an export always produces the logs the client owns. The previous change already treated collection *failures* as non-fatal; this extends the same guarantee to a host that never answers. A late-arriving artifact is discarded rather than leaked, since nothing is waiting for it by the time it lands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Give host-side log collection a longer bound Raise the host-collection timeout from 20s to 30s. Manual testing showed a real collection has to zip the host's logs, which can be large, so the original bound was tight enough to risk dropping host logs from a host that was merely slow rather than stuck. Also correct the comment: the failure this guards against is a host that never answers at all, not merely one that is unreachable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix local agent host management calls hanging forever Every call through the local agent host management channel -- Network Diagnostics, and the new debug-log collection -- could hang forever with no error and no output. `_getManagementService()` was an `async` method that returned the management service. That service is a `ProxyChannel` proxy whose `get` trap answers *every* string property with a function, including `then`. Resolving a promise with such an object makes the runtime treat it as a thenable and invoke `then` as if it were a remote method, so the promise never settles and the caller waits forever. It also puts a bogus `agentHostManagement.then` request on the wire, whose malformed reply is the source of the "Unexpected end of JSON input" deserialization errors seen in the agent host log. Split the wait from the lookup: `_whenManagementConnected()` resolves `void`, and the proxy is obtained synchronously afterwards, so it is never passed through a promise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Collect debug logs on the Agent Host only, with no client-side fallback The export had two implementations of host-log discovery: the Agent Host's own collector, and a client-side path-guessing fallback used whenever the host artifact was missing or incomplete, guarded by a 30s timeout. That is a lot of machinery to keep a second, less accurate implementation alive. Make the host path the only path. The client now contributes only the logs it genuinely owns (window/shared-process output channels, AHP transport JSONL, and the client-local capture sidecars); everything host-owned comes from the artifact. If collection fails, the failure surfaces to the user instead of being silently replaced by a lesser result. The host can now produce those logs in the case that previously forced the fallback. `collectDebugLogs` no longer requires a live session: with no session to reach the SDK through, the provider copies the most recent Copilot process log straight off the host's own disk, where it knows the real location rather than guessing it from the client. Because those logs can reach hundreds of megabytes, the collector now keeps only the trailing bytes of any file over a per-file cap. That bounds the artifact whether the file came from the SDK bundle or was copied in directly, and the tail is the part that explains a recent failure. In practice this takes a local export from ~44 MB to ~2.5 MB. Also drops the remaining whole-file Base64 copy that a remote export could silently fall back to when streaming was unavailable; streaming is now required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Require a live session for Agent Host debug-log collection Remove the remaining no-session implementation and make the live session URI required through the client, private extension protocol, management bridge, AgentService, collector, and provider contracts. The workbench command now reports an error when there is no active Agent Host chat. The Agents Window no longer substitutes its most recently updated, possibly closed session. On the host, the session must resolve to its owning provider, and Copilot must resolve it to a live SDK session before invoking `session.rpc.debug.collectLogs` with events, process logs, and shell logs. Providers without additional diagnostics still contribute the Agent Host process log through the same collector. If a provider implements collection, its failure propagates and fails the export rather than silently returning a partial archive. This leaves one success path and no client-side or direct-disk fallback. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Allow Agent Host debug-log export without an active session A host-wide export is valid without an active chat. Keep the session optional through the client, private protocol, management bridge, AgentService, collector, and provider contracts. With a live session, Copilot asks that exact SDK session for events, process logs, and shell logs. Without a live session (including a New Session placeholder whose SDK session has not started yet), it uses any live Copilot SDK session only as the gateway for process logs, explicitly excluding that unrelated session's events and shell logs. If no SDK session is live, the same host collector still packages the Agent Host process log. The client still adds the logs it owns: window/shared output, all applicable AHP JSONL logs, remote-forwarded output channels, and capture sidecars. No direct process-log path scan or client-side host-log fallback is reintroduced. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Require debug-log collection on Agent Host connections Every real Agent Host connection implements debug-log collection and bounded artifact reads. Make both methods required on `IAgentConnection`, implement them as explicit unsupported operations on the browser null service, and remove workbench guards that could never detect an older remote server. Provider-specific diagnostics remain optional: that controls what the host adds to its artifact, not whether the connection supports the collection API. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Harden Agent Host debug-log artifacts and remote export Enforce the same 1,000-file limit while staging that the native ZIP merger uses, and include an exact normalized file manifest in each artifact. Remote clients validate the manifest's paths, entry sizes, uniqueness, aggregate size, and entry count. Use that manifest to stream browser remote-directory files through bounded 1 MiB artifact reads instead of whole-file Base64 resource reads. Retained directory artifacts whitelist only the regular files that were enumerated, so the chunk endpoint remains artifact-scoped. Local browser copies verify the manifest against file type, symlink state, and size before copying. Restore the active remote Agent Host forwarded output channel, and preserve text MIME types in resourceRead while retaining binary Base64 support. Align archive validation with the collection contract: at most 16 MiB on the wire, while highly-compressible archives may expand to the bounded 256 MiB staging limit. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Stabilize debug-log artifact cleanup tests Replace fixed 20 ms sleeps with bounded polling for the actual temporary directory state. Artifact expiration triggers asynchronous filesystem removal, so a busy CI machine can observe the timer firing before `rm` has completed. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/base/node/zip.ts | 43 +++ src/vs/base/test/node/zip/zip.test.ts | 16 +- .../agentHost/browser/nullAgentHostService.ts | 4 +- .../browser/remoteAgentHostProtocolClient.ts | 84 ++++- src/vs/platform/agentHost/common/agent.ts | 3 + .../common/agentHostExtensionProtocol.ts | 25 ++ .../common/agentHostFileSystemProvider.ts | 4 +- .../platform/agentHost/common/agentService.ts | 68 +++- .../electron-browser/localAgentHostService.ts | 17 +- .../agentHost/node/agentHostDebugLogs.ts | 226 +++++++++++ .../platform/agentHost/node/agentHostMain.ts | 5 +- .../node/agentHostManagementService.ts | 16 +- .../agentHost/node/agentHostServerMain.ts | 5 +- .../platform/agentHost/node/agentService.ts | 41 +- .../agentHost/node/copilot/copilotAgent.ts | 19 + .../node/copilot/copilotAgentSession.ts | 21 ++ .../agentHost/node/protocolServerHandler.ts | 65 +++- .../agentHostFileSystemProvider.test.ts | 22 +- .../remoteAgentHostProtocolClient.test.ts | 96 ++++- .../test/node/agentHostDebugLogs.test.ts | 356 ++++++++++++++++++ .../agentHost/test/node/agentService.test.ts | 13 +- .../test/node/protocolServerHandler.test.ts | 104 +++++ src/vs/platform/native/common/native.ts | 3 +- .../electron-main/nativeHostMainService.ts | 69 +++- .../browser/exportDebugLogsAction.ts | 25 +- .../actions/exportAgentHostDebugLogsAction.ts | 277 +++++++++----- .../exportAgentHostDebugLogsService.ts | 47 ++- .../browser/exportAgentHostDebugLogs.test.ts | 61 +++ .../test/browser/agentHostPty.test.ts | 4 +- .../editorRemoteAgentHostServiceClient.ts | 23 +- ...editorRemoteAgentHostServiceClient.test.ts | 13 + 31 files changed, 1593 insertions(+), 182 deletions(-) create mode 100644 src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts create mode 100644 src/vs/platform/agentHost/node/agentHostDebugLogs.ts create mode 100644 src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts diff --git a/src/vs/base/node/zip.ts b/src/vs/base/node/zip.ts index 7be49cbb75decb..09d86cd2fb78cc 100644 --- a/src/vs/base/node/zip.ts +++ b/src/vs/base/node/zip.ts @@ -201,6 +201,49 @@ export interface IFile { localPathSize?: number; } +export interface IZipValidationOptions { + readonly maxEntries: number; + readonly maxUncompressedSize: number; +} + +export async function validateZip(zipPath: string, options: IZipValidationOptions): Promise { + const zipfile = await openZip(zipPath, true); + return new Promise((resolve, reject) => { + let entries = 0; + let uncompressedSize = 0; + let settled = false; + const fail = (error: Error) => { + if (settled) { + return; + } + settled = true; + zipfile.close(); + reject(error); + }; + zipfile.once('error', error => fail(toExtractError(error))); + zipfile.once('end', () => { + if (!settled) { + settled = true; + resolve(); + } + }); + zipfile.on('entry', (entry: Entry) => { + entries++; + uncompressedSize += entry.uncompressedSize; + if (entries > options.maxEntries) { + fail(new Error(`ZIP contains too many entries (${entries}; limit ${options.maxEntries})`)); + return; + } + if (uncompressedSize > options.maxUncompressedSize) { + fail(new Error(`ZIP expands beyond the allowed size (${uncompressedSize} bytes; limit ${options.maxUncompressedSize} bytes)`)); + return; + } + zipfile.readEntry(); + }); + zipfile.readEntry(); + }); +} + export async function zip(zipPath: string, files: IFile[]): Promise { const { ZipFile } = await import('yazl'); diff --git a/src/vs/base/test/node/zip/zip.test.ts b/src/vs/base/test/node/zip/zip.test.ts index 443053570500f6..63671eff1b52aa 100644 --- a/src/vs/base/test/node/zip/zip.test.ts +++ b/src/vs/base/test/node/zip/zip.test.ts @@ -10,7 +10,7 @@ import { createCancelablePromise } from '../../../common/async.js'; import { FileAccess } from '../../../common/network.js'; import * as path from '../../../common/path.js'; import { Promises } from '../../../node/pfs.js'; -import { buffer, extract, zip } from '../../../node/zip.js'; +import { buffer, extract, validateZip, zip } from '../../../node/zip.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../common/utils.js'; import { getRandomTestPath } from '../testUtils.js'; @@ -89,4 +89,18 @@ suite('Zip', () => { await Promises.rm(testDir); }); + + test('validateZip enforces entry and expanded-size limits', async () => { + const testDir = getRandomTestPath(tmpdir(), 'vsctests', 'zip-validation'); + const zipPath = path.join(testDir, 'logs.zip'); + await fs.promises.mkdir(testDir, { recursive: true }); + await zip(zipPath, [ + { path: 'one.txt', contents: '1234' }, + { path: 'two.txt', contents: '5678' }, + ]); + + await assert.rejects(validateZip(zipPath, { maxEntries: 1, maxUncompressedSize: 100 }), /too many entries/); + await assert.rejects(validateZip(zipPath, { maxEntries: 10, maxUncompressedSize: 7 }), /expands beyond the allowed size/); + await Promises.rm(testDir); + }); }); diff --git a/src/vs/platform/agentHost/browser/nullAgentHostService.ts b/src/vs/platform/agentHost/browser/nullAgentHostService.ts index e7b01064e0c85f..001844f822e0c5 100644 --- a/src/vs/platform/agentHost/browser/nullAgentHostService.ts +++ b/src/vs/platform/agentHost/browser/nullAgentHostService.ts @@ -8,7 +8,7 @@ import { IReference } from '../../../base/common/lifecycle.js'; import { constObservable, IObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import type { IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../common/agent.js'; -import type { IAgentHostInspectInfo, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentHostService, IAgentHostSocketInfo } from '../common/agentService.js'; +import type { AgentHostDebugLogsArtifactKind, IAgentHostDebugLogsArtifact, IAgentHostDebugLogsChunk, IAgentHostInspectInfo, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentHostService, IAgentHostSocketInfo } from '../common/agentService.js'; import type { IActiveSubscriptionInfo, IAgentSubscription } from '../common/state/agentSubscription.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InitializeResult } from '../common/state/protocol/common/commands.js'; @@ -53,6 +53,8 @@ export class NullAgentHostService implements IAgentHostService { async getNetworkDiagnosticsInfo(): Promise { return notSupported(); } async getManagedSettingsDiagnostics(): Promise { return []; } async diagnosticsFetch(_url: string): Promise { return notSupported(); } + async collectDebugLogs(_session: URI | undefined, _kind: AgentHostDebugLogsArtifactKind): Promise { return notSupported(); } + async readDebugLogsChunk(_resource: URI, _position: number): Promise { return notSupported(); } async listSessions(): Promise { return []; } async createSession(_config?: IAgentCreateSessionConfig): Promise { return notSupported(); } async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return notSupported(); } diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index a2e836063057c0..5841fa4199feb4 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -19,7 +19,8 @@ import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; import { AgentSession, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; -import { IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult } from '../common/agentService.js'; +import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; +import { CollectAgentHostDebugLogsExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, type IAgentHostExtensionCommandMap } from '../common/agentHostExtensionProtocol.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js'; import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; @@ -32,10 +33,10 @@ import { SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/re import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { type IVscodeUpgradeResult } from '../common/state/protocolUpgrade.js'; import { isClientTransport, NonReconnectableTransportError, type IProtocolTransport } from '../common/state/sessionTransport.js'; -import { AhpErrorCodes } from '../common/state/protocol/errors.js'; +import { AhpErrorCodes, JsonRpcErrorCodes } from '../common/state/protocol/errors.js'; import { ChatSourceKind, ContentEncoding, ResourceRequestParams, type CompletionsParams, type CompletionsResult, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; -import { encodeBase64 } from '../../../base/common/buffer.js'; +import { decodeBase64, encodeBase64 } from '../../../base/common/buffer.js'; import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.js'; import { ITelemetryService, TelemetryLevel, TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; @@ -99,13 +100,6 @@ function transportLostError(address: string): ProtocolError { return new ProtocolError(AHP_CLIENT_CONNECTION_CLOSED, `Transport lost (reconnecting): ${address}`); } -interface IRemoteAgentHostExtensionCommandMap { - 'shutdown': { params: undefined; result: void }; - 'getNetworkDiagnosticsInfo': { params: undefined; result: IAgentHostNetworkDiagnosticsInfo }; - 'getManagedSettingsDiagnostics': { params: undefined; result: readonly IAgentHostManagedSettingsDiagnostics[] }; - 'diagnosticsFetch': { params: { url: string }; result: IAgentHostNetworkFetchResult }; -} - interface IRemoteAgentHostExtensionNotificationMap { 'setClientManagedSettingsPermissions': { params: { permissions: IAgentHostManagedSettingsPermissions } }; } @@ -1133,6 +1127,68 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return this._sendExtensionRequest('getManagedSettingsDiagnostics'); } + async collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise { + const result = await this._sendExtensionRequest(CollectAgentHostDebugLogsExtensionMethod, { + session: session?.toString(), + kind, + }); + if (result.kind !== kind) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Agent Host returned ${result.kind} debug logs for a ${kind} request`); + } + const resource = URI.parse(result.resource, true); + if (resource.scheme !== Schemas.file) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Agent Host returned a non-file debug log resource: ${resource.toString()}`); + } + const maxUncompressedSize = kind === 'archive' ? AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES : AGENT_HOST_DEBUG_LOGS_MAX_BYTES; + if (!Number.isSafeInteger(result.size) || result.size < 0 || result.size > AGENT_HOST_DEBUG_LOGS_MAX_BYTES + || !Number.isSafeInteger(result.uncompressedSize) || result.uncompressedSize < 0 || result.uncompressedSize > maxUncompressedSize) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Agent Host returned invalid debug log artifact sizes'); + } + if (!Array.isArray(result.entries) || result.entries.length > AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Agent Host returned an invalid debug log artifact manifest'); + } + const entryPaths = new Set(); + let manifestSize = 0; + for (const entry of result.entries) { + const segments = entry.path.split('/'); + if (!entry.path || entry.path.includes('\\') || segments.some((segment: string) => !segment || segment === '.' || segment === '..') + || !Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES + || entryPaths.has(entry.path)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Agent Host returned an invalid debug log artifact manifest entry'); + } + entryPaths.add(entry.path); + manifestSize += entry.size; + } + if (!Number.isSafeInteger(manifestSize) || manifestSize !== result.uncompressedSize) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Agent Host debug log artifact manifest size does not match its declared size'); + } + return { + kind: result.kind, + resource: toAgentHostUri(resource, this._connectionAuthority), + providerLogsIncluded: result.providerLogsIncluded, + size: result.size, + uncompressedSize: result.uncompressedSize, + entries: result.entries, + }; + } + + /** + * Read one bounded slice of a debug-log artifact previously returned by + * {@link collectDebugLogs}. `resource` is the agent-host URI handed out by + * that call; it is unwrapped back to the host-local path here. + */ + async readDebugLogsChunk(resource: URI, position: number): Promise { + const result = await this._sendExtensionRequest(ReadAgentHostDebugLogsChunkExtensionMethod, { + resource: fromAgentHostUri(resource).toString(), + position, + }); + const data = decodeBase64(result.data); + if (data.byteLength > AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Agent Host returned an oversized debug log chunk'); + } + return { data, eof: result.eof === true }; + } + /** * Probe connectivity from the remote agent host to a single `url`. */ @@ -1292,8 +1348,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** * Read the content of a resource on the remote host. */ - async resourceRead(uri: URI): Promise { - return this._sendRequest('resourceRead', { channel: ROOT_STATE_URI, uri: uri.toString() }); + async resourceRead(uri: URI, encoding?: ContentEncoding): Promise { + return this._sendRequest('resourceRead', { channel: ROOT_STATE_URI, uri: uri.toString(), encoding }); } async resourceWrite(params: CommandMap['resourceWrite']['params']): Promise { @@ -1633,8 +1689,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } /** Send a JSON-RPC request for a VS Code extension method (not in the protocol spec). */ - private _sendExtensionRequest(method: M, params?: IRemoteAgentHostExtensionCommandMap[M]['params']): Promise { - return this._dispatchRequest(method, params); + private _sendExtensionRequest(method: M, params?: IAgentHostExtensionCommandMap[M]['params']): Promise { + return this._dispatchRequest(method, params); } private _updateTelemetryLevel(): void { diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 2a352dcc39a344..84e23cf0488d65 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1186,6 +1186,9 @@ export interface IAgent { /** Optional managed-settings snapshot for providers with an enterprise policy surface. */ getManagedSettingsDiagnostics?(): Promise; + /** Add provider-owned diagnostics to an Agent Host debug-log staging directory. */ + collectDebugLogs?(session: URI | undefined, outputDirectory: URI): Promise; + // ---- MCP and server tools ----------------------------------------------- /** Optional host wiring for providers that advertise Agent Host server tools. */ diff --git a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts new file mode 100644 index 00000000000000..89f118a306fb02 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { AgentHostDebugLogsArtifactKind, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult } from './agentService.js'; + +export const CollectAgentHostDebugLogsExtensionMethod = 'vscode/collectAgentHostDebugLogs'; +export const ReadAgentHostDebugLogsChunkExtensionMethod = 'vscode/readAgentHostDebugLogsChunk'; + +export interface IAgentHostExtensionCommandMap { + 'shutdown': { params: undefined; result: void }; + 'getNetworkDiagnosticsInfo': { params: undefined; result: IAgentHostNetworkDiagnosticsInfo }; + 'getManagedSettingsDiagnostics': { params: undefined; result: readonly IAgentHostManagedSettingsDiagnostics[] }; + 'diagnosticsFetch': { params: { url: string }; result: IAgentHostNetworkFetchResult }; + [CollectAgentHostDebugLogsExtensionMethod]: { + params: { session?: string; kind: AgentHostDebugLogsArtifactKind }; + result: { kind: AgentHostDebugLogsArtifactKind; resource: string; providerLogsIncluded: boolean; size: number; uncompressedSize: number; entries: readonly { path: string; size: number }[] }; + }; + [ReadAgentHostDebugLogsChunkExtensionMethod]: { + params: { resource: string; position: number }; + /** `data` is base64; at most `AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES` decoded bytes. */ + result: { data: string; eof: boolean }; + }; +} diff --git a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts index 114626868cfa14..39ca9fab306da7 100644 --- a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts +++ b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts @@ -24,7 +24,7 @@ import { ROOT_STATE_URI } from './state/sessionState.js'; */ export interface IRemoteFilesystemConnection { resourceList(uri: URI): Promise; - resourceRead(uri: URI): Promise; + resourceRead(uri: URI, encoding?: ContentEncoding): Promise; resourceWrite(params: ResourceWriteParams): Promise; resourceDelete(params: ResourceDeleteParams): Promise; resourceMove(params: ResourceMoveParams): Promise; @@ -448,7 +448,7 @@ export abstract class AHPFileSystemProvider extends Disposable implements IFileS const connection = await this._getConnection(resource.authority); try { const originalUri = this._decodeUri(resource); - const result = await connection.resourceRead(originalUri); + const result = await connection.resourceRead(originalUri, ContentEncoding.Base64); if (result.encoding === ContentEncoding.Base64) { return decodeBase64(result.data).buffer; } diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 2082ba828df939..35b5dc7c289783 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { CancellationToken } from '../../../base/common/cancellation.js'; +import type { VSBuffer } from '../../../base/common/buffer.js'; import { Event } from '../../../base/common/event.js'; import { IReference } from '../../../base/common/lifecycle.js'; import type { IObservable } from '../../../base/common/observable.js'; @@ -19,7 +20,7 @@ import type { CompletionsParams, CompletionsResult, CreateTerminalParams, Resolv import type { InitializeResult } from './state/protocol/common/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction, ClientChangesetAction } from './state/sessionActions.js'; -import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js'; +import type { ContentEncoding, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js'; import { ComponentToState, StateComponents, type RootState } from './state/sessionState.js'; import { type AgentProvider, CLAUDE_AGENT_PROVIDER_ID, CODEX_AGENT_PROVIDER_ID, type AuthenticateParams, type AuthenticateResult, type IAgentHostAuthTokenRequest, type IAgentCreateChatOptions, type IAgentCreateSessionConfig, type IAgentSessionMetadata, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IMcpNotification, type IAgentHostNetworkEndpoint, type IAgentHostManagedSettingsSnapshot } from './agent.js'; @@ -69,6 +70,53 @@ export const enum AgentHostIpcChannels { /** Configuration key that controls whether AHP JSONL logs are written for agent host transports. */ export const AgentHostAhpJsonlLoggingSettingId = 'chat.agentHost.ahpJsonlLoggingEnabled'; +export type AgentHostDebugLogsArtifactKind = 'archive' | 'directory'; +export const AGENT_HOST_DEBUG_LOGS_MAX_BYTES = 16 * 1024 * 1024; +/** Maximum number of files in one Agent Host debug-log artifact. */ +export const AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES = 1000; +/** + * Maximum payload of a single {@link IAgentHostDebugLogsChunk}. Debug-log + * artifacts are streamed in chunks of at most this size so a remote agent host + * never has to encode a whole archive into one JSON-RPC message. + */ +export const AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES = 1024 * 1024; +/** + * Upper bound on the *uncompressed* logs staged for an archive artifact. Log + * text compresses heavily, so this is deliberately far larger than + * {@link AGENT_HOST_DEBUG_LOGS_MAX_BYTES} — which still bounds the archive that + * is actually transferred. It only exists to keep zipping work finite. + */ +export const AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES = 256 * 1024 * 1024; +/** + * Upper bound on any single file inside an artifact. Oversized files are + * reduced to their trailing bytes rather than dropped, so a very large process + * log still contributes the portion that explains a recent failure. + */ +export const AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES = 10 * 1024 * 1024; + +export interface IAgentHostDebugLogsArtifactEntry { + readonly path: string; + readonly size: number; +} + +export interface IAgentHostDebugLogsArtifact { + readonly kind: AgentHostDebugLogsArtifactKind; + readonly resource: URI; + readonly providerLogsIncluded: boolean; + readonly size: number; + readonly uncompressedSize: number; + /** Exact regular files staged in the artifact. Paths are relative, normalized, and unique. */ + readonly entries: readonly IAgentHostDebugLogsArtifactEntry[]; +} + +/** One bounded slice of a debug-log artifact, read via `readDebugLogsChunk`. */ +export interface IAgentHostDebugLogsChunk { + /** Raw bytes for this slice. Empty once `position` is at or past the end. */ + readonly data: VSBuffer; + /** `true` when this slice reaches the end of the artifact. */ + readonly eof: boolean; +} + /** Configuration key controlling automatic OS system proxy discovery for agent-host Copilot sessions. */ export const AgentHostSystemProxyEnabledSettingId = 'chat.agentHost.systemProxy.enabled'; @@ -738,6 +786,8 @@ export interface IAgentHostManagementService { getNetworkDiagnosticsInfo(): Promise; getManagedSettingsDiagnostics(): Promise; diagnosticsFetch(url: string): Promise; + collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise; + readDebugLogsChunk(resource: URI, position: number): Promise; startWebSocketServer(): Promise; getInspectInfo(tryEnable: boolean): Promise; } @@ -870,6 +920,10 @@ export interface IAgentService { */ diagnosticsFetch(url: string): Promise; + collectDebugLogs?(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise; + + readDebugLogsChunk?(resource: URI, position: number): Promise; + // ---- Protocol methods (sessions process protocol) ---------------------- /** @@ -934,7 +988,7 @@ export interface IAgentService { * Read stored content by URI from the agent host (e.g. file edit snapshots, * or reading files from the remote filesystem). */ - resourceRead(uri: URI): Promise; + resourceRead(uri: URI, encoding?: ContentEncoding): Promise; /** * Write content to a file on the agent host's filesystem. @@ -1097,6 +1151,14 @@ export interface IAgentConnection { */ diagnosticsFetch(url: string): Promise; + collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise; + + /** + * Read one bounded slice of an artifact previously returned by + * {@link collectDebugLogs}. Only artifacts this host produced are readable. + */ + readDebugLogsChunk(resource: URI, position: number): Promise; + /** * Create an additional peer chat inside an existing session. `chat` is a * client-chosen chat URI (see {@link buildChatUri}). The host adds the @@ -1115,7 +1177,7 @@ export interface IAgentConnection { // ---- Filesystem operations ---------------------------------------------- resourceList(uri: URI): Promise; - resourceRead(uri: URI): Promise; + resourceRead(uri: URI, encoding?: ContentEncoding): Promise; resourceWrite(params: ResourceWriteParams): Promise; resourceCopy(params: ResourceCopyParams): Promise; resourceDelete(params: ResourceDeleteParams): Promise; diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index fb2312213280cd..b67bdebd441664 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -32,6 +32,7 @@ import { AgentHostStartupTelemetry } from '../common/agentHostStartupTelemetry.j import { AgentHostClientConnectionKind } from '../common/agentHostTelemetry.js'; import { AgentHostAhpJsonlLoggingSettingId, + type AgentHostDebugLogsArtifactKind, AgentHostIpcChannels, AgentHostOTelPolicyIpcChannel, AgentHostRestartIpcChannel, @@ -40,6 +41,7 @@ import { IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, + type IAgentHostDebugLogsArtifact, IAgentHostManagementService, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, @@ -53,10 +55,11 @@ import { AuthenticateResult, IMcpNotification, readAgentHostOTelPolicySettings, + type IAgentHostDebugLogsChunk, } from '../common/agentService.js'; import type { IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; import type { IActiveSubscriptionInfo, IAgentSubscription } from '../common/state/agentSubscription.js'; -import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, ContentEncoding, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { Implementation, InitializeResult } from '../common/state/protocol/common/commands.js'; import { NonReconnectableTransportError } from '../common/state/sessionTransport.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; @@ -467,8 +470,8 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._requireClient().resourceList(uri); } - resourceRead(uri: URI): Promise { - return this._requireClient().resourceRead(uri); + resourceRead(uri: URI, encoding?: ContentEncoding): Promise { + return this._requireClient().resourceRead(uri, encoding); } resourceWrite(params: ResourceWriteParams): Promise { @@ -515,6 +518,14 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._getManagementService().diagnosticsFetch(url); } + collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise { + return this._getManagementService().collectDebugLogs(session, kind); + } + + readDebugLogsChunk(resource: URI, position: number): Promise { + return this._getManagementService().readDebugLogsChunk(resource, position); + } + async restartAgentHost(): Promise { this._forwardOTelPolicy(); ipcRenderer.send(AgentHostRestartIpcChannel); diff --git a/src/vs/platform/agentHost/node/agentHostDebugLogs.ts b/src/vs/platform/agentHost/node/agentHostDebugLogs.ts new file mode 100644 index 00000000000000..0b19b14c1cac8d --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostDebugLogs.ts @@ -0,0 +1,226 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { copyFile, mkdir, open, readdir, rm, stat } from 'fs/promises'; +import { disposableTimeout } from '../../../base/common/async.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { Schemas } from '../../../base/common/network.js'; +import { join } from '../../../base/common/path.js'; +import { zip, type IFile } from '../../../base/node/zip.js'; +import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import type { ILogService } from '../../log/common/log.js'; +import type { IAgent } from '../common/agent.js'; +import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; + +type DebugLogsProvider = Pick; +type LocalZipFile = IFile & { readonly localPath: string }; +const DEFAULT_ARTIFACT_LEASE_MS = 10 * 60 * 1000; + +export interface IAgentHostDebugLogsEnvironment { + readonly logsHome: URI; + readonly tmpDir: URI; +} + +export class AgentHostDebugLogsCollector extends Disposable { + private readonly _retainedArtifacts = new Map(); + private readonly _readableArtifacts = new Set(); + + constructor( + private readonly _environment: IAgentHostDebugLogsEnvironment, + private readonly _logService: ILogService, + private readonly _artifactLeaseMs = DEFAULT_ARTIFACT_LEASE_MS, + ) { + super(); + this._register(toDisposable(() => void this.cleanup())); + } + + async collect(providers: readonly DebugLogsProvider[], session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise { + const id = generateUuid(); + const staging = join(this._environment.tmpDir.fsPath, `agent-host-debug-logs-${id}`); + await mkdir(staging, { recursive: true }); + this._retainedArtifacts.set(artifactKey(staging), true); + let providerLogsIncluded = false; + let retainStaging = false; + const archive = join(this._environment.tmpDir.fsPath, `agent-host-debug-logs-${id}.zip`); + try { + for (const provider of providers) { + if (!provider.collectDebugLogs) { + continue; + } + // An implemented provider contributor is part of this collection, + // so its failure must fail the export. Providers without additional + // diagnostics still get the Agent Host process log below. + providerLogsIncluded = await provider.collectDebugLogs(session, URI.file(staging)) || providerLogsIncluded; + } + + await this._copyOptional( + join(this._environment.logsHome.fsPath, 'agenthost.log'), + join(staging, 'agenthost.log'), + ); + + const files = await collectFiles(staging); + // Process logs can reach hundreds of megabytes. Keep the tail of any + // oversized file: it is the part that explains a recent failure, and + // it keeps the artifact within the size the client will accept — + // whether the file came from a provider's SDK bundle or was copied + // in directly. + let uncompressedSize = 0; + const artifactEntries: { path: string; size: number }[] = []; + for (const file of files) { + const size = await truncateToTail(file.localPath, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES); + uncompressedSize += size; + artifactEntries.push({ path: file.path, size }); + } + // A directory artifact is copied file-by-file, so its uncompressed + // size is what crosses the wire. An archive only has to keep the + // staged input bounded; the archive itself is checked after zipping. + const stagedLimit = kind === 'directory' ? AGENT_HOST_DEBUG_LOGS_MAX_BYTES : AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES; + if (uncompressedSize > stagedLimit) { + throw new Error(`Agent Host debug logs are too large (${uncompressedSize} bytes; limit ${stagedLimit} bytes)`); + } + + if (kind === 'directory') { + retainStaging = true; + this._scheduleCleanup(staging, true, files.map(file => file.localPath)); + return { kind, resource: URI.file(staging), providerLogsIncluded, size: uncompressedSize, uncompressedSize, entries: artifactEntries }; + } + + await zip(archive, files); + const archiveSize = (await stat(archive)).size; + if (archiveSize > AGENT_HOST_DEBUG_LOGS_MAX_BYTES) { + throw new Error(`Agent Host debug log archive is too large (${archiveSize} bytes; limit ${AGENT_HOST_DEBUG_LOGS_MAX_BYTES} bytes)`); + } + this._scheduleCleanup(archive, false, [archive]); + return { kind, resource: URI.file(archive), providerLogsIncluded, size: archiveSize, uncompressedSize, entries: artifactEntries }; + } catch (error) { + await rm(archive, { force: true }); + throw error; + } finally { + if (!retainStaging) { + await rm(staging, { recursive: true, force: true }); + this._retainedArtifacts.delete(artifactKey(staging)); + } + } + } + + /** + * Read one bounded slice of an artifact this collector produced. The + * resource must be a currently retained *file* artifact, so this cannot be + * used as a general-purpose file read. + */ + async readArtifactChunk(resource: URI, position: number): Promise { + if (resource.scheme !== Schemas.file) { + throw new Error(`Unsupported debug-log artifact scheme: ${resource.scheme}`); + } + if (!Number.isSafeInteger(position) || position < 0) { + throw new Error(`Invalid debug-log artifact position: ${position}`); + } + const path = resource.fsPath; + if (!this._readableArtifacts.has(artifactKey(path))) { + throw new Error('Unknown or expired Agent Host debug-log artifact'); + } + + const handle = await open(path, 'r'); + try { + const buffer = Buffer.allocUnsafe(AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES); + const { bytesRead } = await handle.read(buffer, 0, AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, position); + const { size } = await handle.stat(); + return { + data: VSBuffer.wrap(buffer.subarray(0, bytesRead)), + eof: position + bytesRead >= size, + }; + } finally { + await handle.close(); + } + } + + async cleanup(): Promise { + const artifacts = [...this._retainedArtifacts]; + this._retainedArtifacts.clear(); + this._readableArtifacts.clear(); + await Promise.all(artifacts.map(async ([path, recursive]) => { + try { + await rm(path, { recursive, force: true }); + } catch (error) { + this._logService.warn(`[AgentHostDebugLogs] Failed to remove temporary artifact during shutdown ${path}`, error); + } + })); + } + + private async _copyOptional(source: string, target: string): Promise { + try { + await copyFile(source, target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + this._logService.warn(`[AgentHostDebugLogs] Failed to include ${source}`, error); + } + } + } + + private _scheduleCleanup(path: string, recursive: boolean, readablePaths: readonly string[]): void { + this._retainedArtifacts.set(artifactKey(path), recursive); + for (const readablePath of readablePaths) { + this._readableArtifacts.add(artifactKey(readablePath)); + } + this._register(disposableTimeout(() => { + this._retainedArtifacts.delete(artifactKey(path)); + for (const readablePath of readablePaths) { + this._readableArtifacts.delete(artifactKey(readablePath)); + } + rm(path, { recursive, force: true }).catch(error => { + this._logService.warn(`[AgentHostDebugLogs] Failed to expire temporary artifact ${path}`, error); + }); + }, this._artifactLeaseMs)); + } +} + +/** + * Normalizes a path for artifact bookkeeping. `URI.file(…).fsPath` lower-cases + * Windows drive letters, so keys must be derived the same way on both the + * writing and the lookup side or reads would never match on Windows. + */ +function artifactKey(path: string): string { + return URI.file(path).fsPath; +} + +/** + * Rewrites `path` in place to its last `maxBytes` bytes when it exceeds them. + * Returns the resulting size. + */ +async function truncateToTail(path: string, maxBytes: number): Promise { + const { size } = await stat(path); + if (size <= maxBytes) { + return size; + } + const handle = await open(path, 'r+'); + try { + const buffer = Buffer.allocUnsafe(maxBytes); + const { bytesRead } = await handle.read(buffer, 0, maxBytes, size - maxBytes); + await handle.write(buffer, 0, bytesRead, 0); + await handle.truncate(bytesRead); + return bytesRead; + } finally { + await handle.close(); + } +} + +async function collectFiles(root: string, relative = '', files: LocalZipFile[] = []): Promise { + const directory = join(root, relative); + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const path = relative ? `${relative}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await collectFiles(root, path, files); + } else if (entry.isFile()) { + files.push({ path, localPath: join(root, path) }); + if (files.length > AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES) { + throw new Error(`Agent Host debug logs contain too many files (${files.length}; limit ${AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES})`); + } + } + } + return files; +} diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 35064c1126561b..ff51ce682eb1b4 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -205,7 +205,10 @@ async function startAgentHost(): Promise { diServices.set(IByokLmProxyService, byokLmProxyService); const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService, fetchFn)); diServices.set(IAgentHostOTelService, agentHostOTelService); - agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)], hostLaunchKind, storageResource); + agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)], hostLaunchKind, storageResource, undefined, undefined, { + logsHome: environmentService.logsHome, + tmpDir: environmentService.tmpDir, + }); const networkDiagnosticsService = instantiationService.createInstance(NetworkDiagnosticsService); diServices.set(INetworkDiagnosticsService, networkDiagnosticsService); agentService.setNetworkDiagnosticsService(networkDiagnosticsService); diff --git a/src/vs/platform/agentHost/node/agentHostManagementService.ts b/src/vs/platform/agentHost/node/agentHostManagementService.ts index a5e53dcedd5997..2e6e848923a6e8 100644 --- a/src/vs/platform/agentHost/node/agentHostManagementService.ts +++ b/src/vs/platform/agentHost/node/agentHostManagementService.ts @@ -7,7 +7,7 @@ import { Promises, raceTimeout } from '../../../base/common/async.js'; import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentCreateChatOptions, IAgentCreateSessionConfig } from '../common/agent.js'; -import { IAgentHostInspectInfo, IAgentHostManagedSettingsDiagnostics, IAgentHostManagementService, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService } from '../common/agentService.js'; +import { IAgentHostInspectInfo, IAgentHostManagedSettingsDiagnostics, IAgentHostManagementService, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; const SHUTDOWN_DRAIN_TIMEOUT_MS = 1000; @@ -88,6 +88,20 @@ export class AgentHostManagementService implements IAgentHostManagementService { return this._agentService.diagnosticsFetch(url); } + collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise { + if (!this._agentService.collectDebugLogs) { + throw new Error('Agent Host debug log collection is unavailable'); + } + return this._agentService.collectDebugLogs(session, kind); + } + + readDebugLogsChunk(resource: URI, position: number): Promise { + if (!this._agentService.readDebugLogsChunk) { + throw new Error('Agent Host debug log collection is unavailable'); + } + return this._agentService.readDebugLogsChunk(resource, position); + } + startWebSocketServer(): Promise { return this._connectionTrackerService.startWebSocketServer(); } diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 0e067070e96b5a..9f03ee18a6c6fe 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -264,7 +264,10 @@ async function main(): Promise { diServices.set(IAgentHostGitService, gitService); // Create the agent service (owns AgentHostStateManager + AgentSideEffects internally) - const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)], AgentHostLaunchKind.VSCodeCLI, storageResource); + const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, rootConfigResource, telemetryService, fileMonitorService, undefined, fetchFn, [createCodexProviderConfiguration(environmentService.userHome)], AgentHostLaunchKind.VSCodeCLI, storageResource, undefined, undefined, { + logsHome: environmentService.logsHome, + tmpDir: environmentService.tmpDir, + }); disposables.add(agentService); diServices.set(IAgentService, agentService); diServices.set(IAgentHostStateManager, agentService.stateManager); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 69e87edfc579ab..22699c2965c178 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -4,13 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { open, unlink, type FileHandle } from 'fs/promises'; -import { decodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; import { DeferredPromise, disposableTimeout, Limiter, Promises, ResourceQueue } from '../../../base/common/async.js'; import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { Emitter, type Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; -import { getExtensionForMimeType, getMediaMime } from '../../../base/common/mime.js'; +import { getExtensionForMimeType, getMediaMime, getMediaOrTextMime } from '../../../base/common/mime.js'; import { Schemas } from '../../../base/common/network.js'; import { IObservable, observableValue } from '../../../base/common/observable.js'; import { dirname as resourcesDirname, extname as resourcesExtname, extUriBiasedIgnorePathCase, isEqual, isEqualOrParent, joinPath } from '../../../base/common/resources.js'; @@ -23,7 +23,7 @@ import { InstantiationService } from '../../instantiation/common/instantiationSe import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDiscoveredChat, IAgentHostAuthTokenRequest, IAgentHostNetworkEndpoint, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentChatAdoptionResult, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, SubagentChatSignal, subagentChatTitle } from '../common/agent.js'; -import { AgentHostSessionReleaseGraceMsEnvVar, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; +import { AgentHostSessionReleaseGraceMsEnvVar, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentService } from '../common/agentService.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; import { IAgentEditAttributionService, ICancelEditAttributionFlushParams, ICommitEditAttributionFlushParams, IEditAttributionFlushResult, IPrepareEditAttributionFlushParams, IPreparedEditAttributionFlush, parseEditAttributionResource } from '../common/fileEditAttribution.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; @@ -52,6 +52,7 @@ import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateM import { createAgentChatContext } from './agentChatContext.js'; import { AgentHostPromptCache, IAgentHostPromptCache } from './agentHostPromptCache.js'; import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from './agentHostSessionTitleSignal.js'; +import { AgentHostDebugLogsCollector, type IAgentHostDebugLogsEnvironment } from './agentHostDebugLogs.js'; import { AgentHostDatabase, IAgentHostDatabase } from './agentHostDatabase.js'; import { AgentSessionRegistry, IRegisteredSession, IStoredRegisteredSession } from './agentSessionRegistry.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; @@ -439,6 +440,7 @@ export class AgentService extends Disposable implements IAgentService { private readonly _localTurns: AgentHostLocalTurns; /** Server-side host for the agent host's server tools. */ private readonly _serverToolHost: AgentServerToolHost; + private readonly _debugLogsCollector: AgentHostDebugLogsCollector | undefined; private readonly _configurationService: AgentConfigurationService; private readonly _storageService: AgentHostStorageService; private readonly _customizationEnablementService: AgentHostCustomizationEnablementService; @@ -560,6 +562,7 @@ export class AgentService extends Disposable implements IAgentService { storageResource?: URI, orchestratorDatabase?: IAgentHostDatabase, private readonly _now: () => number = Date.now, + debugLogsEnvironment?: IAgentHostDebugLogsEnvironment, ) { super(); this._logService.info('AgentService initialized'); @@ -568,6 +571,7 @@ export class AgentService extends Disposable implements IAgentService { ? joinPath(resourcesDirname(this._rootConfigResource), 'agent-host.db').fsPath : ':memory:'; this._orchestratorDatabase = this._register(orchestratorDatabase ?? new AgentHostDatabase(databasePath)); + this._debugLogsCollector = debugLogsEnvironment ? this._register(new AgentHostDebugLogsCollector(debugLogsEnvironment, this._logService)) : undefined; this._sessionRegistry = this._register(new AgentSessionRegistry(this._orchestratorDatabase)); this._stateManager = this._register(new AgentHostStateManager(_logService, { hostBuildInfo: hostBuildInfoFromProduct(this._productService), @@ -5326,7 +5330,7 @@ export class AgentService extends Disposable implements IAgentService { return allSessions?.find(candidate => candidate.session.toString() === sessionStr); } - async resourceRead(uri: URI): Promise { + async resourceRead(uri: URI, encoding: ContentEncoding = ContentEncoding.Utf8): Promise { const editAttributionRequest = parseEditAttributionResource(uri); if (editAttributionRequest?.kind === 'prepare') { const prepared = await this.prepareEditAttributionFlush(editAttributionRequest.params); @@ -5372,9 +5376,9 @@ export class AgentService extends Disposable implements IAgentService { try { const content = await this._fileService.readFile(uri); return { - data: content.value.toString(), - encoding: ContentEncoding.Utf8, - contentType: 'text/plain', + data: encoding === ContentEncoding.Base64 ? encodeBase64(content.value) : content.value.toString(), + encoding, + contentType: getMediaOrTextMime(uri.path) ?? 'application/octet-stream', }; } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); @@ -5815,6 +5819,7 @@ export class AgentService extends Disposable implements IAgentService { try { await Promises.settled(promises); } finally { + await this._debugLogsCollector?.cleanup(); await this._orchestratorDatabase.close(); this._sessionToProvider.clear(); this._downloadProgressInterest.clear(); @@ -5892,6 +5897,28 @@ export class AgentService extends Disposable implements IAgentService { return this._networkDiagnostics.fetch(url); } + async collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise { + if (!this._debugLogsCollector) { + throw new Error('Agent Host debug log collection is unavailable'); + } + const providers = session + ? [this._findProviderForSession(session)].filter((provider): provider is IAgent => provider !== undefined) + : [...this._providers.values()]; + if (providers.length === 0) { + throw new Error(session + ? `No Agent Host provider is available for session ${session.toString()}` + : 'No Agent Host providers are available for debug-log collection'); + } + return this._debugLogsCollector.collect(providers, session, kind); + } + + async readDebugLogsChunk(resource: URI, position: number): Promise { + if (!this._debugLogsCollector) { + throw new Error('Agent Host debug log collection is unavailable'); + } + return this._debugLogsCollector.readArtifactChunk(resource, position); + } + // ---- helpers ------------------------------------------------------------ private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 85a564b340993b..555abb2acabf12 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -2205,6 +2205,25 @@ export class CopilotAgent extends Disposable implements IAgent { return result; } + async collectDebugLogs(session: URI | undefined, outputDirectory: URI): Promise { + const sessionTarget = session ? this._findSessionChat(session) : undefined; + if (sessionTarget) { + await sessionTarget.collectDebugLogs(outputDirectory, true); + return true; + } + + // A new/closed UI session can have a URI without a live SDK session. In + // that case this is a host-wide export: use any live SDK session only as + // the gateway to collect process logs, without attributing events or shell + // logs from that unrelated session. + const processLogsTarget = this._allLiveSessions()[0]; + if (!processLogsTarget) { + return false; + } + await processLogsTarget.collectDebugLogs(outputDirectory, false); + return true; + } + private _copilotChatDiscovery: Promise | undefined; private readonly _copilotChatDiscoverySequencer = new Sequencer(); private readonly _discoveredChats = new Map(); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index cf2d50c3663d0f..4f86023d1392f4 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { CopilotSession, CurrentToolMetadata, ElicitationContext, ElicitationFieldValue, ElicitationResult, ElicitationSchema, ElicitationSchemaField, ExitPlanModeCompletedData, ExitPlanModeRequest, ExitPlanModeResult, ManagedSettingsResolvedData, JsonValue, McpServersLoadedServer, MessageOptions, PermissionAllowAllMode, PermissionAutoApproval, PermissionRequest, PermissionRequestResult, PermissionResult, SessionConfig, SessionHooks, SessionMode as CopilotSdkMode, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; +import { cp, rm } from 'fs/promises'; import { raceCancellation, RunOnceScheduler, Sequencer, SequencerByKey, Throttler } from '../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; @@ -743,6 +744,26 @@ export class CopilotAgentSession extends Disposable { get currentTurnId(): string | undefined { return this._currentTurn?.id; } get currentTurnClientType(): AgentHostClientType { return this._currentTurn?.clientType ?? AgentHostClientType.Unknown; } get currentTurnClientContext(): IAgentHostClientTelemetryContext | undefined { return this._currentTurn?.clientContext; } + + async collectDebugLogs(outputDirectory: URI, includeSessionLogs: boolean): Promise { + const result = await this._wrapper.session.rpc.debug.collectLogs({ + destination: { kind: 'directory', outputDirectory: outputDirectory.fsPath }, + include: { + events: includeSessionLogs, + processLogs: true, + shellLogs: includeSessionLogs, + }, + }); + if (result.kind !== 'directory' || result.path === outputDirectory.fsPath) { + return; + } + try { + await cp(result.path, outputDirectory.fsPath, { recursive: true }); + } finally { + await rm(result.path, { recursive: true, force: true }); + } + } + /** * Last model id seen on the SDK's per-LLM-call `Usage` event (or a * direct {@link setModel} call). We rely on the diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index baecb6a34881fa..f2c85187b1498b 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { disposableTimeout } from '../../../base/common/async.js'; +import { encodeBase64 } from '../../../base/common/buffer.js'; import { Emitter } from '../../../base/common/event.js'; import { isJsonRpcResponse } from '../../../base/common/jsonRpcProtocol.js'; import { Disposable, DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; @@ -19,6 +20,7 @@ import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportK import { AgentSession, type IAgentCreateChatOptions, type IMcpNotification } from '../common/agent.js'; import { isManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { type IAgentService } from '../common/agentService.js'; +import { CollectAgentHostDebugLogsExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod } from '../common/agentHostExtensionProtocol.js'; import { isActionEnvelopeRelevantToSubscriptionUris } from '../common/state/agentSubscription.js'; import { ChatSourceKind } from '../common/state/protocol/channels-chat/commands.js'; import type { CommandMap } from '../common/state/protocol/messages.js'; @@ -1501,7 +1503,7 @@ export class ProtocolServerHandler extends Disposable { return this._agentService.resourceList(URI.parse(params.uri)); }, resourceRead: async (_client, params) => { - return this._agentService.resourceRead(URI.parse(params.uri)); + return this._agentService.resourceRead(URI.parse(params.uri), params.encoding); }, resourceCopy: async (_client, params) => { return this._agentService.resourceCopy(params); @@ -1668,6 +1670,67 @@ export class ProtocolServerHandler extends Disposable { return this._agentService.getManagedSettingsDiagnostics(); case 'diagnosticsFetch': return this._agentService.diagnosticsFetch((params as { url: string }).url); + case CollectAgentHostDebugLogsExtensionMethod: { + if (!this._agentService.collectDebugLogs) { + return undefined; + } + if (!isParamsObject(params)) { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'params must be an object')); + } + const sessionParam = params['session']; + if (sessionParam !== undefined && typeof sessionParam !== 'string') { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be a URI string')); + } + let session: URI | undefined; + if (sessionParam !== undefined) { + try { + session = URI.parse(sessionParam, true); + } catch { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be a valid URI string')); + } + if (!AgentSession.provider(session)) { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be an Agent Session URI')); + } + } + const kind = params['kind']; + if (kind !== 'archive' && kind !== 'directory') { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'kind must be archive or directory')); + } + return this._agentService.collectDebugLogs(session, kind).then(result => ({ + kind: result.kind, + resource: result.resource.toString(), + providerLogsIncluded: result.providerLogsIncluded, + size: result.size, + uncompressedSize: result.uncompressedSize, + entries: result.entries, + })); + } + case ReadAgentHostDebugLogsChunkExtensionMethod: { + if (!this._agentService.readDebugLogsChunk) { + return undefined; + } + if (!isParamsObject(params)) { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'params must be an object')); + } + const resourceParam = params['resource']; + if (typeof resourceParam !== 'string') { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'resource must be a URI string')); + } + let resource: URI; + try { + resource = URI.parse(resourceParam, true); + } catch { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'resource must be a valid URI string')); + } + const position = params['position']; + if (typeof position !== 'number' || !Number.isSafeInteger(position) || position < 0) { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'position must be a non-negative integer')); + } + return this._agentService.readDebugLogsChunk(resource, position).then(chunk => ({ + data: encodeBase64(chunk.data), + eof: chunk.eof, + })); + } default: return undefined; } diff --git a/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts index 8313349378760a..74be7fa87e447f 100644 --- a/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts @@ -348,12 +348,14 @@ suite('AgentHostFileSystemProvider - synthetic content schemes', () => { */ class StubConnection implements IRemoteFilesystemConnection { readonly readCalls: URI[] = []; + readonly readEncodings: (ContentEncoding | undefined)[] = []; readonly listCalls: URI[] = []; readonly resolveCalls: ResourceResolveParams[] = []; readResult: ResourceReadResult = { data: 'stub-content', encoding: ContentEncoding.Utf8, contentType: 'text/plain' }; - async resourceRead(uri: URI): Promise { + async resourceRead(uri: URI, encoding?: ContentEncoding): Promise { this.readCalls.push(uri); + this.readEncodings.push(encoding); return this.readResult; } async resourceList(uri: URI): Promise { @@ -430,7 +432,23 @@ suite('AgentHostFileSystemProvider - synthetic content schemes', () => { const bytes = await provider.readFile(wrapped); assert.strictEqual(VSBuffer.wrap(bytes).toString(), 'stub-content'); - assert.deepStrictEqual(connection.readCalls.map(u => u.toString()), [inner.toString()]); + assert.deepStrictEqual({ + resources: connection.readCalls.map(u => u.toString()), + encodings: connection.readEncodings, + }, { + resources: [inner.toString()], + encodings: [ContentEncoding.Base64], + }); + }); + + test('readFile decodes binary Base64 content', async () => { + const { provider, connection } = setup(); + disposables.add(provider.registerAuthority('remote', connection)); + connection.readResult = { data: 'UEsAAf8=', encoding: ContentEncoding.Base64, contentType: 'application/zip' }; + + const bytes = await provider.readFile(agentHostUri('remote', '/tmp/logs.zip')); + + assert.deepStrictEqual([...bytes], [80, 75, 0, 1, 255]); }); test('full stat-then-read round-trip mirrors the diff editor flow', async () => { diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index b4db0e0cf44c46..d23daac7168911 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -21,7 +21,7 @@ import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import { ConfigurationTarget, type IConfigurationValue } from '../../../configuration/common/configuration.js'; import { ContentEncoding, ReconnectResultType } from '../../common/state/protocol/commands.js'; import { ChatSourceKind } from '../../common/state/protocol/channels-chat/commands.js'; -import { AhpErrorCodes } from '../../common/state/protocol/errors.js'; +import { AhpErrorCodes, JsonRpcErrorCodes } from '../../common/state/protocol/errors.js'; import { PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from '../../common/state/protocol/version/registry.js'; import { ActionType, type ChatTurnStartedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionTitleChangedAction } from '../../common/state/sessionActions.js'; import { ProtocolError, type AhpServerNotification, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type ProtocolMessage } from '../../common/state/sessionProtocol.js'; @@ -1288,6 +1288,100 @@ suite('RemoteAgentHostProtocolClient', () => { await assertRemoteProtocolError(resultPromise, { code: AhpErrorCodes.TurnInProgress, message: 'Turn in progress' }); }); + test('collectDebugLogs maps the returned host resource', async () => { + const { client, transport } = createClient(); + const session = URI.parse('copilotcli:/session-1'); + const resultPromise = client.collectDebugLogs(session, 'archive'); + + assert.deepStrictEqual(transport.sentMessages[0], { + jsonrpc: '2.0', + id: 1, + method: 'vscode/collectAgentHostDebugLogs', + params: { session: session.toString(), kind: 'archive' }, + }); + + transport.fireMessage({ + jsonrpc: '2.0', + id: 1, + result: { kind: 'archive', resource: 'file:///tmp/agent-host-debug.zip', providerLogsIncluded: true, size: 1024, uncompressedSize: 2048, entries: [{ path: 'agenthost.log', size: 2048 }] }, + }); + const result = await resultPromise; + assert.deepStrictEqual({ + kind: result.kind, + providerLogsIncluded: result.providerLogsIncluded, + size: result.size, + uncompressedSize: result.uncompressedSize, + scheme: result.resource.scheme, + authority: result.resource.authority, + path: result.resource.path, + entries: result.entries, + }, { + kind: 'archive', + providerLogsIncluded: true, + size: 1024, + uncompressedSize: 2048, + scheme: 'vscode-agent-host', + authority: 'test.example__1234', + path: '/tmp/agent-host-debug.zip', + entries: [{ path: 'agenthost.log', size: 2048 }], + }); + }); + + test('collectDebugLogs accepts an archive that expands beyond the transfer limit', async () => { + const { client, transport } = createClient(); + const resultPromise = client.collectDebugLogs(URI.parse('copilotcli:/session-1'), 'archive'); + const entrySize = 10 * 1024 * 1024; + transport.fireMessage({ + jsonrpc: '2.0', id: 1, + result: { + kind: 'archive', resource: 'file:///tmp/agent-host-debug.zip', providerLogsIncluded: true, + size: 1024, uncompressedSize: entrySize * 2, + entries: [{ path: 'process.log', size: entrySize }, { path: 'events.jsonl', size: entrySize }], + }, + }); + + assert.strictEqual((await resultPromise).uncompressedSize, entrySize * 2); + }); + + test('collectDebugLogs rejects an unsafe or inconsistent artifact manifest', async () => { + const unsafe = createClient(); + const unsafeResult = unsafe.client.collectDebugLogs(URI.parse('copilotcli:/session-1'), 'archive'); + unsafe.transport.fireMessage({ + jsonrpc: '2.0', id: 1, + result: { kind: 'archive', resource: 'file:///tmp/agent-host-debug.zip', providerLogsIncluded: true, size: 10, uncompressedSize: 10, entries: [{ path: '../secret', size: 10 }] }, + }); + + const inconsistent = createClient(); + const inconsistentResult = inconsistent.client.collectDebugLogs(URI.parse('copilotcli:/session-1'), 'archive'); + inconsistent.transport.fireMessage({ + jsonrpc: '2.0', id: 1, + result: { kind: 'archive', resource: 'file:///tmp/agent-host-debug.zip', providerLogsIncluded: true, size: 10, uncompressedSize: 10, entries: [{ path: 'agenthost.log', size: 9 }] }, + }); + + assert.deepStrictEqual({ + unsafe: await unsafeResult.then(() => 'resolved', error => error.message), + inconsistent: await inconsistentResult.then(() => 'resolved', error => error.message), + }, { + unsafe: 'Agent Host returned an invalid debug log artifact manifest entry', + inconsistent: 'Agent Host debug log artifact manifest size does not match its declared size', + }); + }); + + test('collectDebugLogs rejects a non-file host resource', async () => { + const { client, transport } = createClient(); + const resultPromise = client.collectDebugLogs(URI.parse('copilotcli:/session-1'), 'archive'); + transport.fireMessage({ + jsonrpc: '2.0', + id: 1, + result: { kind: 'archive', resource: 'vscode-userdata:/User/settings.json', providerLogsIncluded: true, size: 10, uncompressedSize: 10, entries: [{ path: 'agenthost.log', size: 10 }] }, + }); + + await assertRemoteProtocolError(resultPromise, { + code: JsonRpcErrorCodes.InvalidParams, + message: 'Agent Host returned a non-file debug log resource: vscode-userdata:/User/settings.json', + }); + }); + test('ping sends a JSON-RPC request and resolves on response', async () => { const { client, transport } = createClient(); const resultPromise = client.ping(); diff --git a/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts b/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts new file mode 100644 index 00000000000000..be5f5ae89c32d9 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostDebugLogs.test.ts @@ -0,0 +1,356 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { randomBytes } from 'crypto'; +import { mkdtemp, mkdir, readdir, rm, truncate, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { URI } from '../../../../base/common/uri.js'; +import { join } from '../../../../base/common/path.js'; +import { joinPath } from '../../../../base/common/resources.js'; +import { buffer } from '../../../../base/node/zip.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentHostDebugLogsCollector } from '../../node/agentHostDebugLogs.js'; +import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES } from '../../common/agentService.js'; + +suite('AgentHostDebugLogsCollector', () => { + const emptyProvider = { id: 'test', collectDebugLogs: async () => false }; + + async function waitForEmptyDirectory(path: string): Promise { + for (let i = 0; i < 100; i++) { + if ((await readdir(path)).length === 0) { + return; + } + await new Promise(resolve => setTimeout(resolve, 5)); + } + assert.deepStrictEqual(await readdir(path), []); + } + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let testRoot: string; + + setup(async () => { + testRoot = await mkdtemp(join(tmpdir(), 'agent-host-debug-logs-test-')); + }); + + teardown(async () => { + await rm(testRoot, { recursive: true, force: true }); + }); + + test('creates a flat archive from provider and host logs', async () => { + const logsHome = join(testRoot, 'logs'); + const outputRoot = join(testRoot, 'tmp'); + await mkdir(logsHome, { recursive: true }); + await mkdir(outputRoot, { recursive: true }); + await writeFile(join(logsHome, 'agenthost.log'), 'agent host'); + const collector = disposables.add(new AgentHostDebugLogsCollector({ + logsHome: URI.file(logsHome), + tmpDir: URI.file(outputRoot), + }, new NullLogService())); + + const result = await collector.collect([{ + id: 'test', + collectDebugLogs: async (_session, outputDirectory) => { + await writeFile(join(outputDirectory.fsPath, 'events.jsonl'), 'event'); + return true; + }, + }], URI.parse('test:/session-1'), 'archive'); + + assert.deepStrictEqual({ + kind: result.kind, + providerLogsIncluded: result.providerLogsIncluded, + sizeIsBounded: result.size > 0 && result.uncompressedSize > 0 + && result.size <= AGENT_HOST_DEBUG_LOGS_MAX_BYTES + && result.uncompressedSize <= AGENT_HOST_DEBUG_LOGS_MAX_BYTES, + events: (await buffer(result.resource.fsPath, 'events.jsonl')).toString(), + agentHost: (await buffer(result.resource.fsPath, 'agenthost.log')).toString(), + }, { + kind: 'archive', + providerLogsIncluded: true, + sizeIsBounded: true, + events: 'event', + agentHost: 'agent host', + }); + }); + + test('rejects and cleans an oversized directory artifact', async () => { + const logsHome = join(testRoot, 'logs'); + const outputRoot = join(testRoot, 'tmp'); + await mkdir(logsHome, { recursive: true }); + await mkdir(outputRoot, { recursive: true }); + const collector = disposables.add(new AgentHostDebugLogsCollector({ + logsHome: URI.file(logsHome), + tmpDir: URI.file(outputRoot), + }, new NullLogService())); + + await assert.rejects(collector.collect([{ + id: 'test', + collectDebugLogs: async (_session, outputDirectory) => { + // A directory artifact is copied file-by-file, so its total + // uncompressed size is what must stay bounded. No single file can + // exceed the per-file cap, so it takes several to go over. + for (let i = 0; i < 3; i++) { + const largeLog = join(outputDirectory.fsPath, `large-${i}.log`); + await writeFile(largeLog, ''); + await truncate(largeLog, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES - 1); + } + return true; + }, + }], URI.parse('test:/session-1'), 'directory'), /Agent Host debug logs are too large/); + assert.deepStrictEqual(await readdir(outputRoot), []); + }); + + test('rejects and cleans an artifact with too many files', async () => { + const logsHome = join(testRoot, 'logs'); + const outputRoot = join(testRoot, 'tmp'); + await mkdir(logsHome, { recursive: true }); + await mkdir(outputRoot, { recursive: true }); + const collector = disposables.add(new AgentHostDebugLogsCollector({ + logsHome: URI.file(logsHome), + tmpDir: URI.file(outputRoot), + }, new NullLogService())); + + await assert.rejects(collector.collect([{ + id: 'test', + collectDebugLogs: async (_session, outputDirectory) => { + for (let i = 0; i <= AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES; i++) { + await writeFile(join(outputDirectory.fsPath, `${i}.log`), ''); + } + return true; + }, + }], URI.parse('test:/session-1'), 'archive'), /too many files/); + assert.deepStrictEqual(await readdir(outputRoot), []); + }); + + test('streams an archive artifact in bounded chunks and refuses foreign paths', async () => { + const logsHome = join(testRoot, 'logs'); + const outputRoot = join(testRoot, 'tmp'); + await mkdir(logsHome, { recursive: true }); + await mkdir(outputRoot, { recursive: true }); + const collector = disposables.add(new AgentHostDebugLogsCollector({ + logsHome: URI.file(logsHome), + tmpDir: URI.file(outputRoot), + }, new NullLogService())); + + const artifact = await collector.collect([{ + id: 'test', + collectDebugLogs: async (_session, outputDirectory) => { + // Incompressible, so the resulting archive spans several chunks. + await writeFile(join(outputDirectory.fsPath, 'events.jsonl'), randomBytes(3 * 1024 * 1024)); + return true; + }, + }], URI.parse('test:/session-1'), 'archive'); + + const chunks: number[] = []; + let position = 0; + let eof = false; + while (!eof) { + const chunk = await collector.readArtifactChunk(artifact.resource, position); + chunks.push(chunk.data.byteLength); + position += chunk.data.byteLength; + eof = chunk.eof; + } + + const outsider = join(testRoot, 'outsider.txt'); + await writeFile(outsider, 'secret'); + + assert.deepStrictEqual({ + transferred: position, + matchesDeclaredSize: position === artifact.size, + everyChunkBounded: chunks.every(size => size <= AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES), + chunkCountAboveOne: chunks.length > 1, + foreignRead: await collector.readArtifactChunk(URI.file(outsider), 0).then(() => 'resolved', () => 'rejected'), + negativePosition: await collector.readArtifactChunk(artifact.resource, -1).then(() => 'resolved', () => 'rejected'), + // The artifact URI must survive a protocol round-trip, which is how + // a remote client sends it back (and which normalizes drive casing). + afterUriRoundTrip: (await collector.readArtifactChunk(URI.parse(artifact.resource.toString(), true), 0)).data.byteLength > 0, + }, { + transferred: artifact.size, + matchesDeclaredSize: true, + everyChunkBounded: true, + chunkCountAboveOne: true, + foreignRead: 'rejected', + negativePosition: 'rejected', + afterUriRoundTrip: true, + }); + }); + + test('streams only files enumerated in a retained directory artifact', async () => { + const logsHome = join(testRoot, 'logs'); + const outputRoot = join(testRoot, 'tmp'); + await mkdir(logsHome, { recursive: true }); + await mkdir(outputRoot, { recursive: true }); + const collector = disposables.add(new AgentHostDebugLogsCollector({ + logsHome: URI.file(logsHome), + tmpDir: URI.file(outputRoot), + }, new NullLogService())); + + const artifact = await collector.collect([{ + id: 'test', + collectDebugLogs: async (_session, outputDirectory) => { + await mkdir(join(outputDirectory.fsPath, 'nested')); + await writeFile(join(outputDirectory.fsPath, 'nested', 'debug.log'), 'directory artifact'); + return true; + }, + }], URI.parse('test:/session-1'), 'directory'); + const file = joinPath(artifact.resource, 'nested', 'debug.log'); + const chunk = await collector.readArtifactChunk(file, 0); + const foreignFile = URI.file(join(testRoot, 'foreign.log')); + await writeFile(foreignFile.fsPath, 'foreign'); + + assert.deepStrictEqual({ + data: chunk.data.toString(), + eof: chunk.eof, + rootRead: await collector.readArtifactChunk(artifact.resource, 0).then(() => 'resolved', () => 'rejected'), + foreignRead: await collector.readArtifactChunk(foreignFile, 0).then(() => 'resolved', () => 'rejected'), + }, { + data: 'directory artifact', + eof: true, + rootRead: 'rejected', + foreignRead: 'rejected', + }); + }); + + test('accepts logs that exceed the transfer limit only before compression', async () => { + const logsHome = join(testRoot, 'logs'); + const outputRoot = join(testRoot, 'tmp'); + await mkdir(logsHome, { recursive: true }); + await mkdir(outputRoot, { recursive: true }); + const collector = disposables.add(new AgentHostDebugLogsCollector({ + logsHome: URI.file(logsHome), + tmpDir: URI.file(outputRoot), + }, new NullLogService())); + + const result = await collector.collect([{ + id: 'test', + collectDebugLogs: async (_session, outputDirectory) => { + // Highly compressible, like real log text: together these exceed + // the transfer limit uncompressed while each stays under the + // per-file cap, yet they compress to well under the limit. + for (let i = 0; i < 3; i++) { + await writeFile(join(outputDirectory.fsPath, `big-${i}.log`), Buffer.alloc(AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES - 1024)); + } + return true; + }, + }], URI.parse('test:/session-1'), 'archive'); + + assert.deepStrictEqual({ + uncompressedOverLimit: result.uncompressedSize > AGENT_HOST_DEBUG_LOGS_MAX_BYTES, + archiveUnderLimit: result.size < AGENT_HOST_DEBUG_LOGS_MAX_BYTES, + }, { + uncompressedOverLimit: true, + archiveUnderLimit: true, + }); + }); + + test('keeps the tail of a file that exceeds the per-file cap', async () => { + const logsHome = join(testRoot, 'logs'); + const outputRoot = join(testRoot, 'tmp'); + await mkdir(logsHome, { recursive: true }); + await mkdir(outputRoot, { recursive: true }); + const collector = disposables.add(new AgentHostDebugLogsCollector({ + logsHome: URI.file(logsHome), + tmpDir: URI.file(outputRoot), + }, new NullLogService())); + + const head = Buffer.alloc(AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES, 'A'); + const tail = Buffer.from('THE-INTERESTING-END'); + const artifact = await collector.collect([{ + id: 'test', + collectDebugLogs: async (_session, outputDirectory) => { + await writeFile(join(outputDirectory.fsPath, 'huge.log'), Buffer.concat([head, tail])); + return true; + }, + }], URI.parse('test:/session-1'), 'archive'); + + const kept = await buffer(artifact.resource.fsPath, 'huge.log'); + assert.deepStrictEqual({ + cappedToLimit: kept.length === AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES, + keptTheTail: kept.subarray(kept.length - tail.length).toString(), + }, { + cappedToLimit: true, + keptTheTail: 'THE-INTERESTING-END', + }); + }); + + test('propagates provider collection failures and cleans staging', async () => { + const logsHome = join(testRoot, 'logs'); + const outputRoot = join(testRoot, 'tmp'); + await mkdir(logsHome, { recursive: true }); + await mkdir(outputRoot, { recursive: true }); + const collector = disposables.add(new AgentHostDebugLogsCollector({ + logsHome: URI.file(logsHome), + tmpDir: URI.file(outputRoot), + }, new NullLogService())); + + await assert.rejects(collector.collect([{ + id: 'test', + collectDebugLogs: async () => { throw new Error('SDK collection failed'); }, + }], URI.parse('test:/session-1'), 'archive'), /SDK collection failed/); + assert.deepStrictEqual(await readdir(outputRoot), []); + }); + + test('collects host-wide logs without a session', async () => { + const logsHome = join(testRoot, 'logs'); + const outputRoot = join(testRoot, 'tmp'); + await mkdir(logsHome, { recursive: true }); + await mkdir(outputRoot, { recursive: true }); + await writeFile(join(logsHome, 'agenthost.log'), 'agent host'); + let receivedSession: URI | undefined; + const collector = disposables.add(new AgentHostDebugLogsCollector({ + logsHome: URI.file(logsHome), + tmpDir: URI.file(outputRoot), + }, new NullLogService())); + + const artifact = await collector.collect([{ + id: 'test', + collectDebugLogs: async (session, outputDirectory) => { + receivedSession = session; + await writeFile(join(outputDirectory.fsPath, 'process.log'), 'process'); + return true; + }, + }], undefined, 'archive'); + + assert.deepStrictEqual({ + receivedSession, + agentHost: (await buffer(artifact.resource.fsPath, 'agenthost.log')).toString(), + process: (await buffer(artifact.resource.fsPath, 'process.log')).toString(), + }, { + receivedSession: undefined, + agentHost: 'agent host', + process: 'process', + }); + }); + + test('expires an abandoned artifact', async () => { + const logsHome = join(testRoot, 'logs'); + const outputRoot = join(testRoot, 'tmp'); + await mkdir(logsHome, { recursive: true }); + await mkdir(outputRoot, { recursive: true }); + const collector = disposables.add(new AgentHostDebugLogsCollector({ + logsHome: URI.file(logsHome), + tmpDir: URI.file(outputRoot), + }, new NullLogService(), 5)); + + await collector.collect([emptyProvider], URI.parse('test:/session-1'), 'directory'); + await waitForEmptyDirectory(outputRoot); + }); + + test('removes retained artifacts when disposed', async () => { + const logsHome = join(testRoot, 'logs'); + const outputRoot = join(testRoot, 'tmp'); + await mkdir(logsHome, { recursive: true }); + await mkdir(outputRoot, { recursive: true }); + const collector = disposables.add(new AgentHostDebugLogsCollector({ + logsHome: URI.file(logsHome), + tmpDir: URI.file(outputRoot), + }, new NullLogService())); + + await collector.collect([emptyProvider], URI.parse('test:/session-1'), 'directory'); + collector.dispose(); + await waitForEmptyDirectory(outputRoot); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index e81ca4c45917a3..4e4fa33ff152b6 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -55,7 +55,7 @@ import { buildGitBlobUri } from '../../node/gitDiffContent.js'; import { buildBranchChangesetUri, buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; import { getWorktreesRoot, WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT } from '../../node/shared/worktreeIsolation.js'; -import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../../common/state/sessionProtocol.js'; +import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../../common/state/sessionProtocol.js'; import type { INetworkDiagnosticsService } from '../../node/networkDiagnosticsService.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; @@ -1176,6 +1176,17 @@ suite('AgentService (node dispatcher)', () => { suite('resourceRead', () => { + test('returns binary resources as Base64 when requested', async () => { + const uri = URI.from({ scheme: Schemas.inMemory, path: '/logs.zip' }); + await fileService.writeFile(uri, VSBuffer.wrap(Uint8Array.from([80, 75, 0, 1, 255]))); + + assert.deepStrictEqual(await service.resourceRead(uri, ContentEncoding.Base64), { + data: 'UEsAAf8=', + encoding: ContentEncoding.Base64, + contentType: 'application/octet-stream', + }); + }); + test('maps missing files to NotFound', async () => { const uri = URI.from({ scheme: Schemas.inMemory, path: '/missing.txt' }); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 2d2bc1ee1840e7..86a916836be07e 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -145,6 +145,7 @@ class MockAgentService implements IAgentService { readonly listedSessions: IAgentSessionMetadata[] = []; readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = []; managedSettingsDiagnostics: readonly IAgentHostManagedSettingsDiagnostics[] = []; + readonly collectDebugLogsCalls: { session: string | undefined; kind: 'archive' | 'directory' }[] = []; shutdownCalls = 0; createSessionBarrier: DeferredPromise | undefined; subscribeBarrier: DeferredPromise | undefined; @@ -217,6 +218,10 @@ class MockAgentService implements IAgentService { async getNetworkDiagnosticsInfo(): Promise { return { version: 'test', os: 'test', arch: 'test', proxySettings: {}, proxyEnv: {}, endpoints: [] }; } async getManagedSettingsDiagnostics(): Promise { return this.managedSettingsDiagnostics; } async diagnosticsFetch(url: string): Promise { return { url }; } + async collectDebugLogs(session: URI | undefined, kind: 'archive' | 'directory') { + this.collectDebugLogsCalls.push({ session: session?.toString(), kind }); + return { kind, resource: URI.file('/tmp/agent-host-debug.zip'), providerLogsIncluded: true, size: 1024, uncompressedSize: 2048, entries: [{ path: 'agenthost.log', size: 2048 }] }; + } async authenticate(_params: AuthenticateParams): Promise { return { authenticated: true }; } getAuthToken(): string | undefined { return undefined; } async resourceWrite(_params: ResourceWriteParams): Promise { return {}; } @@ -616,6 +621,105 @@ suite('ProtocolServerHandler', () => { }); }); + test('collects Agent Host debug logs through the extension request', async () => { + const transport = connectClient('client-debug-logs'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 12); + + transport.simulateMessage(request(12, 'vscode/collectAgentHostDebugLogs', { + session: 'copilotcli:/session-1', + kind: 'archive', + })); + + assert.deepStrictEqual({ + response: await responsePromise, + calls: agentService.collectDebugLogsCalls, + }, { + response: { + jsonrpc: '2.0', + id: 12, + result: { kind: 'archive', resource: 'file:///tmp/agent-host-debug.zip', providerLogsIncluded: true, size: 1024, uncompressedSize: 2048, entries: [{ path: 'agenthost.log', size: 2048 }] }, + }, + calls: [{ session: 'copilotcli:/session-1', kind: 'archive' }], + }); + }); + + test('rejects an invalid Agent Host debug log artifact kind', async () => { + const transport = connectClient('client-debug-logs-invalid'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 13); + + transport.simulateMessage(request(13, 'vscode/collectAgentHostDebugLogs', { session: 'copilotcli:/session-1', kind: 'tgz' })); + + assert.deepStrictEqual(await responsePromise, { + jsonrpc: '2.0', + id: 13, + error: { code: JsonRpcErrorCodes.InvalidParams, message: 'kind must be archive or directory' }, + }); + }); + + test('collects Agent Host debug logs without a session', async () => { + const transport = connectClient('client-debug-logs-no-session'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 16); + + transport.simulateMessage(request(16, 'vscode/collectAgentHostDebugLogs', { kind: 'archive' })); + + assert.deepStrictEqual({ + response: await responsePromise, + calls: agentService.collectDebugLogsCalls.at(-1), + }, { + response: { + jsonrpc: '2.0', + id: 16, + result: { kind: 'archive', resource: 'file:///tmp/agent-host-debug.zip', providerLogsIncluded: true, size: 1024, uncompressedSize: 2048, entries: [{ path: 'agenthost.log', size: 2048 }] }, + }, + calls: { session: undefined, kind: 'archive' }, + }); + }); + + test('rejects a non-string Agent Host debug log session', async () => { + const transport = connectClient('client-debug-logs-invalid-session'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 14); + + transport.simulateMessage(request(14, 'vscode/collectAgentHostDebugLogs', { session: 123, kind: 'archive' })); + + assert.deepStrictEqual(await responsePromise, { + jsonrpc: '2.0', + id: 14, + error: { code: JsonRpcErrorCodes.InvalidParams, message: 'session must be a URI string' }, + }); + }); + + test('rejects non-object Agent Host debug log params', async () => { + const transport = connectClient('client-debug-logs-invalid-params'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 15); + + transport.simulateMessage(request(15, 'vscode/collectAgentHostDebugLogs', [])); + + assert.deepStrictEqual(await responsePromise, { + jsonrpc: '2.0', + id: 15, + error: { code: JsonRpcErrorCodes.InvalidParams, message: 'params must be an object' }, + }); + }); + + test('rejects a scheme-less Agent Host debug log session', async () => { + const transport = connectClient('client-debug-logs-invalid-session-uri'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 16); + + transport.simulateMessage(request(16, 'vscode/collectAgentHostDebugLogs', { session: 'session-1', kind: 'archive' })); + + assert.deepStrictEqual(await responsePromise, { + jsonrpc: '2.0', + id: 16, + error: { code: JsonRpcErrorCodes.InvalidParams, message: 'session must be a valid URI string' }, + }); + }); + test('extension methods can be disabled without blocking managed settings contributions', () => { const localDisposables = disposables.add(new DisposableStore()); const localServer = localDisposables.add(new MockProtocolServer()); diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index dff9b52d79b030..440fba137a1fe4 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -38,7 +38,8 @@ export interface IToastResult { */ export type INativeZipFile = | { readonly path: string; readonly contents: string } - | { readonly path: string; readonly source: URI; readonly size: number }; + | { readonly path: string; readonly source: URI; readonly size: number } + | { readonly sourceArchive: URI }; export interface IOpenAgentsWindowOptions { readonly folderUri?: UriComponents; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index f09c80af27d1c8..8a8ab3fb420832 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -44,16 +44,18 @@ import { IV8Profile } from '../../profiling/common/profiling.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; import { CancellationError } from '../../../base/common/errors.js'; -import { zip } from '../../../base/node/zip.js'; +import { extract, validateZip, zip, type IFile } from '../../../base/node/zip.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IProxyAuthService } from './auth.js'; import { AuthInfo, Credentials, IRequestService } from '../../request/common/request.js'; import { randomPath } from '../../../base/common/extpath.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; +import { AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES } from '../../agentHost/common/agentService.js'; export interface INativeHostMainService extends AddFirstParameterToFunctions /* only methods, not events */, number | undefined /* window ID */> { } export const INativeHostMainService = createDecorator('nativeHostMainService'); +const MAX_MERGED_ZIP_SIZE = 16 * 1024 * 1024; export class NativeHostMainService extends Disposable implements INativeHostMainService { @@ -1423,16 +1425,51 @@ export class NativeHostMainService extends Disposable implements INativeHostMain //#region Zip async createZipFile(windowId: number | undefined, zipPath: URI, files: INativeZipFile[]): Promise { - await zip(zipPath.fsPath, files.map(file => { - if (hasKey(file, { contents: true })) { - return file; + const zipFiles: IFile[] = []; + const temporaryDirectories: string[] = []; + try { + for (const file of files) { + if (hasKey(file, { contents: true })) { + zipFiles.push(file); + continue; + } + if (hasKey(file, { sourceArchive: true })) { + const sourceArchive = URI.revive(file.sourceArchive); + if (sourceArchive.scheme !== Schemas.file) { + throw new Error(`Cannot merge non-local archive '${sourceArchive.toString()}'`); + } + const temporaryDirectory = join(this.environmentMainService.tmpDir.fsPath, `vscode-zip-merge-${randomPath()}`); + temporaryDirectories.push(temporaryDirectory); + const archiveSize = (await fs.promises.stat(sourceArchive.fsPath)).size; + if (archiveSize > MAX_MERGED_ZIP_SIZE) { + throw new Error(`ZIP is too large to merge (${archiveSize} bytes; limit ${MAX_MERGED_ZIP_SIZE} bytes)`); + } + await validateZip(sourceArchive.fsPath, { + maxEntries: AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, + maxUncompressedSize: AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES, + }); + await extract(sourceArchive.fsPath, temporaryDirectory, {}, CancellationToken.None); + zipFiles.push(...await collectZipFiles(temporaryDirectory)); + continue; + } + const source = URI.revive(file.source); + if (source.scheme !== Schemas.file) { + throw new Error(`Cannot add non-local resource '${source.toString()}' to a zip file`); + } + zipFiles.push({ path: file.path, localPath: source.fsPath, localPathSize: file.size }); } - const source = URI.revive(file.source); - if (source.scheme !== Schemas.file) { - throw new Error(`Cannot add non-local resource '${source.toString()}' to a zip file`); + + const paths = new Set(); + for (const file of zipFiles) { + if (paths.has(file.path)) { + throw new Error(`Duplicate ZIP entry '${file.path}'`); + } + paths.add(file.path); } - return { path: file.path, localPath: source.fsPath, localPathSize: file.size }; - })); + await zip(zipPath.fsPath, zipFiles); + } finally { + await Promise.all(temporaryDirectories.map(directory => Promises.rm(directory))); + } } //#endregion @@ -1495,3 +1532,17 @@ export class NativeHostMainService extends Disposable implements INativeHostMain return this.auxiliaryWindowsMainService.getWindowByWebContents(contents); } } + +async function collectZipFiles(root: string, relative = ''): Promise { + const entries = await fs.promises.readdir(join(root, relative), { withFileTypes: true }); + const files: IFile[] = []; + for (const entry of entries) { + const path = relative ? posix.join(relative, entry.name) : entry.name; + if (entry.isDirectory()) { + files.push(...await collectZipFiles(root, path)); + } else if (entry.isFile()) { + files.push({ path, localPath: join(root, path) }); + } + } + return files; +} diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts b/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts index 3d5efaadfeb93d..8f760699788e0e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts @@ -13,7 +13,6 @@ import { IsSessionsWindowContext } from '../../../../../workbench/common/context import { exportAgentHostDebugLogs, IActiveAgentHostSessionForExport } from '../../../../../workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { type ISession } from '../../../../services/sessions/common/session.js'; -import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.js'; @@ -36,19 +35,16 @@ export class ExportAgentHostDebugLogsAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - const sessionsManagementService = accessor.get(ISessionsManagementService); const sessionsService = accessor.get(ISessionsService); const sessionsProvidersService = accessor.get(ISessionsProvidersService); const activeSession = sessionsService.activeSession.get(); const activeAgentHostSession = isAgentHostSession(activeSession, sessionsProvidersService) ? activeSession : undefined; - const sessionForEvents = activeAgentHostSession ?? getMostRecentAgentHostSession(sessionsManagementService.getSessions(), sessionsProvidersService); - - const activeSessionContext: IActiveAgentHostSessionForExport | undefined = sessionForEvents + const activeSessionContext: IActiveAgentHostSessionForExport | undefined = activeAgentHostSession ? { - resource: sessionForEvents.resource, - title: activeAgentHostSession?.title.get(), - isLocal: sessionForEvents.resource.scheme.startsWith('agent-host-'), + resource: activeAgentHostSession.resource, + title: activeAgentHostSession.title.get(), + isLocal: activeAgentHostSession.resource.scheme.startsWith('agent-host-'), } : undefined; @@ -60,17 +56,4 @@ function isAgentHostSession(session: ISession | undefined, sessionsProvidersServ return !!session && sessionsProvidersService.getProvider(session.providerId) instanceof BaseAgentHostSessionsProvider; } -function getMostRecentAgentHostSession(sessions: readonly ISession[], sessionsProvidersService: ISessionsProvidersService): ISession | undefined { - let mostRecent: ISession | undefined; - for (const session of sessions) { - if (!isAgentHostSession(session, sessionsProvidersService)) { - continue; - } - if (!mostRecent || session.updatedAt.get().getTime() > mostRecent.updatedAt.get().getTime()) { - mostRecent = session; - } - } - return mostRecent; -} - registerAction2(ExportAgentHostDebugLogsAction); diff --git a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts index f964396552dbee..a174dbfda94fbc 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSBuffer, streamToBuffer } from '../../../../../base/common/buffer.js'; +import { VSBuffer, newWriteableBufferStream, streamToBuffer, type VSBufferReadableStream } from '../../../../../base/common/buffer.js'; import { Schemas } from '../../../../../base/common/network.js'; import { joinPath } from '../../../../../base/common/resources.js'; import { hasKey } from '../../../../../base/common/types.js'; @@ -11,10 +11,10 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { Action2 } from '../../../../../platform/actions/common/actions.js'; -import { agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; -import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; -import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, remoteAgentHostLogOutputChannelId, AGENT_HOST_LOG_OUTPUT_CHANNEL_ID } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IAgentHostService, type AgentHostDebugLogsArtifactKind, type IAgentConnection, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../../../../../platform/agentHost/common/agentService.js'; +import { IRemoteAgentHostService, remoteAgentHostLogOutputChannelId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IsWebContext } from '../../../../../platform/contextkey/common/contextkeys.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; @@ -23,24 +23,19 @@ import { IFileService } from '../../../../../platform/files/common/files.js'; import { createDecorator, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { IOutputService } from '../../../../services/output/common/output.js'; -import { IPathService } from '../../../../services/path/common/pathService.js'; import { IChatWidgetService } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { buildLocalCopilotLogsUri, buildRemoteCopilotLogsUri, COPILOT_CLI_LOCAL_AH_SCHEME, getCopilotCliSessionRawId, parseRemoteAuthorityFromScheme, resolveEventsUri } from '../copilotCliEventsUri.js'; -import { findRelevantCopilotLogs, getRemoteConnectionForSession, readRemoteAgentHostLog, sanitizeFilePart } from '../chatDebug/agentHostLogSources.js'; +import { COPILOT_CLI_LOCAL_AH_SCHEME, getCopilotCliSessionRawId, parseRemoteAuthorityFromScheme } from '../copilotCliEventsUri.js'; +import { getRemoteConnectionForSession, sanitizeFilePart } from '../chatDebug/agentHostLogSources.js'; import { buildAgentHostCustomizationsUri, buildAgentHostUsageUri } from '../chatDebug/agentHostUsageSidecar.js'; -/** Output channel ID for the agent host process logger (forwarded via RemoteLoggerChannelClient). */ -const AGENT_HOST_LOGGER_CHANNEL_ID = AGENT_HOST_LOG_OUTPUT_CHANNEL_ID; /** Output channel ID for the current window's renderer log. */ const WINDOW_LOG_CHANNEL_ID = 'rendererLog'; /** Output channel ID for the shared process compound log. */ const SHARED_PROCESS_LOG_CHANNEL_ID = 'shared'; -const MAX_REMOTE_COPILOT_LOG_EXPORT_SIZE = 10 * 1024 * 1024; /** * Description of the agent-host session whose logs should be exported. If @@ -63,33 +58,88 @@ export type IAgentHostDebugLogFile = export interface IAgentHostDebugLogsExport { readonly files: IAgentHostDebugLogFile[]; readonly exportName: string; + readonly hostArtifact: IAgentHostDebugLogsHostArtifact; +} + +/** + * A debug-log artifact produced by an agent host, paired with the means to read + * its bytes. For a remote host the artifact lives on the remote disk, so + * {@link readChunk} streams it over AHP in bounded slices instead of + * materializing the whole archive in one protocol message. + */ +export interface IAgentHostDebugLogsHostArtifact { + readonly artifact: IAgentHostDebugLogsArtifact; + readonly readChunk: (resource: URI, position: number) => Promise; } export const IAgentHostDebugLogsExportService = createDecorator('agentHostDebugLogsExportService'); export interface IAgentHostDebugLogsExportService { readonly _serviceBrand: undefined; - save(exportName: string, files: readonly IAgentHostDebugLogFile[]): Promise; + readonly hostArtifactKind: AgentHostDebugLogsArtifactKind; + save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact): Promise; } export class BrowserAgentHostDebugLogsExportService implements IAgentHostDebugLogsExportService { declare readonly _serviceBrand: undefined; + readonly hostArtifactKind = 'directory'; constructor( @IFileDialogService private readonly fileDialogService: IFileDialogService, @IFileService private readonly fileService: IFileService, ) { } - async save(exportName: string, files: readonly IAgentHostDebugLogFile[]): Promise { - return exportFilesToLocalFolder(this.fileDialogService, this.fileService, exportName, files); + async save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact): Promise { + return exportFilesToLocalFolder(this.fileDialogService, this.fileService, exportName, files, hostArtifact); } } +/** + * Streams a host-owned artifact by repeatedly calling `readChunk`. The stream + * fails if the host overruns or underruns the size it declared, so a + * truncated or runaway transfer can never be silently zipped up. + */ +export function createHostArtifactStream( + artifact: IAgentHostDebugLogsArtifact, + readChunk: (position: number) => Promise, +): VSBufferReadableStream { + const stream = newWriteableBufferStream(); + (async () => { + let position = 0; + while (true) { + const chunk = await readChunk(position); + const byteLength = chunk.data.byteLength; + if (byteLength > 0) { + position += byteLength; + if (position > artifact.size) { + throw new Error(`Agent Host debug log artifact exceeded its declared size of ${artifact.size} bytes`); + } + await stream.write(chunk.data); + } + if (chunk.eof) { + break; + } + if (byteLength === 0) { + throw new Error('Agent Host returned an empty debug log chunk before the end of the artifact'); + } + } + if (position !== artifact.size) { + throw new Error(`Agent Host debug log artifact ended after ${position} bytes, expected ${artifact.size}`); + } + stream.end(); + })().catch(error => { + stream.error(error instanceof Error ? error : new Error(String(error))); + stream.end(); + }); + return stream; +} + /** * Shared implementation of "Export Agent Host Debug Logs". Collects the - * Copilot CLI session events file (if available), the window/shared/local - * agent-host output channel logs, remote forwarded logs, and the AHP - * transport JSONL logs. + * Agent Host's own debug-log bundle (collected and packaged by the host), plus + * the logs this side owns: the window/shared-process output channels, remote + * forwarded logs, the AHP transport JSONL logs, and the client-local capture + * sidecars. * * Both the workbench-side action (resolves the active session via * `IChatWidgetService`) and the sessions-app-side action (resolves it via @@ -98,49 +148,55 @@ export class BrowserAgentHostDebugLogsExportService implements IAgentHostDebugLo export async function collectAgentHostDebugLogs( accessor: ServicesAccessor, activeSession: IActiveAgentHostSessionForExport | undefined, -): Promise { - const pathService = accessor.get(IPathService); + onDidCreateHostArtifact: (artifact: IAgentHostDebugLogsArtifact) => void, +): Promise { const agentHostService = accessor.get(IAgentHostService); + const agentHostConnectionsService = accessor.get(IAgentHostConnectionsService); const remoteAgentHostService = accessor.get(IRemoteAgentHostService); const outputService = accessor.get(IOutputService); const fileService = accessor.get(IFileService); - const notificationService = accessor.get(INotificationService); const textModelService = accessor.get(ITextModelService); - const productService = accessor.get(IProductService); const logService = accessor.get(ILogService); const environmentService = accessor.get(IEnvironmentService); + const exportService = accessor.get(IAgentHostDebugLogsExportService); - const userHome = pathService.userHome({ preferLocal: true }); - - const eventsResult = resolveEventsUri( - activeSession?.resource, - userHome, - authority => remoteAgentHostService.connections.find(c => agentHostAuthority(c.address) === authority), - ); + let connection: IAgentConnection; + let backendSession: URI | undefined; + if (activeSession) { + const sessionResolution = agentHostConnectionsService.resolveSessionResource(activeSession.resource); + if (!sessionResolution) { + throw new Error(`No live Agent Host connection owns session ${activeSession.resource.toString()}`); + } + connection = sessionResolution.connection; + backendSession = sessionResolution.backendSession; + } else { + connection = agentHostConnectionsService.ambientConnection; + } + // The Agent Host owns discovery and packaging of its own logs; failures + // surface to the user rather than being papered over by a second, + // path-guessing implementation on this side. + const hostArtifact = await connection.collectDebugLogs(backendSession, exportService.hostArtifactKind); + onDidCreateHostArtifact(hostArtifact); // Collect all output channel IDs relevant for the current session's agent host. const channelIds = new Set(); - // Remote agent host connection (if any), for downloading agenthost.log from the remote. - let remoteConnection: IRemoteAgentHostConnectionInfo | undefined; let ahpLogNameFilter: ((name: string) => boolean) | undefined; - if (activeSession) { if (activeSession.isLocal) { - // Agent host process logger (forwarded from the utility process) - channelIds.add(AGENT_HOST_LOGGER_CHANNEL_ID); const localClientId = sanitizeFilePart(agentHostService.clientId); ahpLogNameFilter = name => name.includes(localClientId); } else { - remoteConnection = getRemoteConnectionForSession(activeSession.resource, remoteAgentHostService.connections); + const remoteConnection = getRemoteConnectionForSession(activeSession.resource, remoteAgentHostService.connections); if (remoteConnection) { channelIds.add(remoteAgentHostLogOutputChannelId(remoteConnection.address)); + const remoteConnectionId = sanitizeFilePart(remoteConnection.address); + ahpLogNameFilter = name => name.includes(remoteConnectionId); } } } else { - channelIds.add(AGENT_HOST_LOGGER_CHANNEL_ID); - for (const connection of remoteAgentHostService.connections) { - channelIds.add(remoteAgentHostLogOutputChannelId(connection.address)); + for (const remoteConnection of remoteAgentHostService.connections) { + channelIds.add(remoteAgentHostLogOutputChannelId(remoteConnection.address)); } } @@ -150,16 +206,7 @@ export async function collectAgentHostDebugLogs( const files: IAgentHostDebugLogFile[] = []; - // 1. events.jsonl - if (eventsResult.kind === 'ok') { - try { - files.push(await createDebugLogFile('events.jsonl', eventsResult.resource, fileService)); - } catch { - // File may not exist yet if the session never wrote any events - } - } - - // 2. Output channels + // 1. Output channels for (const channelId of channelIds) { const channel = outputService.getChannel(channelId); const descriptor = outputService.getChannelDescriptor(channelId); @@ -175,7 +222,7 @@ export async function collectAgentHostDebugLogs( } } - // 3. AHP transport JSONL logs (one file per remote connection, written under /ahp/). + // 2. AHP transport JSONL logs (one file per remote connection, written under /ahp/). // These replace the per-connection `agenthost.` IPC traffic output channel. try { const ahpDir = joinPath(environmentService.logsHome, 'ahp'); @@ -194,41 +241,9 @@ export async function collectAgentHostDebugLogs( // AHP log directory may not exist if no remote connection has been opened or if logging is disabled. } - // 4. For remote agent hosts, also download the agenthost.log file directly from - // the remote machine. The CLI launches the server with its default data dir, - // which lives at `//data/logs//agenthost.log`. - if (remoteConnection?.defaultDirectory) { - try { - const remoteLog = await readRemoteAgentHostLog(remoteConnection, productService.serverDataFolderName, fileService); - if (remoteLog) { - files.push({ path: 'remote-agenthost.log', contents: remoteLog }); - } - } catch (error) { - logService.warn(`[ExportAgentHostDebugLogs] Failed to download remote agenthost.log: ${error instanceof Error ? error.message : String(error)}`); - } - } - - // 5. Copilot SDK process logs under /logs. const rawSessionId = getCopilotCliSessionRawId(activeSession?.resource); - const copilotLogsDir = activeSession - ? rawSessionId - ? activeSession.isLocal - ? buildLocalCopilotLogsUri(userHome) - : remoteConnection ? buildRemoteCopilotLogsUri(remoteConnection) : undefined - : undefined - : buildLocalCopilotLogsUri(userHome); - if (copilotLogsDir) { - const copilotLogFiles = await findRelevantCopilotLogs(copilotLogsDir, rawSessionId, fileService, logService); - for (const file of copilotLogFiles) { - try { - files.push(await createDebugLogFile(file.path, file.resource, fileService, file.size, MAX_REMOTE_COPILOT_LOG_EXPORT_SIZE)); - } catch (error) { - logService.warn(`[ExportAgentHostDebugLogs] Failed to read Copilot log '${file.path}': ${error instanceof Error ? error.message : String(error)}`); - } - } - } - // 6. Client-local capture sidecars for the session. These hold data the SDK + // 3. Client-local capture sidecars for the session. These hold data the SDK // never persists — per-model-call token/credit usage (`assistant.usage` is // ephemeral) and the loaded customization set (`session.*_loaded` likewise) — // so without them an export cannot explain a usage/cost discrepancy or say @@ -247,20 +262,19 @@ export async function collectAgentHostDebugLogs( } } - if (files.length === 0) { - notificationService.notify({ - severity: Severity.Warning, - message: activeSession - ? localize('exportDebugLogs.noFiles.activeSession', "No log files were found for the active Agent Host session.") - : localize('exportDebugLogs.noFiles.currentWindow', "No Agent Host log files were found for the current window."), - }); - return undefined; - } - const titleSlug = activeSession?.title ? `-${activeSession.title.replace(/[/\\:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)}` : ''; - return { files, exportName: `ah-logs${titleSlug}` }; + return { + files, + exportName: `ah-logs${titleSlug}`, + hostArtifact: { artifact: hostArtifact, readChunk: createChunkReader(connection) }, + }; +} + +/** Binds a connection's chunked artifact read to one artifact. */ +function createChunkReader(connection: IAgentConnection): (resource: URI, position: number) => Promise { + return (resource, position) => connection.readDebugLogsChunk(resource, position); } export async function exportAgentHostDebugLogs( @@ -270,22 +284,37 @@ export async function exportAgentHostDebugLogs( const exportService = accessor.get(IAgentHostDebugLogsExportService); const notificationService = accessor.get(INotificationService); const chatEntitlementService = accessor.get(IChatEntitlementService); - const logs = await collectAgentHostDebugLogs(accessor, activeSession); - if (!logs) { - return; - } + const fileService = accessor.get(IFileService); + const logService = accessor.get(ILogService); + let hostArtifact: IAgentHostDebugLogsArtifact | undefined; try { - const saved = await exportService.save(logs.exportName, logs.files); - if (saved) { - notificationService.warn(chatEntitlementService.isInternal - ? localize('exportDebugLogs.privacyWarning.internal', "Note: This log may contain personal information such as auth tokens, file contents, or terminal output. It MUST be shared privately via Slack or in an issue filed on the microsoft/vscode-internalbacklog repo.") - : localize('exportDebugLogs.privacyWarning', "Note: This log may contain personal information such as auth tokens, file contents, or terminal output. Please consider sharing privately or reviewing the contents carefully before sharing.")); + const logs = await collectAgentHostDebugLogs(accessor, activeSession, artifact => hostArtifact = artifact); + try { + const saved = await exportService.save(logs.exportName, logs.files, logs.hostArtifact); + if (saved) { + notificationService.warn(chatEntitlementService.isInternal + ? localize('exportDebugLogs.privacyWarning.internal', "Note: This log may contain personal information such as auth tokens, file contents, or terminal output. It MUST be shared privately via Slack or in an issue filed on the microsoft/vscode-internalbacklog repo.") + : localize('exportDebugLogs.privacyWarning', "Note: This log may contain personal information such as auth tokens, file contents, or terminal output. Please consider sharing privately or reviewing the contents carefully before sharing.")); + } + } catch (error) { + notificationService.notify({ + severity: Severity.Error, + message: localize('exportDebugLogs.saveError', "Failed to save debug logs: {0}", error instanceof Error ? error.message : String(error)), + }); } } catch (error) { notificationService.notify({ severity: Severity.Error, - message: localize('exportDebugLogs.saveError', "Failed to save debug logs: {0}", error instanceof Error ? error.message : String(error)), + message: localize('exportDebugLogs.collectError', "Failed to collect debug logs: {0}", error instanceof Error ? error.message : String(error)), }); + } finally { + if (hostArtifact) { + try { + await fileService.del(hostArtifact.resource, { recursive: hostArtifact.kind === 'directory' }); + } catch (error) { + logService.warn(`[ExportAgentHostDebugLogs] Failed to delete temporary Agent Host log artifact: ${error instanceof Error ? error.message : String(error)}`); + } + } } } @@ -342,6 +371,7 @@ async function exportFilesToLocalFolder( fileService: IFileService, exportName: string, files: readonly IAgentHostDebugLogFile[], + hostArtifact: IAgentHostDebugLogsHostArtifact, ): Promise { const folders = await fileDialogService.showOpenDialog({ title: localize('exportDebugLogs.folderDialogTitle', "Select Folder for Agent Host Debug Logs"), @@ -358,6 +388,10 @@ async function exportFilesToLocalFolder( const exportFolder = joinPath(parentFolder, exportName); await fileService.createFolder(exportFolder); + if (hostArtifact.artifact.kind !== 'directory') { + throw new Error(`Expected an Agent Host debug-log directory, got ${hostArtifact.artifact.kind}`); + } + await copyHostArtifactDirectory(exportFolder, hostArtifact, fileService); for (const file of files) { const segments = toSafeRelativePathSegments(file.path); if (segments.length === 0) { @@ -380,6 +414,45 @@ async function exportFilesToLocalFolder( return true; } +async function copyHostArtifactDirectory( + target: URI, + hostArtifact: IAgentHostDebugLogsHostArtifact, + fileService: IFileService, +): Promise { + let copiedSize = 0; + for (const entry of hostArtifact.artifact.entries) { + copiedSize += entry.size; + if (copiedSize > hostArtifact.artifact.uncompressedSize) { + throw new Error(`Agent Host debug-log directory exceeded its declared size of ${hostArtifact.artifact.uncompressedSize} bytes`); + } + + const source = joinPath(hostArtifact.artifact.resource, ...entry.path.split('/')); + const segments = toSafeRelativePathSegments(entry.path); + if (segments.length === 0) { + throw new Error(`Agent Host returned an invalid debug-log artifact path: ${entry.path}`); + } + let targetFolder = target; + for (const segment of segments.slice(0, -1)) { + targetFolder = joinPath(targetFolder, segment); + await fileService.createFolder(targetFolder); + } + const entryTarget = joinPath(targetFolder, segments[segments.length - 1]); + if (source.scheme === Schemas.file) { + const sourceStat = await fileService.resolve(source, { resolveMetadata: true }); + if (!sourceStat.isFile || sourceStat.isSymbolicLink || sourceStat.size !== entry.size) { + throw new Error(`Agent Host debug-log file no longer matches its manifest: ${entry.path}`); + } + await fileService.copy(source, entryTarget, true); + continue; + } + const artifact = { ...hostArtifact.artifact, resource: source, size: entry.size, uncompressedSize: entry.size }; + await fileService.writeFile(entryTarget, createHostArtifactStream(artifact, position => hostArtifact.readChunk(source, position))); + } + if (copiedSize !== hostArtifact.artifact.uncompressedSize) { + throw new Error(`Agent Host debug-log directory manifest accounts for ${copiedSize} bytes, expected ${hostArtifact.artifact.uncompressedSize}`); + } +} + async function createDebugLogFile(path: string, resource: URI, fileService: IFileService, size?: number, maxInlineSize?: number): Promise { if (resource.scheme === Schemas.file) { const observedSize = size ?? (await fileService.resolve(resource, { resolveMetadata: true })).size; diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts index 8e8bfbe1c50e11..113ace11beb9c4 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/exportAgentHostDebugLogsService.ts @@ -6,21 +6,30 @@ import { Schemas } from '../../../../../base/common/network.js'; import { joinPath } from '../../../../../base/common/resources.js'; import { hasKey } from '../../../../../base/common/types.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { INativeEnvironmentService } from '../../../../../platform/environment/common/environment.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; -import { INativeHostService } from '../../../../../platform/native/common/native.js'; -import { IAgentHostDebugLogFile, IAgentHostDebugLogsExportService } from '../../browser/actions/exportAgentHostDebugLogsAction.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { INativeHostService, type INativeZipFile } from '../../../../../platform/native/common/native.js'; +import { createHostArtifactStream, IAgentHostDebugLogFile, IAgentHostDebugLogsExportService, type IAgentHostDebugLogsHostArtifact } from '../../browser/actions/exportAgentHostDebugLogsAction.js'; class NativeAgentHostDebugLogsExportService implements IAgentHostDebugLogsExportService { declare readonly _serviceBrand: undefined; + readonly hostArtifactKind = 'archive'; constructor( @IFileDialogService private readonly fileDialogService: IFileDialogService, + @IFileService private readonly fileService: IFileService, + @INativeEnvironmentService private readonly environmentService: INativeEnvironmentService, @INativeHostService private readonly nativeHostService: INativeHostService, + @ILogService private readonly logService: ILogService, ) { } - async save(exportName: string, files: readonly IAgentHostDebugLogFile[]): Promise { + async save(exportName: string, files: readonly IAgentHostDebugLogFile[], hostArtifact: IAgentHostDebugLogsHostArtifact): Promise { const defaultUri = joinPath(await this.fileDialogService.preferredHome(Schemas.file), `${exportName}.zip`); const saveUri = await this.fileDialogService.showSaveDialog({ title: localize('exportDebugLogs.saveDialogTitle', "Export Agent Host Debug Logs"), @@ -33,11 +42,39 @@ class NativeAgentHostDebugLogsExportService implements IAgentHostDebugLogsExport return false; } - await this.nativeHostService.createZipFile(saveUri, files.map(file => { + const zipFiles: INativeZipFile[] = files.map(file => { return hasKey(file, { contents: true }) ? file : { path: file.path, source: file.resource, size: file.size }; - })); + }); + let temporaryHostArchive: URI | undefined; + try { + const { artifact, readChunk } = hostArtifact; + if (artifact.kind !== 'archive') { + throw new Error(`Expected an Agent Host debug-log archive, got ${artifact.kind}`); + } + let localHostArchive = artifact.resource; + if (artifact.resource.scheme !== Schemas.file) { + // The archive lives on a remote agent host. Stream it down in + // bounded chunks rather than pulling the whole thing over in a + // single protocol message. + localHostArchive = joinPath(this.environmentService.tmpDir, `agent-host-debug-logs-${generateUuid()}.zip`); + temporaryHostArchive = localHostArchive; + await this.fileService.writeFile(localHostArchive, createHostArtifactStream(artifact, position => readChunk(artifact.resource, position))); + } + zipFiles.push({ sourceArchive: localHostArchive }); + await this.nativeHostService.createZipFile(saveUri, zipFiles); + } finally { + if (temporaryHostArchive) { + // Best-effort: the download may have failed before the file was + // created, and a cleanup failure must never mask that error. + try { + await this.fileService.del(temporaryHostArchive); + } catch (error) { + this.logService.warn(`[ExportAgentHostDebugLogs] Failed to remove temporary host archive: ${error instanceof Error ? error.message : String(error)}`); + } + } + } return true; } } diff --git a/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts b/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts new file mode 100644 index 00000000000000..b3043126bf09cd --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/exportAgentHostDebugLogs.test.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer, streamToBuffer } from '../../../../../base/common/buffer.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import type { IAgentHostDebugLogsArtifact, IAgentHostDebugLogsChunk } from '../../../../../platform/agentHost/common/agentService.js'; +import { createHostArtifactStream } from '../../browser/actions/exportAgentHostDebugLogsAction.js'; + +function artifactOfSize(size: number): IAgentHostDebugLogsArtifact { + return { + kind: 'archive', + resource: URI.parse('vscode-agent-host://remote/tmp/logs.zip'), + providerLogsIncluded: true, + size, + uncompressedSize: size, + entries: [{ path: 'agenthost.log', size }], + }; +} + +/** Serves `contents` in fixed-size slices, like a remote host would. */ +function chunkedReader(contents: VSBuffer, chunkSize: number): (position: number) => Promise { + return async position => { + const data = contents.slice(position, Math.min(position + chunkSize, contents.byteLength)); + return { data, eof: position + data.byteLength >= contents.byteLength }; + }; +} + +suite('createHostArtifactStream', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reassembles an artifact delivered over several chunks', async () => { + const contents = VSBuffer.fromString('abcdefghij'); + const stream = createHostArtifactStream(artifactOfSize(contents.byteLength), chunkedReader(contents, 3)); + + assert.strictEqual((await streamToBuffer(stream)).toString(), 'abcdefghij'); + }); + + test('fails when the host delivers fewer bytes than it declared', async () => { + const contents = VSBuffer.fromString('abc'); + const stream = createHostArtifactStream(artifactOfSize(10), chunkedReader(contents, 3)); + + await assert.rejects(streamToBuffer(stream), /ended after 3 bytes, expected 10/); + }); + + test('fails when the host delivers more bytes than it declared', async () => { + const contents = VSBuffer.fromString('abcdefghij'); + const stream = createHostArtifactStream(artifactOfSize(4), chunkedReader(contents, 3)); + + await assert.rejects(streamToBuffer(stream), /exceeded its declared size of 4 bytes/); + }); + + test('fails when the host never reaches the end of the artifact', async () => { + const stream = createHostArtifactStream(artifactOfSize(10), async () => ({ data: VSBuffer.alloc(0), eof: false })); + + await assert.rejects(streamToBuffer(stream), /empty debug log chunk/); + }); +}); diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index b4a22e94dcb5f5..0f0ef06b2c7b91 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -9,7 +9,7 @@ import { DisposableStore, IReference } from '../../../../../base/common/lifecycl import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { constObservable, IObservable } from '../../../../../base/common/observable.js'; -import { IAgentConnection, IAgentCreateSessionConfig, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostDebugLogsArtifactKind, IAgentConnection, IAgentCreateSessionConfig, IAgentHostDebugLogsArtifact, IAgentHostDebugLogsChunk, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult } from '../../../../../platform/agentHost/common/agentService.js'; import { ActionType, StateAction } from '../../../../../platform/agentHost/common/state/protocol/actions.js'; import { RootState, TerminalClaimKind, type TerminalState } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; @@ -76,6 +76,8 @@ class MockAgentConnection implements IAgentConnection { async getNetworkDiagnosticsInfo(): Promise { return { version: 'test', os: 'test', arch: 'test', proxySettings: {}, proxyEnv: {}, endpoints: [] }; } async getManagedSettingsDiagnostics(): Promise { return []; } async diagnosticsFetch(url: string): Promise { return { url }; } + async collectDebugLogs(_session: URI | undefined, _kind: AgentHostDebugLogsArtifactKind): Promise { throw new Error('Not implemented'); } + async readDebugLogsChunk(_resource: URI, _position: number): Promise { throw new Error('Not implemented'); } async listSessions(): Promise { return []; } async createSession(_config?: IAgentCreateSessionConfig): Promise { return URI.parse('copilot:///test'); } async resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise { return { schema: { type: 'object', properties: {} }, values: {} }; } diff --git a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts index 7b910012a509a6..0b545176e9e2ed 100644 --- a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts +++ b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts @@ -15,13 +15,13 @@ import { autorun, IObservable, ISettableObservable, observableValue, constObserv import { URI } from '../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentHostService, IAgentHostSocketInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../../../../platform/agentHost/common/agentService.js'; +import { AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, IAgentHostService, IAgentHostSocketInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../../../../platform/agentHost/common/agentService.js'; import { IAgentHostEnablementService } from '../../../../platform/agentHost/common/agentHostEnablementService.js'; import { AgentHostIpcChannelTransport } from '../../../../platform/agentHost/browser/agentHostIpcChannelTransport.js'; import { AgentHostClientConnectionKind } from '../../../../platform/agentHost/common/agentHostTelemetry.js'; import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; import type { IActiveSubscriptionInfo, IAgentSubscription } from '../../../../platform/agentHost/common/state/agentSubscription.js'; -import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; +import type { CompletionsParams, CompletionsResult, ContentEncoding, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, TerminalAction, ClientAnnotationsAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import type { IRemoteWatchHandle } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; @@ -31,6 +31,8 @@ import type { InitializeResult } from '../../../../platform/agentHost/common/sta import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from '../../../../platform/agentHost/common/agentHostClientInfo.js'; +import { agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { IAgentHostFileSystemService } from '../common/agentHostFileSystemService.js'; const REMOTE_NOT_SUPPORTED = (op: string) => new Error(`${op} is not supported when the agent host runs on a remote.`); const LOG_PREFIX = '[AgentHost:remote]'; @@ -72,6 +74,7 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @IAgentHostFileSystemService agentHostFileSystemService: IAgentHostFileSystemService, ) { super(); @@ -91,6 +94,10 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA const address = `vscode-remote://${connection.remoteAuthority}`; const clientInfo = environmentService.isSessionsWindow ? agentsWindowAgentHostClientInfo : editorWindowAgentHostClientInfo; this._protocolClient = this._register(instantiationService.createInstance(RemoteAgentHostProtocolClient, address, createTransport, undefined, undefined, clientInfo)); + // Resources this client hands out (e.g. debug-log artifacts) are stamped with the + // address-derived authority, so register it for reads. The ambient `local` authority + // registered elsewhere covers a different URI namespace. + this._register(agentHostFileSystemService.registerAuthority(agentHostAuthority(address), this._protocolClient)); this._register(this._protocolClient.onDidClose(() => { this._logService.info(`${LOG_PREFIX} Protocol client closed`); this._onAgentHostExit.fire(0); @@ -218,6 +225,14 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA return this._requireClient().diagnosticsFetch(url); } + collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise { + return this._requireClient().collectDebugLogs(session, kind); + } + + readDebugLogsChunk(resource: URI, position: number): Promise { + return this._requireClient().readDebugLogsChunk(resource, position); + } + listSessions(): Promise { return this._requireClient().listSessions(); } @@ -274,8 +289,8 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA return this._requireClient().resourceList(uri); } - resourceRead(uri: URI): Promise { - return this._requireClient().resourceRead(uri); + resourceRead(uri: URI, encoding?: ContentEncoding): Promise { + return this._requireClient().resourceRead(uri, encoding); } resourceWrite(params: ResourceWriteParams): Promise { diff --git a/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts b/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts index 5fa447388b66d9..9127f5b77e9c97 100644 --- a/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts +++ b/src/vs/workbench/services/agentHost/test/browser/editorRemoteAgentHostServiceClient.test.ts @@ -15,6 +15,7 @@ import { IAgentHostEnablementService } from '../../../../../platform/agentHost/c import { IWorkbenchEnvironmentService } from '../../../environment/common/environmentService.js'; import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; import { editorWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js'; +import { agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -22,6 +23,7 @@ import { NullLogService, ILogService } from '../../../../../platform/log/common/ import type { RemoteAgentConnectionContext, IRemoteAgentEnvironment } from '../../../../../platform/remote/common/remoteAgentEnvironment.js'; import type { PersistentConnectionEvent } from '../../../../../platform/remote/common/remoteAgentConnection.js'; import { EditorRemoteAgentHostServiceClient } from '../../browser/editorRemoteAgentHostServiceClient.js'; +import { IAgentHostFileSystemService } from '../../common/agentHostFileSystemService.js'; import { IRemoteAgentService, type IRemoteAgentConnection } from '../../../remote/common/remoteAgentService.js'; import { TestRemoteAgentService } from '../../../../test/browser/workbenchTestServices.js'; @@ -105,12 +107,21 @@ suite('EditorRemoteAgentHostServiceClient', () => { }, dispose: () => { }, }; + const registeredAuthorities: string[] = []; const agentHostEnabled = observableValue('agentHostEnabled', false); const instantiationService = disposables.add(new TestInstantiationService(new ServiceCollection( [IRemoteAgentService, remoteAgentService], [IAgentHostEnablementService, { _serviceBrand: undefined, enabled: agentHostEnabled, managedSandboxEnforced: constObservable(false) }], [ILogService, new NullLogService()], [IWorkbenchEnvironmentService, { isSessionsWindow: false }], + [IAgentHostFileSystemService, { + _serviceBrand: undefined, + registerAuthority: (authority: string) => { + registeredAuthorities.push(authority); + return Disposable.None; + }, + ensureSyncedCustomizationProvider: () => { }, + }], ))); instantiationService.stubInstance(RemoteAgentHostProtocolClient, protocolClient); instantiationService.set(IInstantiationService, instantiationService); @@ -133,10 +144,12 @@ suite('EditorRemoteAgentHostServiceClient', () => { beforeReady, afterReady: connectCalls, clientInfo: protocolClientCall?.args[5], + registeredAuthorities, }, { beforeReady: 0, afterReady: 1, clientInfo: editorWindowAgentHostClientInfo, + registeredAuthorities: [agentHostAuthority('vscode-remote://ssh-remote+test')], }); }); });