diff --git a/extensions/copilot/src/extension/prompts/node/agent/executionSubagentPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/executionSubagentPrompt.tsx index 69358e96495655..6f286dec0269b4 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/executionSubagentPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/executionSubagentPrompt.tsx @@ -33,6 +33,7 @@ export class ExecutionSubagentPrompt extends PromptElement= this.props.maxExecutionTurns - 1; + const remainingTurns = Math.max(this.props.maxExecutionTurns - currentTurn, 1); return ( <> @@ -82,11 +83,9 @@ export class ExecutionSubagentPrompt extends PromptElement - {isLastTurn && ( - - OK, your allotted iterations are finished. Show the <final_answer>. - - )} + + You have {remainingTurns} of {this.props.maxExecutionTurns} allotted iterations remaining. When one iteration remains, do not call tools; return only the <final_answer>. + {!isLastTurn && this.props.hasBackgroundCommand && ( One or more commands are running in the background. You do not have the ability to monitor them. Show the <final_answer>. diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index 5908467511a0cc..d32bf32badc887 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -93,9 +93,8 @@ import { DEV_CONTAINER_AGENT_HOST_CHANNEL, IDevContainerAgentHostMainService } f import { DevContainerAgentHostMainService } from '../../../platform/agentHost/node/devContainerAgentHostService.js'; import { IWSLRemoteAgentHostMainService, WSL_REMOTE_AGENT_HOST_CHANNEL } from '../../../platform/agentHost/common/wslRemoteAgentHost.js'; import { WSLRemoteAgentHostMainService } from '../../../platform/agentHost/node/wslRemoteAgentHostService.js'; -import { ITunnelAgentHostMainService, ITunnelAgentHostHostingService, TUNNEL_AGENT_HOST_CHANNEL, TUNNEL_HOST_CHANNEL } from '../../../platform/agentHost/common/tunnelAgentHost.js'; +import { ITunnelAgentHostMainService, TUNNEL_AGENT_HOST_CHANNEL } from '../../../platform/agentHost/common/tunnelAgentHost.js'; import { TunnelAgentHostMainService } from '../../../platform/agentHost/node/tunnelAgentHostService.js'; -import { TunnelHostMainService } from '../../../platform/agentHost/node/tunnelHostMainService.js'; import { IUserDataProfilesService } from '../../../platform/userDataProfile/common/userDataProfile.js'; import { IExtensionsProfileScannerService } from '../../../platform/extensionManagement/common/extensionsProfileScannerService.js'; import { PolicyChannelClient } from '../../../platform/policy/common/policyIpc.js'; @@ -434,9 +433,6 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // Tunnel Agent Host services.set(ITunnelAgentHostMainService, new SyncDescriptor(TunnelAgentHostMainService, undefined, true)); - // Tunnel Host (hosting local agent host for remote connections) - services.set(ITunnelAgentHostHostingService, new SyncDescriptor(TunnelHostMainService, undefined, true)); - return new InstantiationService(services); } @@ -529,9 +525,6 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { const tunnelAgentHostChannel = ProxyChannel.fromService(accessor.get(ITunnelAgentHostMainService), this._store); this.server.registerChannel(TUNNEL_AGENT_HOST_CHANNEL, tunnelAgentHostChannel); - // Tunnel Host - const tunnelHostChannel = ProxyChannel.fromService(accessor.get(ITunnelAgentHostHostingService), this._store); - this.server.registerChannel(TUNNEL_HOST_CHANNEL, tunnelHostChannel); } private registerErrorHandler(logService: ILogService): void { diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 7a800b1387c707..41645713a7c25f 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1228,6 +1228,16 @@ export interface IAgent { // ---- Metadata ----------------------------------------------------------- + /** + * Warms a short-lived, in-memory cache of per-session metadata from a single + * bulk provider call, so a subsequent burst of {@link getChatMetadata} calls + * (e.g. a `listSessions` pass over a large catalogue) can be served without + * one provider round-trip per session. Returns a disposable that clears the + * cache; callers dispose it once the burst is complete. Optional: providers + * without a cheap bulk read simply omit it and pay per session. + */ + prewarmSessionMetadata?(): Promise; + /** Retrieve metadata for an exact registered chat. Ambient catalogue reads never set {@link IAgentChatMetadataOptions.activation}. */ getChatMetadata(chat: URI, context: URI | IAgentChatContext, providerData?: string, options?: IAgentChatMetadataOptions): Promise; diff --git a/src/vs/platform/agentHost/common/agentModelPricing.ts b/src/vs/platform/agentHost/common/agentModelPricing.ts index 9fd6bee9664b63..9bf228f4629c22 100644 --- a/src/vs/platform/agentHost/common/agentModelPricing.ts +++ b/src/vs/platform/agentHost/common/agentModelPricing.ts @@ -63,6 +63,19 @@ const NUMBER_KEYS = [ 'discountPercent', ] as const satisfies readonly (keyof IAgentModelPricingMeta)[]; +/** + * Flat-dotted `_meta` key the Copilot agent host publishes a model's capability category under. + * + * A host that derives its model list from the Copilot SDK namespaces its metadata by producer + * rather than using the flat {@link IAgentModelPricingMeta} key names, so the category arrives + * under this key instead of `category`. Read as a fallback so sandbox models still show a + * capability category in the picker hover; the flat key wins when both are present. + * + * Only the category is mapped: such a host surfaces no billing information at all, so there is no + * multiplier or cost to recover. + */ +const COPILOT_MODEL_PICKER_CATEGORY_META_KEY = 'copilot.modelPickerCategory'; + /** * Reads the well-known {@link IAgentModelPricingMeta} keys from a model's open `_meta` bag, ignoring any unrelated * provider-specific keys and values of the wrong type. Returns an object containing only the keys that were present @@ -85,6 +98,8 @@ export function readAgentModelPricingMeta(model: IAgentModelInfo | SessionModelI } if (typeof meta.category === 'string') { result.category = meta.category; + } else if (typeof meta[COPILOT_MODEL_PICKER_CATEGORY_META_KEY] === 'string') { + result.category = meta[COPILOT_MODEL_PICKER_CATEGORY_META_KEY]; } const rawPromo = meta.promo; if (rawPromo && typeof rawPromo === 'object' && !Array.isArray(rawPromo)) { diff --git a/src/vs/platform/agentHost/common/copilotCliConfig.ts b/src/vs/platform/agentHost/common/copilotCliConfig.ts index de6bed7136d87d..b63f6abeb687a4 100644 --- a/src/vs/platform/agentHost/common/copilotCliConfig.ts +++ b/src/vs/platform/agentHost/common/copilotCliConfig.ts @@ -101,6 +101,10 @@ export interface ICopilotCliModelCapabilityOverride { readonly excludedTools?: readonly string[]; /** Deep-merged over the runtime's resolved defaults (e.g. `supports.vision`). */ readonly modelCapabilities?: Record; + /** Inline YAML with system-prompt and tool-description overrides. */ + readonly promptOverrideString?: string; + /** Path to a YAML file with system-prompt and tool-description overrides. */ + readonly promptOverrideFile?: string; } /** Map of model id → capability override. */ @@ -206,7 +210,7 @@ export const copilotCliConfigSchema = createSchema({ [CopilotCliConfigKey.ModelCapabilityOverrides]: schemaProperty({ type: 'object', title: localize('agentHost.config.modelCapabilityOverrides.title', "Model Capability Overrides"), - description: localize('agentHost.config.modelCapabilityOverrides.description', "Per-model capability overrides for Copilot SDK sessions, keyed by model id (`*` matches every model; a specific entry wins field-by-field). Aliasing a model id to a known `family` routes it to that family's tuned system prompt and tool profile without changing the model id sent to the runtime; the remaining fields override reasoning effort, tool enablement, and model capability limits per model. Only affects Copilot SDK sessions; intended for experimentation."), + description: localize('agentHost.config.modelCapabilityOverrides.description', "Per-model overrides for Copilot SDK sessions. Use `*` to match every model. Intended for experimentation."), additionalProperties: { type: 'object', title: localize('agentHost.config.modelCapabilityOverrides.entry.title', "Capability Override"), @@ -240,6 +244,16 @@ export const copilotCliConfigSchema = createSchema({ title: localize('agentHost.config.modelCapabilityOverrides.modelCapabilities.title', "Model Capabilities"), description: localize('agentHost.config.modelCapabilityOverrides.modelCapabilities.description', "Per-property model capability overrides passed through to the Copilot SDK's `modelCapabilities` session field (e.g. `{ \"supports\": { \"vision\": false }, \"limits\": { \"max_context_window_tokens\": 64000 } }`), deep-merged over the runtime's resolved defaults for this model. Applied when the session launches or resumes."), }, + promptOverrideString: { + type: 'string', + title: localize('agentHost.config.modelCapabilityOverrides.promptOverrideString.title', "Prompt Override String"), + description: localize('agentHost.config.modelCapabilityOverrides.promptOverrideString.description', "Inline YAML that overrides the system prompt and/or SDK tool descriptions for sessions on this model. Takes precedence over `promptOverrideFile`."), + }, + promptOverrideFile: { + type: 'string', + title: localize('agentHost.config.modelCapabilityOverrides.promptOverrideFile.title', "Prompt Override File"), + description: localize('agentHost.config.modelCapabilityOverrides.promptOverrideFile.description', "Path to a YAML file that overrides the system prompt and/or SDK tool descriptions for sessions on this model. Ignored when `promptOverrideString` is also set."), + }, }, }, default: {}, diff --git a/src/vs/platform/agentHost/common/meta/agentPermissionRequestMeta.ts b/src/vs/platform/agentHost/common/meta/agentPermissionRequestMeta.ts new file mode 100644 index 00000000000000..0dc82fce5df0bd --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentPermissionRequestMeta.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Reader for the permission metadata a remote agent host echoes onto a tool + * call that is waiting for approval. + * + * A remote host describes the pending decision (run a command, read a file, …) + * but does not stamp the `_meta.toolKind` rendering hint local agent adapters + * provide, so the kind is recovered from here instead. + */ + +interface IHasPermissionRequestMeta { + readonly _meta?: Record; +} + +/** + * The permission kinds that carry a rendering consequence. A remote host + * reports more kinds than these; the rest are left unrecognized so they fall + * through to the generic tool presentation. + */ +export const enum AgentPermissionRequestKind { + /** Execute a shell command. */ + Commands = 'commands', + /** Read a single file. */ + Read = 'read', +} + +export interface IAgentPermissionRequestMeta { + readonly kind?: AgentPermissionRequestKind; +} + +/** + * Normalizes a wire `kind`. A shell request arrives as `"commands"` on the + * projected payload and `"shell"` on the raw one. + * + * A path-batched request (`"path"`, whose own `accessKind` may be `"shell"`) is + * not a command: its subject is a list of paths, not a command line. + */ +function normalizeKind(value: unknown): AgentPermissionRequestKind | undefined { + switch (value) { + case 'commands': + case 'shell': + return AgentPermissionRequestKind.Commands; + case 'read': + return AgentPermissionRequestKind.Read; + default: + return undefined; + } +} + +function readKind(value: unknown): AgentPermissionRequestKind | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + return normalizeKind((value as Record)['kind']); +} + +/** + * Reads the recognized permission metadata from a tool call's `_meta` bag. + * + * Hosts echo the same request as `promptRequest` (the prompt-shaped + * projection) and `permissionRequest` (the raw form); older hosts send only + * the raw one. + */ +export function readAgentPermissionRequestMeta(source: IHasPermissionRequestMeta): IAgentPermissionRequestMeta { + const meta = source._meta; + if (!meta) { + return {}; + } + const kind = readKind(meta['promptRequest']) ?? readKind(meta['permissionRequest']); + return kind ? { kind } : {}; +} diff --git a/src/vs/platform/agentHost/common/state/sessionReducers.ts b/src/vs/platform/agentHost/common/state/sessionReducers.ts index 8e2fbd41cf725f..129ad047429bde 100644 --- a/src/vs/platform/agentHost/common/state/sessionReducers.ts +++ b/src/vs/platform/agentHost/common/state/sessionReducers.ts @@ -9,14 +9,29 @@ // Re-export reducers from the protocol layer export { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer, automationReducer, automationRunReducer, softAssertNever, isClientDispatchable } from './protocol/reducers.js'; +import { AgentPermissionRequestKind, readAgentPermissionRequestMeta } from '../meta/agentPermissionRequestMeta.js'; import { readToolCallMeta, type ToolKind } from '../meta/agentToolCallMeta.js'; import type { ICompletedToolCall, ToolCallState } from './sessionState.js'; +/** Rendering kinds implied by a remote host's permission request. */ +const PERMISSION_REQUEST_TOOL_KINDS: Readonly>> = { + [AgentPermissionRequestKind.Commands]: 'terminal', + [AgentPermissionRequestKind.Read]: 'read', +}; + /** - * Extracts the VS Code-specific `toolKind` hint from a tool call's `_meta` - * bag. This is not part of the protocol and is injected by the agent adapter - * (e.g. `copilotEventMapper`). + * Extracts the VS Code-specific `toolKind` rendering hint for a tool call. + * + * Normally the `_meta.toolKind` flag an agent adapter injects (e.g. + * `copilotEventMapper`); it is not part of the protocol. A remote agent host + * does not stamp that key, so for a call awaiting approval the kind comes from + * the permission request it echoes instead. */ export function getToolKind(tc: ToolCallState | ICompletedToolCall): ToolKind | undefined { - return readToolCallMeta(tc).toolKind; + const kind = readToolCallMeta(tc).toolKind; + if (kind) { + return kind; + } + const permissionKind = readAgentPermissionRequestMeta(tc).kind; + return permissionKind ? PERMISSION_REQUEST_TOOL_KINDS[permissionKind] : undefined; } diff --git a/src/vs/platform/agentHost/common/tunnelAgentHost.ts b/src/vs/platform/agentHost/common/tunnelAgentHost.ts index a17b0f2ed53d52..0cee376dde2d33 100644 --- a/src/vs/platform/agentHost/common/tunnelAgentHost.ts +++ b/src/vs/platform/agentHost/common/tunnelAgentHost.ts @@ -534,14 +534,6 @@ export interface ITunnelAgentHostService { getAuthProvider(options?: { silent?: boolean }): Promise<'github' | 'microsoft' | undefined>; } -// ---- Tunnel hosting (exposing the local agent host to remote clients) -------- - -/** IPC channel name for the tunnel host service. */ -export const TUNNEL_HOST_CHANNEL = 'tunnelHost'; - -/** Output channel ID for the tunnel host logs. */ -export const TUNNEL_HOST_LOG_ID = 'tunnelHostService'; - /** Information about an actively hosted tunnel. */ export interface ITunnelHostInfo { readonly tunnelName: string; @@ -560,34 +552,3 @@ export function isTunnelHosted(sharingInfo: ITunnelHostInfo | undefined, tunnel: ? sharingInfo.tunnelId === tunnel.tunnelId : sharingInfo.tunnelName === tunnel.name; } - -/** Status of the tunnel host. */ -export type TunnelHostStatus = - | { readonly active: false } - | { readonly active: true; readonly info: ITunnelHostInfo }; - -/** - * Shared-process service that hosts a dev tunnel using the code CLI. - */ -export const ITunnelAgentHostHostingService = createDecorator('tunnelAgentHostHostingService'); - -export interface ITunnelAgentHostHostingService { - readonly _serviceBrand: undefined; - - /** Fires when the hosting status changes. */ - readonly onDidChangeStatus: Event; - - /** - * Start hosting a dev tunnel that exposes the local agent host. - * - * @param token The user's access token. - * @param authProvider The auth provider that issued the token. - */ - startHosting(token: string, authProvider: 'github' | 'microsoft'): Promise; - - /** Stop hosting and clean up the tunnel. */ - stopHosting(): Promise; - - /** Get the current hosting status. */ - getStatus(): Promise; -} diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index a2ee98b7b65d26..139913434eb29f 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -988,7 +988,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC orderedSources.push(nonGitDiffs); } const evaluation = evaluateMultiRootDiffSources(orderedSources); - if (evaluation.outcome === 'failed') { + if (evaluation.outcome !== 'complete') { // No source produced diffs (total failure or no sources at all). // Preserve the previously cached summary instead of clobbering it // with a spurious zero aggregate. diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index b2fc3c0f64ca08..01082bceb6c919 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -44,6 +44,11 @@ export interface IAgentHostDatabaseExternalUpdate { readonly external: boolean; } +export interface IAgentHostDatabaseModifiedTimeUpdate { + readonly session: string; + readonly modifiedTime: number; +} + export interface IAgentHostDatabase extends IDisposable { /** * Records a session with source-aware provenance. When requested, the @@ -56,6 +61,8 @@ export interface IAgentHostDatabase extends IDisposable { updateSessionExternal(updates: readonly IAgentHostDatabaseExternalUpdate[]): Promise; /** Advances the durable last-observed modification time. */ updateSessionModifiedTime(session: string, modifiedTime: number): Promise; + /** Advances the durable last-observed modification time for many sessions in one transaction. */ + updateSessionModifiedTimes(updates: readonly IAgentHostDatabaseModifiedTimeUpdate[]): Promise; getSession(session: string): Promise; listSessions(): Promise; isSessionRegistryEmpty(): Promise; @@ -302,6 +309,30 @@ export class AgentHostDatabase implements IAgentHostDatabase { return changes > 0; } + async updateSessionModifiedTimes(updates: readonly IAgentHostDatabaseModifiedTimeUpdate[]): Promise { + // Advancing durable recency for a large catalogue one statement at a time + // dominates discovery, so the whole batch is flushed in a single + // transaction. The `modified_time < ?` guard keeps each advance monotonic + // even if a concurrent write moved a row forward since the snapshot. + const statements = updates + .filter(({ modifiedTime }) => Number.isFinite(modifiedTime)) + .map(({ session, modifiedTime }) => `UPDATE sessions SET modified_time = ${modifiedTime} WHERE session_uri = ${quoteSqlString(session)} AND modified_time < ${modifiedTime}`); + if (statements.length === 0) { + return; + } + const database = await this._ensureDatabase(); + try { + await exec(database, `BEGIN IMMEDIATE;\n${statements.join(';\n')};\nCOMMIT`); + } catch (error) { + try { + await exec(database, 'ROLLBACK'); + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], 'Failed to advance session modified times'); + } + throw error; + } + } + async listSessions(): Promise { const rows = await all(await this._ensureDatabase(), 'SELECT session_uri, provider, start_time, modified_time, external, registration_source FROM sessions', []); return rows.map(row => ({ diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 2581cc0954ff01..6c8d5128a18682 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -1781,9 +1781,13 @@ export class AgentService extends Disposable implements IAgentService { */ private async _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise { - // Keys only: discovery arrives in batches, and the full listing re-runs the - // per-row provenance migration for every registered session each time. - const registeredKeys = new Set(await this._sessionRegistry.listSessionKeys()); + // Keys and durable recency only: discovery arrives in batches, and the + // full listing re-runs the per-row provenance migration for every + // registered session each time. The recency snapshot lets the + // already-registered branch skip a per-session write when the provider + // re-reports an unchanged modified time (the common case on startup). + const registeredRecency = await this._sessionRegistry.listSessionModifiedTimes(); + const registeredKeys = new Set(registeredRecency.keys()); const discoveryLimiter = new Limiter(4); let suppressed = 0; let skippedAsStale = 0; @@ -1791,15 +1795,21 @@ export class AgentService extends Disposable implements IAgentService { let alreadyRegistered = 0; let registryChanged = false; const untitledExternal: IAgentSessionMetadata[] = []; + const modifiedTimeAdvances: { readonly session: URI; readonly modifiedTime: number }[] = []; const results = await Promise.all(chats.map(({ external, ...metadata }) => discoveryLimiter.queue(async () => { const sessionMetadata = this._toSessionMetadata(metadata); const session = sessionMetadata.session; try { // Matching registry entries still advance their durable recency from - // the provider catalog, but need no per-session metadata I/O. + // the provider catalog, but need no per-session metadata I/O. Only a + // genuine forward move is queued for the batched write below, so a + // steady-state startup issues no recency writes at all. if (registeredKeys.has(session.toString())) { alreadyRegistered++; - await this._advanceSessionModifiedTime(session, sessionMetadata.modifiedTime); + const stored = registeredRecency.get(session.toString()); + if (Number.isFinite(sessionMetadata.modifiedTime) && (stored === undefined || sessionMetadata.modifiedTime > stored)) { + modifiedTimeAdvances.push({ session, modifiedTime: sessionMetadata.modifiedTime }); + } return false; } if (isSubagentSession(session.toString()) || await this._isChatBacking(session)) { @@ -1841,6 +1851,13 @@ export class AgentService extends Disposable implements IAgentService { } }))); const registered = results.filter(changed => changed).length; + if (modifiedTimeAdvances.length > 0) { + await this._retryRegistryMutation( + () => this._sessionRegistry.updateModifiedTimes(modifiedTimeAdvances), + `batched modified-time update for ${modifiedTimeAdvances.length} session(s)`, + ); + this._invalidateSessionList(); + } if (registryChanged) { this._invalidateSessionList(); } @@ -2127,31 +2144,61 @@ export class AgentService extends Disposable implements IAgentService { const registered = hiddenExternal.size > 0 ? allRegistered.filter(entry => !hiddenExternal.has(entry.session.toString())) : allRegistered; - const metadataLimiter = new Limiter(4); - const results = await Promise.all(registered.map(registeredSession => metadataLimiter.queue(async (): Promise => { - const { session, provider, external } = registeredSession; - // Idle provisional sessions stay hidden until they materialize or gain - // turn activity (#321269). The state-manager overlay below re-surfaces - // them then. - if (this._stateManager.isIdleProvisionalSession(session.toString())) { - return undefined; - } - - const agent = this._providerService.getProvider(provider); - if (!agent) { - return undefined; + // Warm each involved provider's bulk metadata cache once, so the + // per-session metadata reads below are served from memory instead of one + // provider round-trip per session (the dominant cost on a large + // catalogue). Best-effort and provider-optional; disposed once the + // metadata phase completes. + const prewarmStore = new DisposableStore(); + const involvedProviders = new Map(); + for (const entry of registered) { + if (!involvedProviders.has(entry.provider)) { + const agent = this._providerService.getProvider(entry.provider); + if (agent?.prewarmSessionMetadata) { + involvedProviders.set(entry.provider, agent); + } } + } + await Promise.all([...involvedProviders.values()].map(async agent => { try { - return await this._registeredSessionMetadata(agent, session, external, registeredSession); + prewarmStore.add(await agent.prewarmSessionMetadata!()); } catch (err) { - this._logService.warn(`[AgentService] listSessions: failed to read metadata for ${session}`, err); - return undefined; + this._logService.warn(`[AgentService] listSessions: failed to prewarm metadata for provider ${agent.id}`, err); } - }))); + })); + const metadataLimiter = new Limiter(4); + const metadataPhaseStartedAt = Date.now(); + let results: readonly (IAgentSessionMetadata | undefined)[]; + try { + results = await Promise.all(registered.map(registeredSession => metadataLimiter.queue(async (): Promise => { + const { session, provider, external } = registeredSession; + // Idle provisional sessions stay hidden until they materialize or gain + // turn activity (#321269). The state-manager overlay below re-surfaces + // them then. + if (this._stateManager.isIdleProvisionalSession(session.toString())) { + return undefined; + } + + const agent = this._providerService.getProvider(provider); + if (!agent) { + return undefined; + } + try { + return await this._registeredSessionMetadata(agent, session, external, registeredSession); + } catch (err) { + this._logService.warn(`[AgentService] listSessions: failed to read metadata for ${session}`, err); + return undefined; + } + }))); + } finally { + prewarmStore.dispose(); + } const flat = results.filter((s): s is IAgentSessionMetadata => s !== undefined); + const metadataPhaseMs = Date.now() - metadataPhaseStartedAt; // Overlay persisted custom titles from per-session databases. const overlayLimiter = new Limiter(4); + const overlayPhaseStartedAt = Date.now(); const overlaid = await Promise.all(flat.map(s => overlayLimiter.queue(async (): Promise => { const sanitized = { ...s, _meta: withSessionMultiRootMetadata(s._meta, undefined) }; // A backing session whose durable marker write kept failing is @@ -2278,6 +2325,7 @@ export class AgentService extends Disposable implements IAgentService { return sanitized; }))); const result = overlaid.filter((s): s is IAgentSessionMetadata => s !== undefined); + const overlayPhaseMs = Date.now() - overlayPhaseStartedAt; // Overlay live session state from the state manager. // For the title, prefer the state manager's value when it is @@ -2364,7 +2412,7 @@ export class AgentService extends Disposable implements IAgentService { // A catalog pass opens every registered session's database, so it can be slow. const duration = Date.now() - startedAt; - const message = `[AgentService] listSessions computed ${visible.length} of ${total} session(s) for mode '${mode}' in ${duration}ms (${additions.length} state-manager fallback)`; + const message = `[AgentService] listSessions computed ${visible.length} of ${total} session(s) for mode '${mode}' in ${duration}ms (metadata ${metadataPhaseMs}ms, overlay ${overlayPhaseMs}ms, ${additions.length} state-manager fallback)`; if (duration >= SLOW_LIST_SESSIONS_THRESHOLD_MS) { this._logService.info(message); } else { diff --git a/src/vs/platform/agentHost/node/agentSessionRegistry.ts b/src/vs/platform/agentHost/node/agentSessionRegistry.ts index 7bc944a0371b69..5b4f3c28516cf2 100644 --- a/src/vs/platform/agentHost/node/agentSessionRegistry.ts +++ b/src/vs/platform/agentHost/node/agentSessionRegistry.ts @@ -92,11 +92,26 @@ export class AgentSessionRegistry extends Disposable { return this._database.updateSessionModifiedTime(session.toString(), modifiedTime); } + /** Advances the durable last-observed provider modification time for many sessions in one transaction. */ + updateModifiedTimes(updates: readonly { readonly session: URI; readonly modifiedTime: number }[]): Promise { + return this._database.updateSessionModifiedTimes(updates.map(({ session, modifiedTime }) => ({ session: session.toString(), modifiedTime }))); + } + /** Every registered session URI key without running legacy metadata migration. */ async listSessionKeys(): Promise> { return new Set((await this._database.listSessions()).map(entry => entry.session)); } + /** + * Every registered session URI mapped to its durable last-observed + * modification time, without running legacy metadata migration. Lets a + * caller skip no-op recency writes for sessions the provider re-reports + * unchanged. + */ + async listSessionModifiedTimes(): Promise> { + return new Map((await this._database.listSessions()).map(entry => [entry.session, entry.modifiedTime])); + } + /** * Every session currently recorded, in no particular order. Legacy entries * are passed through `migrate`, when provided, before the resolved list is returned. diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index e3953553cab7d5..982d7e7ed63253 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CopilotClient, RuntimeConnection, type CopilotClientOptions, type GitHubTelemetryNotification, type ManagedSettingsResolvedData, type SessionMode as CopilotSdkMode } from '@github/copilot-sdk'; +import { CopilotClient, RuntimeConnection, type CopilotClientOptions, type GitHubTelemetryNotification, type ManagedSettingsResolvedData, type SessionMetadata, type SessionMode as CopilotSdkMode } from '@github/copilot-sdk'; import * as fs from 'fs/promises'; import * as os from 'os'; import { pathToFileURL } from 'url'; @@ -1539,7 +1539,13 @@ export class CopilotAgent extends Disposable implements IAgent { return false; } await this._authenticationSequencer.queue(async () => { - this._authenticationRequired.set(undefined, undefined); + // Only a supplied credential rearms the requirement. Clearing it for an + // empty token would silence the outstanding requirement when a second + // revocation arrives while the agent is already tokenless, because + // `_applyGitHubToken` returns early for an unchanged token. + if (token) { + this._authenticationRequired.set(undefined, undefined); + } await this._applyGitHubToken(token || undefined); }); return true; @@ -1555,6 +1561,18 @@ export class CopilotAgent extends Disposable implements IAgent { this._updateRestrictedTelemetry(token); this._refreshProxy(); if (!token) { + // Losing the credential is the only moment this agent knows for certain + // that it is unauthenticated. Advertise it so clients can re-supply a + // token they still hold; another window may have revoked this one while + // its own authentication provider was still loading. Without this the + // host stays silently unauthenticated -- publishing an empty model list + // -- until a window reloads, because the resulting SDK failure is a + // local `InvalidArg` rather than a 401 and never reaches + // `_handleCopilotSessionAuthRequired`. + this._authenticationRequired.set({ + resource: this._gitHubEndpointService.getCopilotResource(), + reason: AuthRequiredReason.Expired, + }, undefined); await this._requestClientRestart('GitHub authentication cleared'); void this._scheduleModelRefresh(); return; @@ -2706,6 +2724,38 @@ export class CopilotAgent extends Disposable implements IAgent { } } + /** + * Short-lived cache of per-session SDK metadata, warmed by + * {@link prewarmSessionMetadata} from a single bulk `listSessions()` call so a + * `listSessions` pass over a large catalogue serves {@link getChatMetadata} + * from memory instead of one `getSessionMetadata` RPC per session. Ref-counted + * so overlapping passes share one warm set and clear it once all release. + */ + private _prewarmedSessionMetadata: ReadonlyMap | undefined; + private _prewarmSessionMetadataRefs = 0; + + async prewarmSessionMetadata(): Promise { + // One bulk read replaces N per-session `getSessionMetadata` round-trips + // during the metadata phase. Best-effort: when the client cannot enumerate + // (SDK not ready), callers transparently fall back to per-session reads. + const sessions = await this._listSdkSessions('prewarm session metadata', client => client.listSessions()); + if (!sessions) { + return Disposable.None; + } + const byId = new Map(); + for (const metadata of sessions) { + byId.set(metadata.sessionId, metadata); + } + this._prewarmedSessionMetadata = byId; + this._prewarmSessionMetadataRefs++; + return toDisposable(() => { + if (--this._prewarmSessionMetadataRefs <= 0) { + this._prewarmSessionMetadataRefs = 0; + this._prewarmedSessionMetadata = undefined; + } + }); + } + async getChatMetadata(chat: URI, context: URI | IAgentChatContext, providerData?: string): Promise { const session = resolveAgentChatContext(context, chat).configurationResource; const sessionId = providerData ? decodeProviderData(providerData)?.sdkSessionId : AgentSession.id(session); @@ -2714,7 +2764,10 @@ export class CopilotAgent extends Disposable implements IAgent { } const storedMetadata = await this._readStoredSessionMetadata(session); - const sessionMetadata = await this._retryAfterClosedConnection('getSessionMetadata', client => client.getSessionMetadata(sessionId), createCopilotFailureCorrelation(session, chat, undefined, sessionId)); + // Serve from the bulk-warmed cache when available; otherwise fall back to a + // per-session RPC (also covers a session the bulk list transiently omitted). + const prewarmed = this._prewarmedSessionMetadata?.get(sessionId); + const sessionMetadata = prewarmed ?? await this._retryAfterClosedConnection('getSessionMetadata', client => client.getSessionMetadata(sessionId), createCopilotFailureCorrelation(session, chat, undefined, sessionId)); if (!sessionMetadata) { return undefined; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index 199a721e8866e0..6026185f3c066d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -37,7 +37,8 @@ import { isAutoModel, isGpt56Model } from './modelIdentifiers.js'; import { EPHEMERAL_DISABLED_COPILOT_TOOLS } from './copilotToolDisplay.js'; import './prompts/allPrompts.js'; import { agentHostPromptRegistry, type IAgentHostPromptContext } from './prompts/promptRegistry.js'; -import { describeSystemMessageConfig } from './prompts/systemMessage.js'; +import { applyConfiguredPromptOverrides } from './prompts/promptOverride.js'; +import { describeSystemMessageConfig, fullSystemPrompt } from './prompts/systemMessage.js'; import { buildSandboxConfigForSdk, type SandboxConfig } from './sandboxConfigForSdk.js'; import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, agentHostModelSupportsToolSearch } from './toolSearchDeferral.js'; @@ -921,6 +922,12 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { this._logService.warn(`[Copilot:${plan.sessionId}] Ignoring invalid 'modelCapabilities' capability override for '${modelId}'; expected an object`); }); const modelCapabilities = getModelCapabilitiesOverride(modelCapabilitiesOverride, modelId, this._logService, plan.sessionId); + const promptOverrideString = resolveModelCapabilityOverrideField(capabilityOverrides, model?.id, 'promptOverrideString', (value): value is string => typeof value === 'string', () => { + this._logService.warn(`[Copilot:${plan.sessionId}] Ignoring invalid 'promptOverrideString' capability override for '${modelId}'; expected a string`); + }); + const promptOverrideFile = resolveModelCapabilityOverrideField(capabilityOverrides, model?.id, 'promptOverrideFile', (value): value is string => typeof value === 'string', () => { + this._logService.warn(`[Copilot:${plan.sessionId}] Ignoring invalid 'promptOverrideFile' capability override for '${modelId}'; expected a string`); + }); // Host-side routing only — the prompt contributor and the tool-search gate // below. The wire model stays the selected one, so the session still runs // on the real model with the aliased family's prompt and tool profile. @@ -932,6 +939,8 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { && agentHostModelSupportsToolSearch(effectiveModel?.id) && clientToolNames.has(CLIENT_TOOL_SEARCH_REFERENCE_NAME); const toolSearchDeferThreshold = normalizeToolSearchDeferThreshold(this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ToolSearchDeferThreshold)); + const tools = [...shellTools, ...runtime.createClientSdkTools(toolSearchActive), ...runtime.createServerSdkTools()]; + const promptOverrides = await applyConfiguredPromptOverrides(promptOverrideString, promptOverrideFile, tools, this._fileService, this._logService); const managedSettingsPermissions = this._managedSettingsService.permissions; const promptContext: IAgentHostPromptContext = { getSetting: key => this._configurationService.getRootValue(copilotCliConfigSchema, key), @@ -943,7 +952,9 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { // Resolved once per (re)launch — the SDK has no mid-session system-message // update, so this reflects the model/tools/settings at launch time. Log a // summary at info for prompt observability; the full config at trace. - const systemMessage = agentHostPromptRegistry.resolveSystemMessageConfig(effectiveModel, promptContext); + const systemMessage = promptOverrides.systemPrompt !== undefined + ? fullSystemPrompt(promptOverrides.systemPrompt) + : agentHostPromptRegistry.resolveSystemMessageConfig(effectiveModel, promptContext); this._logService.info(`[Copilot:${plan.sessionId}] Resolved system message: ${describeSystemMessageConfig(systemMessage)}`); const additionalDisabledMcpServers = plan.isEphemeral ? [ ...plugins.flatMap(plugin => plugin.mcpServers.map(server => server.name)), @@ -1008,7 +1019,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { excludedTools: sdkExcludedTools, pluginDirectories: coalesce(plugins.map(p => p.pluginDir)) .filter(d => d.scheme === Schemas.file).map(d => d.fsPath), - tools: [...shellTools, ...runtime.createClientSdkTools(toolSearchActive), ...runtime.createServerSdkTools()], + tools: promptOverrides.tools, // Pass the GitHub token at the session level. The SDK's // client-level `gitHubToken` authenticates the CLI process, // but each session also needs its own token resolved into a diff --git a/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md b/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md index 94293e9e18295c..e4b6664ebb526c 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md +++ b/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md @@ -25,7 +25,9 @@ data the SDK accepts directly. ## How the system message is built -`resolveSystemMessageConfig(model, context)` layers, in order: +Unless the matching `chat.agentHost.copilot.modelCapabilityOverrides` entry +provides a YAML `promptOverrideString` or `promptOverrideFile` containing +`systemPrompt`, `resolveSystemMessageConfig(model, context)` layers, in order: 1. **Base** — **`_resolveModelConfig`** picks the per-model (or default) config. Falls back to `COPILOT_AGENT_HOST_SYSTEM_MESSAGE` when there's no @@ -42,6 +44,13 @@ data the SDK accepts directly. `content` for every mode, including `replace`, so a full replacement owns the prompt body but not the host's response-format plumbing. +The per-model prompt override supports the same fields and precedence as the +Copilot Chat debug prompt override: inline YAML takes precedence over a YAML +file, `systemPrompt` bypasses this registry and is sent directly as the Copilot +SDK's `systemMessage` in `replace` mode, and `toolDescriptions` replaces +descriptions on tools registered by Agent Host. This internal debugging setting +assumes those values use the documented string shape. + > **Launch-time freeze.** The SDK accepts a system message only at session > create/resume; there is no mid-session update. The prompt is resolved once per > (re)launch and any tool-gated content reflects the tool set at that moment. A @@ -159,7 +168,7 @@ layering is a known follow-up. ## Related — per-model experimentation knobs (`copilotCliConfig.ts`) `chat.agentHost.copilot.modelCapabilityOverrides` entries (keyed by model id; `'*'` -matches every model, a specific entry wins field-by-field) carry the non-prompt +matches every model, a specific entry wins field-by-field) carry the experimentation knobs the launcher applies: `family` (prompt and tool-profile alias, so a preview model resolves through another family's contributor), `reasoningEffort` (wins over the model picker's thinking level; set it on the @@ -169,9 +178,10 @@ model change), resume, but not on a mid-session model change — and enforced against every SDK-registered tool, including the host's shell and server tools, not just the forwarded client tools), -and `modelCapabilities` (per-property overrides passed through to the SDK's +`modelCapabilities` (per-property overrides passed through to the SDK's `modelCapabilities` field — e.g. vision support, token limits — applied on -every launch and resume). +every launch and resume), and `promptOverrideString`/`promptOverrideFile` (YAML +system-prompt and tool-description overrides, applied on launch and resume). `family` is host-side only: it selects the prompt contributor and the tool-search capability gate, and the model id sent to the runtime is unchanged, @@ -187,11 +197,12 @@ layered on top. Aliasing the runtime's half too would need session in the window. > **Security note.** The setting is application-scoped (not workspace- -> configurable) and forwarded to the agent host; entries must still never carry -> content that reaches the prompt or the host filesystem directly (e.g. a -> prompt-file path). Prompt experiments are code-managed: add a contributor -> (Lever 2) gated on its own opt-in setting, like `anthropicPrompt.ts` with -> `chat.agentHost.opus48Prompt.enabled`. +> configurable) and forwarded to the agent host. `promptOverrideString` and +> `promptOverrideFile` deliberately carry prompt content and a host-local file +> path for debugging and evaluation; do not add other prompt or filesystem +> inputs to this setting without an explicit security review. Code-managed +> prompt experiments should still use a contributor (Lever 2) gated on its own +> opt-in setting. ## Reference diff --git a/src/vs/platform/agentHost/node/copilot/prompts/promptOverride.ts b/src/vs/platform/agentHost/node/copilot/prompts/promptOverride.ts new file mode 100644 index 00000000000000..c6d78d15740ead --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/prompts/promptOverride.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Tool } from '@github/copilot-sdk'; +import { URI } from '../../../../../base/common/uri.js'; +import { parse as parseYaml, type YamlMapNode, type YamlNode, type YamlParseError } from '../../../../../base/common/yaml.js'; +import type { IFileService } from '../../../../files/common/files.js'; +import type { ILogService } from '../../../../log/common/log.js'; + +interface IPromptOverrideConfig { + readonly systemPrompt?: string; + readonly toolDescriptions?: Readonly>; +} + +export interface IPromptOverrideResult { + readonly systemPrompt?: string; + readonly tools: Tool[]; +} + +const INLINE_PROMPT_OVERRIDE_SOURCE = 'inlinePromptOverrideString'; +const warnedSources = new Set(); + +export async function applyConfiguredPromptOverrides( + inlinePromptOverride: string | undefined, + promptOverrideFile: string | undefined, + tools: readonly Tool[], + fileService: IFileService, + logService: ILogService, +): Promise { + const normalizedInlinePromptOverride = inlinePromptOverride?.trim(); + const normalizedPromptOverrideFile = promptOverrideFile?.trim(); + + if (normalizedInlinePromptOverride) { + if (normalizedPromptOverrideFile) { + logService.trace('[PromptOverride] Both inline prompt override text and prompt override file are configured; using inline prompt override text'); + } + return applyPromptOverridesFromString(normalizedInlinePromptOverride, tools, logService); + } + + if (!normalizedPromptOverrideFile) { + return { tools: [...tools] }; + } + + const source = URI.file(normalizedPromptOverrideFile); + let content: string; + try { + content = (await fileService.readFile(source)).value.toString(); + } catch (error) { + logPromptOverrideFailure(logService, source.toString(), `Failed to read prompt override file "${source.toString()}"`, error); + return { tools: [...tools] }; + } + return applyPromptOverridesFromString(content, tools, logService, source.toString()); +} + +export function applyPromptOverridesFromString( + content: string, + tools: readonly Tool[], + logService: ILogService, + source = INLINE_PROMPT_OVERRIDE_SOURCE, +): IPromptOverrideResult { + const config = parsePromptOverrideConfig(content, source, logService); + if (!config) { + return { tools: [...tools] }; + } + + if (config.systemPrompt !== undefined) { + logService.trace('[PromptOverride] Applied system prompt override'); + } + const overriddenTools = tools.map(tool => { + const description = config.toolDescriptions?.[tool.name]?.description; + return description === undefined ? tool : { ...tool, description }; + }); + if (config.toolDescriptions) { + logService.trace('[PromptOverride] Applied tool description overrides'); + } + return { + ...(config.systemPrompt !== undefined ? { systemPrompt: config.systemPrompt } : {}), + tools: overriddenTools, + }; +} + +function parsePromptOverrideConfig(content: string, source: string, logService: ILogService): IPromptOverrideConfig | undefined { + const errors: YamlParseError[] = []; + const document = parseYaml(content.replace(/^\uFEFF/, ''), errors); + const fatalError = errors.find(error => error.code !== 'missing-value'); + if (fatalError) { + logPromptOverrideFailure(logService, source, `Failed to parse prompt override from "${source}"`, fatalError.message); + return undefined; + } + warnedSources.delete(source); + if (document?.type !== 'map') { + return undefined; + } + + const systemPrompt = getStringProperty(document, 'systemPrompt'); + const toolDescriptionsNode = getProperty(document, 'toolDescriptions'); + const toolDescriptions: Record = {}; + if (toolDescriptionsNode?.type === 'map') { + for (const toolProperty of toolDescriptionsNode.properties) { + if (toolProperty.value.type !== 'map') { + continue; + } + const description = getStringProperty(toolProperty.value, 'description'); + if (description !== undefined) { + toolDescriptions[toolProperty.key.value] = { description }; + } + } + } + return { + ...(systemPrompt !== undefined ? { systemPrompt } : {}), + ...(toolDescriptionsNode?.type === 'map' ? { toolDescriptions } : {}), + }; +} + +function getProperty(map: YamlMapNode, name: string): YamlNode | undefined { + return map.properties.find(property => property.key.value === name)?.value; +} + +function getStringProperty(map: YamlMapNode, name: string): string | undefined { + const value = getProperty(map, name); + return value?.type === 'scalar' && value.value.length > 0 ? value.value : undefined; +} + +function logPromptOverrideFailure(logService: ILogService, source: string, message: string, error: unknown): void { + if (warnedSources.has(source)) { + logService.trace(`[PromptOverride] ${message}: ${error}`); + } else { + warnedSources.add(source); + logService.warn(`[PromptOverride] ${message}: ${error}`); + } +} diff --git a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts index 3d9582571d0491..2f153cecf73b81 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts @@ -64,10 +64,9 @@ export const COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS = [ * Builds a {@link SystemMessageConfig} that fully replaces the CLI/SDK system * prompt with `content`. * - * ⚠️ `replace` mode drops ALL SDK guardrails (including security restrictions); - * prefer {@link sectionOverrides} unless the caller intends to own the entire - * prompt. The registry still appends the universal layers afterwards, so a - * replacement owns the prompt body but not the host's response-format contracts. + * ⚠️ `replace` mode drops ALL SDK guardrails (including security restrictions). + * The prompt registry appends its universal layers when this config passes + * through it; direct SDK callers receive only this replacement. */ export function fullSystemPrompt(content: string): SystemMessageConfig { return { mode: 'replace', content }; diff --git a/src/vs/platform/agentHost/node/tunnelHostMainService.ts b/src/vs/platform/agentHost/node/tunnelHostMainService.ts deleted file mode 100644 index a7831c3213555f..00000000000000 --- a/src/vs/platform/agentHost/node/tunnelHostMainService.ts +++ /dev/null @@ -1,169 +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 { DeferredPromise, raceTimeout } from '../../../base/common/async.js'; -import { CancellationError } from '../../../base/common/errors.js'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; -import { joinPath } from '../../../base/common/resources.js'; -import { localize } from '../../../nls.js'; -import { INativeEnvironmentService } from '../../environment/common/environment.js'; -import { ILogger, ILoggerService } from '../../log/common/log.js'; -import { ITunnelProcessCoordinator, ITunnelProcessOutput, ITunnelProcessStatus } from '../../remoteTunnel/node/tunnelProcessCoordinator.js'; -import { - ITunnelAgentHostHostingService, - type ITunnelHostInfo, - type TunnelHostStatus, - TUNNEL_HOST_LOG_ID, -} from '../common/tunnelAgentHost.js'; - -const AGENT_HOST_START_TIMEOUT_MS = 5 * 60 * 1000; - -/** Publishes agent host sharing status while the coordinator owns the tunnel process. */ -export class TunnelHostMainService extends Disposable implements ITunnelAgentHostHostingService { - - declare readonly _serviceBrand: undefined; - - private readonly _onDidChangeStatus = this._register(new Emitter()); - readonly onDidChangeStatus: Event = this._onDidChangeStatus.event; - - private readonly _logger: ILogger; - private _request: { token: string } | undefined; - private _lastStatus: TunnelHostStatus = { active: false }; - - constructor( - @ILoggerService loggerService: ILoggerService, - @INativeEnvironmentService environmentService: INativeEnvironmentService, - @ITunnelProcessCoordinator private readonly tunnelProcessCoordinator: ITunnelProcessCoordinator, - ) { - super(); - this._logger = this._register(loggerService.createLogger( - joinPath(environmentService.logsHome, `${TUNNEL_HOST_LOG_ID}.log`), - { id: TUNNEL_HOST_LOG_ID, name: localize('tunnelHost.log', "Remote Connections") }, - )); - this._register(tunnelProcessCoordinator.onDidChangeStatus(status => this._emitStatus(status))); - this._register(tunnelProcessCoordinator.onDidOutput(output => this._handleOutput(output))); - } - - async startHosting(token: string, authProvider: 'github' | 'microsoft'): Promise { - const request = { token }; - this._request = request; - // The readiness wait is created before the intent is handed over, so it - // can outlive a rejection from the coordinator. Owning its store here - // tears the wait down immediately instead of leaving it pending until - // the start timeout elapses. - const store = new DisposableStore(); - try { - // Awaited together so the readiness promise always has a handler - // attached: disposing the store below rejects it, which would - // otherwise go unhandled on exactly the failure path this guards. - const [status] = await Promise.all([ - this._waitForActiveStatus(store), - this.tunnelProcessCoordinator.setAgentHostSharing({ token, authProvider, logLevel: this._logger.getLevel() }), - ]); - return status.info; - } catch (error) { - // Without this the caller sees a failure while the sharing intent - // survives, and a later reconcile brings hosting online anyway. - // A newer request owns the intent, so only roll back our own. - if (this._request === request) { - this._request = undefined; - try { - await this.tunnelProcessCoordinator.setAgentHostSharing(undefined); - } catch (rollbackError) { - this._logger.error(rollbackError); - } - } - throw error; - } finally { - store.dispose(); - } - } - - async stopHosting(): Promise { - this._request = undefined; - await this.tunnelProcessCoordinator.setAgentHostSharing(undefined); - this._emitStatus(this.tunnelProcessCoordinator.getStatus()); - } - - getStatus(): Promise { - return Promise.resolve(this._getStatus(this.tunnelProcessCoordinator.getStatus())); - } - - private _handleOutput(output: ITunnelProcessOutput): void { - if (output.mode !== 'agentHost') { - return; - } - if (output.isError) { - this._logger.error(output.message); - } else { - this._logger.info(output.message); - } - } - - private async _waitForActiveStatus(store: DisposableStore): Promise { - const current = this._getStatus(this.tunnelProcessCoordinator.getStatus()); - if (current.active) { - return current; - } - - const settled = new DeferredPromise(); - store.add(this.tunnelProcessCoordinator.onDidChangeStatus(coordinatorStatus => { - const status = this._getStatus(coordinatorStatus); - if (status.active) { - settled.complete(status); - } else if (coordinatorStatus.mode === 'agentHost' && coordinatorStatus.connectionState === 'disconnected') { - settled.error(new Error(localize('tunnelHost.startFailed', "The agent host tunnel exited before it became ready."))); - } - })); - // Settles the race when the caller abandons the wait, so neither this - // promise nor `raceTimeout`'s timer outlives the store. - store.add(toDisposable(() => settled.error(new CancellationError()))); - - const status = await raceTimeout(settled.p, AGENT_HOST_START_TIMEOUT_MS); - if (!status) { - throw new Error(localize('tunnelHost.startTimeout', "Timed out waiting for the agent host tunnel to start.")); - } - return status; - } - - private _getStatus(status: ITunnelProcessStatus): TunnelHostStatus { - if (!this._request || status.connectionState !== 'connected' || !status.tunnelName) { - return { active: false }; - } - const info = { - tunnelName: status.tunnelName, - ...(status.tunnelId === undefined ? {} : { tunnelId: status.tunnelId }), - }; - if (status.mode === 'remoteAccess' || status.mode === 'service') { - return { active: true, info: { ...info, viaRemoteTunnelAccess: true } }; - } - if (status.mode === 'agentHost') { - return { active: true, info }; - } - return { active: false }; - } - - private _emitStatus(coordinatorStatus: ITunnelProcessStatus): void { - const status = this._getStatus(coordinatorStatus); - if (!status.active && !this._lastStatus.active) { - return; - } - if (status.active && this._lastStatus.active - && status.info.tunnelName === this._lastStatus.info.tunnelName - && status.info.tunnelId === this._lastStatus.info.tunnelId - && status.info.viaRemoteTunnelAccess === this._lastStatus.info.viaRemoteTunnelAccess) { - return; - } - this._lastStatus = status; - this._onDidChangeStatus.fire(status); - } - - override dispose(): void { - this._request = undefined; - void this.tunnelProcessCoordinator.setAgentHostSharing(undefined); - super.dispose(); - } -} diff --git a/src/vs/platform/agentHost/test/common/copilotCliConfig.test.ts b/src/vs/platform/agentHost/test/common/copilotCliConfig.test.ts index 8abdfda9bcd736..6a5b27203edde5 100644 --- a/src/vs/platform/agentHost/test/common/copilotCliConfig.test.ts +++ b/src/vs/platform/agentHost/test/common/copilotCliConfig.test.ts @@ -37,8 +37,8 @@ suite('copilotCliConfig', () => { test('resolveModelCapabilityOverrideField prefers a usable specific value, then the wildcard', () => { const isString = (value: unknown): value is string => typeof value === 'string'; const overrides: CopilotCliModelCapabilityOverrides = { - '*': { family: 'gpt-5', reasoningEffort: 'medium' }, - 'preview-model-x': { family: 'claude-opus-4.8' }, + '*': { family: 'gpt-5', reasoningEffort: 'medium', promptOverrideString: 'systemPrompt: wildcard prompt' }, + 'preview-model-x': { family: 'claude-opus-4.8', promptOverrideFile: '/prompts/specific.yaml' }, 'bad-model': { family: 42 as never }, }; const invalid: unknown[] = []; @@ -48,6 +48,9 @@ suite('copilotCliConfig', () => { resolveModelCapabilityOverrideField(overrides, 'preview-model-x', 'family', isString), // unset specific field falls back to the wildcard resolveModelCapabilityOverrideField(overrides, 'preview-model-x', 'reasoningEffort', isString), + // prompt overrides use the same specific-then-wildcard field resolution + resolveModelCapabilityOverrideField(overrides, 'preview-model-x', 'promptOverrideString', isString), + resolveModelCapabilityOverrideField(overrides, 'preview-model-x', 'promptOverrideFile', isString), // an invalid specific value falls through instead of masking the wildcard resolveModelCapabilityOverrideField(overrides, 'bad-model', 'family', isString, value => invalid.push(value)), // no model id (server-side "Auto"): only the wildcard can match @@ -58,7 +61,7 @@ suite('copilotCliConfig', () => { resolveModelCapabilityOverrideField({ 'preview-model-x': 'oops' as never, '*': 42 as never }, 'preview-model-x', 'family', isString), invalid, ], - ['claude-opus-4.8', 'medium', 'gpt-5', 'gpt-5', undefined, undefined, undefined, [42]] + ['claude-opus-4.8', 'medium', 'systemPrompt: wildcard prompt', '/prompts/specific.yaml', 'gpt-5', 'gpt-5', undefined, undefined, undefined, [42]] ); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index 5cf5dd3f399f91..d36874140a29e9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -1842,6 +1842,18 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { } } + /** Polls until the independently published primary branch changeset settles. */ + async function waitForBranchCompute(svc: AgentHostChangesetService, stateManager: AgentHostStateManager): Promise { + const branchUri = buildBranchChangesetUri(sessionStr); + for (let i = 0; i < 500; i++) { + const status = stateManager.getChangesetState(branchUri)?.status; + if (!svc.isStaticChangesetComputeActive(branchUri) && status !== ChangesetStatus.Computing) { + return; + } + await timeout(1); + } + } + test('sums every repository branch diff, not just the primary', async () => { const git = createNoopGitService(); git.getRepositoryRoot = async wd => URI.parse(wd.toString()); @@ -1983,7 +1995,7 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { ); }); - test('a repository whose branch diff throws is skipped and logged, without failing the aggregate', async () => { + test('a repository branch diff failure leaves a cold summary unavailable without failing the branch changeset', async () => { const log = new RecordingLogService(); const git = createNoopGitService(); git.getRepositoryRoot = async wd => URI.parse(wd.toString()); @@ -1994,14 +2006,23 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { if (root === 'file:///repoGood2') { return [gitDiff('/repoGood2/b.ts', 5, 1)]; } return undefined; }; - const { svc, stateManager } = build({ workingDirectories: ['file:///repoGood1', 'file:///repoBad', 'file:///repoGood2'], git, checkpoint: NULL_CHECKPOINT_SERVICE, log }); + const db = new TestSessionDatabase(); + const { svc, stateManager } = build({ workingDirectories: ['file:///repoGood1', 'file:///repoBad', 'file:///repoGood2'], git, checkpoint: NULL_CHECKPOINT_SERVICE, db, log }); svc.refreshBranchChangeset(sessionStr); - const changes = await waitForSummaryChanges(stateManager); + await waitForBranchCompute(svc, stateManager); - // repoBad is skipped; the aggregate is the sum of the two good repos. - assert.deepStrictEqual(changes, { additions: 7, deletions: 1, files: 2 }, 'the failing repository is excluded, the rest still counted'); - assert.ok(log.errors.some(e => e.includes('repoBad')), `expected an error naming the failing repository, got ${JSON.stringify(log.errors)}`); + assert.deepStrictEqual({ + live: stateManager.getSessionSummary(sessionStr)?.changes, + persisted: await db.getMetadata(META_CHANGES_SUMMARY), + branchStatus: stateManager.getChangesetState(buildBranchChangesetUri(sessionStr))?.status, + loggedRepoBad: log.errors.some(e => e.includes('repoBad')), + }, { + live: undefined, + persisted: undefined, + branchStatus: ChangesetStatus.Ready, + loggedRepoBad: true, + }, 'one failed source prevents partial publication without failing the primary branch changeset'); }); test('threads a base branch per repository (primary uses the session base, secondaries their default)', async () => { @@ -2027,18 +2048,35 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { assert.ok(repoB.length > 0 && repoB.every(c => c.baseBranch === 'develop'), `secondary repo must use its own default branch (not HEAD), got ${JSON.stringify(repoB)}`); }); - test('all-folder summary is computed even when the primary branch diff is unavailable', async () => { + test('partial recompute preserves cached all-folder summary when the primary branch diff is unavailable', async () => { + let primaryAvailable = true; const git = createNoopGitService(); git.getRepositoryRoot = async wd => URI.parse(wd.toString()); - // The PRIMARY repo (repoA) has no resolvable branch diff; repoB does. - git.computeSessionFileDiffs = async wd => wd.toString() === 'file:///repoB' ? [gitDiff('/repoB/b.ts', 4, 1)] : undefined; + git.computeSessionFileDiffs = async wd => { + const root = wd.toString(); + if (root === 'file:///repoA') { return primaryAvailable ? [gitDiff('/repoA/a.ts', 3, 1)] : undefined; } + if (root === 'file:///repoB') { return [gitDiff('/repoB/b.ts', 5, 2)]; } + return undefined; + }; const db = new TestSessionDatabase(); const { svc, stateManager } = build({ workingDirectories: ['file:///repoA', 'file:///repoB'], git, checkpoint: NULL_CHECKPOINT_SERVICE, db }); svc.refreshBranchChangeset(sessionStr); - const changes = await waitForSummaryChanges(stateManager); + await waitForSummaryChanges(stateManager); - assert.deepStrictEqual(changes, { additions: 4, deletions: 1, files: 1 }, 'the all-folder chip is independent of the primary branch changeset succeeding'); + primaryAvailable = false; + svc.refreshBranchChangeset(sessionStr); + await waitForBranchCompute(svc, stateManager); + + assert.deepStrictEqual({ + live: stateManager.getSessionSummary(sessionStr)?.changes, + persisted: JSON.parse((await db.getMetadata(META_CHANGES_SUMMARY))!), + branchStatus: stateManager.getChangesetState(buildBranchChangesetUri(sessionStr))?.status, + }, { + live: { additions: 8, deletions: 3, files: 2 }, + persisted: { additions: 8, deletions: 3, files: 2 }, + branchStatus: ChangesetStatus.Ready, + }, 'an unavailable primary source preserves the last complete all-folder summary'); }); test('folds non-git folder edits into the all-folder chip', async () => { @@ -2119,7 +2157,7 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { }, 'a genuinely empty all-folder aggregate is written as zero, not preserved'); }); - test('a secondary default-branch lookup rejection yields a partial summary and keeps the branch changeset Ready (never Error)', async () => { + test('a secondary default-branch lookup rejection leaves a cold summary unavailable and keeps the branch changeset Ready', async () => { const log = new RecordingLogService(); const git = createNoopGitService(); git.getRepositoryRoot = async wd => URI.parse(wd.toString()); @@ -2138,19 +2176,21 @@ suite('AgentHostChangesetService - multi-root turn changeset', () => { const { svc, stateManager } = build({ workingDirectories: ['file:///repoA', 'file:///repoB'], git, checkpoint: NULL_CHECKPOINT_SERVICE, db, log }); svc.refreshBranchChangeset(sessionStr); - const changes = await waitForSummaryChanges(stateManager); + await waitForBranchCompute(svc, stateManager); assert.deepStrictEqual({ - changes, + live: stateManager.getSessionSummary(sessionStr)?.changes, + persisted: await db.getMetadata(META_CHANGES_SUMMARY), branchStatus: stateManager.getChangesetState(buildBranchChangesetUri(sessionStr))?.status, + branchFiles: stateManager.getChangesetState(buildBranchChangesetUri(sessionStr))?.files.map(file => file.id), loggedRepoB: log.errors.some(e => e.includes('repoB')), }, { - // repoB is unavailable (its default-branch probe threw); only the - // primary repoA contributes to the partial aggregate. - changes: { additions: 3, deletions: 1, files: 1 }, + live: undefined, + persisted: undefined, branchStatus: ChangesetStatus.Ready, + branchFiles: [URI.file('/repoA/a.ts').toString()], loggedRepoB: true, - }, 'a secondary default-branch failure must not flip the published branch changeset to Error'); + }, 'a secondary failure must not publish a partial summary or fail the independent primary branch changeset'); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPromptOverride.test.ts b/src/vs/platform/agentHost/test/node/agentHostPromptOverride.test.ts new file mode 100644 index 00000000000000..6035a2c701c255 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostPromptOverride.test.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Tool } from '@github/copilot-sdk'; +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { applyConfiguredPromptOverrides } from '../../node/copilot/prompts/promptOverride.js'; + +suite('AgentHostPromptOverride', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('inline YAML overrides the system prompt and tool descriptions and takes precedence over a file', async () => { + const logService = new NullLogService(); + const fileService = disposables.add(new FileService(logService)); + const tools: Tool[] = [ + { name: 'read_file', description: 'Read a file' }, + { name: 'run_tests', description: 'Run tests' }, + ]; + const result = await applyConfiguredPromptOverrides([ + 'systemPrompt: You are an evaluation agent.', + 'toolDescriptions:', + ' read_file:', + ' description: Read exactly one file.', + ].join('\n'), '/path/that/must/not/be/read.yaml', tools, fileService, logService); + + assert.deepStrictEqual(result, { + systemPrompt: 'You are an evaluation agent.', + tools: [ + { name: 'read_file', description: 'Read exactly one file.' }, + { name: 'run_tests', description: 'Run tests' }, + ], + }); + }); + + test('applies BOM-prefixed overrides and ignores empty values', async () => { + const logService = new NullLogService(); + const fileService = disposables.add(new FileService(logService)); + const tools: Tool[] = [ + { name: 'date_prefixed', description: 'original' }, + { name: 'null_description', description: 'original' }, + { name: 'quoted_empty_description', description: 'original' }, + ]; + const result = await applyConfiguredPromptOverrides('\uFEFF' + [ + 'systemPrompt: 2024-01-02 evaluation agent', + 'toolDescriptions:', + ' date_prefixed:', + ' description: 2024-01-02 do exactly one thing', + ' null_description:', + ' description:', + ' quoted_empty_description:', + ' description: ""', + ].join('\n'), undefined, tools, fileService, logService); + + assert.deepStrictEqual(result, { + systemPrompt: '2024-01-02 evaluation agent', + tools: [ + { name: 'date_prefixed', description: '2024-01-02 do exactly one thing' }, + { name: 'null_description', description: 'original' }, + { name: 'quoted_empty_description', description: 'original' }, + ], + }); + }); + + test('ignores a quoted empty system prompt', async () => { + const logService = new NullLogService(); + const fileService = disposables.add(new FileService(logService)); + const result = await applyConfiguredPromptOverrides('systemPrompt: ""', undefined, [], fileService, logService); + + assert.deepStrictEqual(result, { tools: [] }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 51b6b71d55ac2f..f138537d73871c 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -308,6 +308,16 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { return true; } + async updateSessionModifiedTimes(updates: readonly { readonly session: string; readonly modifiedTime: number }[]): Promise { + for (const { session, modifiedTime } of updates) { + this._beforeWrite(); + const existing = this._sessions.get(session); + if (existing && Number.isFinite(modifiedTime) && existing.modifiedTime < modifiedTime) { + this._sessions.set(session, { ...existing, modifiedTime }); + } + } + } + async listSessions(): Promise { this.undefinedExternalListCalls++; return [...this._sessions.values()].map(session => this._sessionsWithoutExternal.has(session.session) @@ -387,6 +397,9 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { private readonly _tombstones = new Set(); private readonly _agentMergeEnabled = new Set(); private _backfilled = false; + /** Test spies for the batched recency-write path. */ + updateSessionModifiedTimesCalls = 0; + lastModifiedTimesBatchSize = 0; async registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { if (registerOptions.checkTombstone && this._tombstones.has(session)) { @@ -425,6 +438,17 @@ class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { return true; } + async updateSessionModifiedTimes(updates: readonly { readonly session: string; readonly modifiedTime: number }[]): Promise { + this.updateSessionModifiedTimesCalls++; + this.lastModifiedTimesBatchSize = updates.length; + for (const { session, modifiedTime } of updates) { + const existing = this._sessions.get(session); + if (existing && Number.isFinite(modifiedTime) && existing.modifiedTime < modifiedTime) { + this._sessions.set(session, { ...existing, modifiedTime }); + } + } + } + async listSessions(): Promise { return [...this._sessions.values()]; } @@ -3487,6 +3511,50 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('discovery batches recency advances: unchanged times write nothing, advances produce one batch and one invalidation', async () => { + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const svc = createExternalSessionService(createSessionDataService(), orchestratorDb); + const agent = disposables.add(new MockAgent('copilot')); + const a = AgentSession.uri('copilot', 'batch-a'); + const b = AgentSession.uri('copilot', 'batch-b'); + for (const s of [a, b]) { + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(s), s); + } + registerTestAgentProvider(svc, agent); + const register = (chats: readonly IAgentDiscoveredChat[]) => (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, chats); + const epoch = () => (svc as unknown as { _registryEpoch: number })._registryEpoch; + + // Seed both sessions at a fixed, high baseline so the assertions below are + // independent of any earlier auto-registration timestamp. + const base = Date.now() + 1_000_000; + await register([discoveredChat(a, false, base), discoveredChat(b, false, base)]); + + // Rediscover with UNCHANGED timestamps -> no batched write, no invalidation. + const batchesBeforeNoop = orchestratorDb.updateSessionModifiedTimesCalls; + const epochBeforeNoop = epoch(); + await register([discoveredChat(a, false, base), discoveredChat(b, false, base)]); + + // Rediscover with NEWER timestamps for both -> exactly one batch and one invalidation. + const batchesBeforeAdvance = orchestratorDb.updateSessionModifiedTimesCalls; + const epochBeforeAdvance = epoch(); + await register([discoveredChat(a, false, base + 1000), discoveredChat(b, false, base + 1000)]); + + assert.deepStrictEqual({ + noopBatches: batchesBeforeAdvance - batchesBeforeNoop, + noopInvalidations: epochBeforeAdvance - epochBeforeNoop, + advanceBatches: orchestratorDb.updateSessionModifiedTimesCalls - batchesBeforeAdvance, + advanceInvalidations: epoch() - epochBeforeAdvance, + advanceBatchSize: orchestratorDb.lastModifiedTimesBatchSize, + }, { + noopBatches: 0, + noopInvalidations: 0, + advanceBatches: 1, + advanceInvalidations: 1, + advanceBatchSize: 2, + }); + }); + + testWithExternalSessionClock('discovery does not ingest external sessions older than 30 days', async () => { const day = 24 * 60 * 60 * 1000; const now = Date.now(); diff --git a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts index 8c903898511be5..6cf1b9e13d61bd 100644 --- a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts @@ -84,6 +84,16 @@ class TestAgentHostDatabase implements IAgentHostDatabase { return true; } + async updateSessionModifiedTimes(updates: readonly { readonly session: string; readonly modifiedTime: number }[]): Promise { + this._throwWriteFailure(); + for (const { session, modifiedTime } of updates) { + const existing = this.sessions.get(session); + if (existing && Number.isFinite(modifiedTime) && existing.modifiedTime < modifiedTime) { + this.sessions.set(session, { ...existing, modifiedTime }); + } + } + } + async listSessions(): Promise { this._throwReadFailure(); this.listCalls++; diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index b9155b6cbf610d..e46414cdd6e7fd 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -2403,6 +2403,60 @@ suite('CopilotAgent', () => { } }); + test('requests reauthentication when the credential is cleared', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + const agent = createTestAgent(disposables, { copilotClient: client }); + const authRequests: Array<{ readonly resource: ProtectedResourceMetadata; readonly reason?: string }> = []; + disposables.add(autorun(reader => { + const requirement = agent.authenticationRequired.read(reader); + if (requirement) { + authRequests.push(requirement); + } + })); + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await waitForState(agent.models, models => models.length > 0); + // Another client revoked the shared credential; the resulting SDK failure + // is a local InvalidArg rather than a 401, so the cleared token itself has + // to advertise the requirement or no client is ever asked to re-supply one. + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, ''); + await waitForState(agent.models, models => models.length === 0); + + assert.deepStrictEqual(authRequests, [{ + resource: GITHUB_COPILOT_PROTECTED_RESOURCE, + reason: 'expired', + }]); + } finally { + await disposeAgent(agent); + } + }); + + test('keeps the requirement raised when a second revocation arrives while tokenless', async () => { + const client = new TestCopilotClient([], [{ + id: 'gpt-4o', + name: 'GPT-4o', + }]); + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await waitForState(agent.models, models => models.length > 0); + // The host forwards every revocation to every provider, so a second + // client revoking must not retract the outstanding requirement. + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, ''); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, ''); + + assert.deepStrictEqual(agent.authenticationRequired.get(), { + resource: GITHUB_COPILOT_PROTECTED_RESOURCE, + reason: 'expired', + }); + } finally { + await disposeAgent(agent); + } + }); + test('retries refreshing models after a transient failure', async () => { const client = new TestCopilotClient([], [{ id: 'gpt-4o', @@ -3313,6 +3367,42 @@ suite('CopilotAgent', () => { }); }); + suite('prewarmSessionMetadata cache', () => { + test('serves getChatMetadata from one bulk list, falls back on miss, and reverts after disposal', async () => { + const sessionA = AgentSession.uri('copilotcli', 'prewarm-a'); + const sessionB = AgentSession.uri('copilotcli', 'prewarm-b'); + const sessionMissing = AgentSession.uri('copilotcli', 'prewarm-missing'); + const client = new TestCopilotClient([sdkSession('prewarm-a'), sdkSession('prewarm-b')]); + const agent = createTestAgent(disposables, { copilotClient: client }); + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + const read = (session: URI) => agent.getChatMetadata(defaultChatUri(session), exactChatContext(session, defaultChatUri(session), session)); + + const warm = await agent.prewarmSessionMetadata(); + // One bulk list warmed the cache; a hit is served without a per-session RPC. + await read(sessionA); + const afterHit = { listCalls: client.listSessionCallCount, rpcCalls: [...client.getSessionMetadataCalls] }; + + // A session absent from the bulk list falls back to a per-session RPC. + await read(sessionMissing); + const afterMiss = [...client.getSessionMetadataCalls]; + + // After disposal the cache is cleared and normal per-session reads resume. + warm.dispose(); + await read(sessionB); + const afterDisposal = [...client.getSessionMetadataCalls]; + + assert.deepStrictEqual({ afterHit, afterMiss, afterDisposal }, { + afterHit: { listCalls: 1, rpcCalls: [] }, + afterMiss: ['prewarm-missing'], + afterDisposal: ['prewarm-missing', 'prewarm-b'], + }); + } finally { + await disposeAgent(agent); + } + }); + }); + suite('restart on startup config change', () => { class StopCountingClient extends TestCopilotClient { @@ -8293,6 +8383,61 @@ suite('CopilotAgent', () => { } }); + test('materialization passes prompt and tool description overrides to the SDK', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + let capturedConfig: Parameters[0] | undefined; + client.createSession = async config => { + capturedConfig = config; + return new MockCopilotSession() as unknown as CopilotSession; + }; + + const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); + try { + configurationService.updateRootConfig({ + [CopilotCliConfigKey.ModelCapabilityOverrides]: { + '*': { + promptOverrideString: [ + 'systemPrompt: You are an evaluation agent.', + 'toolDescriptions:', + ' test_tool:', + ' description: Overridden tool description.', + ].join('\n'), + }, + }, + }); + await agent.authenticate('https://api.github.com', 'token'); + + const result = await provisionSession(agent, { + session: AgentSession.uri('copilotcli', 'system-message-override-session'), + workingDirectories: [URI.file('/workspace')], + activeClient: { + clientId: 'client-1', + tools: [{ name: 'test_tool', description: 'Original tool description.', inputSchema: { type: 'object' } }], + customizations: [], + }, + }); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session)); + + const testTool = capturedConfig?.tools?.find(tool => tool.name === 'test_tool'); + assert.deepStrictEqual({ + systemMessage: capturedConfig?.systemMessage, + tool: testTool && { name: testTool.name, description: testTool.description }, + }, { + systemMessage: { + mode: 'replace', + content: 'You are an evaluation agent.', + }, + tool: { + name: 'test_tool', + description: 'Overridden tool description.', + }, + }); + } finally { + await disposeAgent(agent); + } + }); + test('materialization applies the per-model capability overrides without changing the wire model', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const client = new TestCopilotClient([], [{ id: 'claude-sonnet', name: 'Claude Sonnet' }]); @@ -11584,6 +11729,40 @@ suite('CopilotAgent', () => { _resumeSession: (id: string) => Promise; }; + test('resume replaces the complete SDK system message when configured', async () => { + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/resume-system-message-`); + const promptOverrideFile = '/prompt.yaml'; + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([sdkSession('s1', workingDirectory)]); + let capturedSystemMessage: Parameters[1]['systemMessage']; + client.resumeSession = async (_sessionId, options) => { + capturedSystemMessage = options.systemMessage; + return new MockCopilotSession() as unknown as CopilotSession; + }; + const { agent, configurationService, fileService } = createTestAgentContext(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService }); + const provider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(Schemas.file, provider)); + await fileService.writeFile(URI.file(promptOverrideFile), VSBuffer.fromString('systemPrompt: |-\n You are an evaluation agent.\n')); + const internals = agent as unknown as AgentInternals; + try { + configurationService.updateRootConfig({ + [CopilotCliConfigKey.ModelCapabilityOverrides]: { + '*': { promptOverrideFile }, + }, + }); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await internals._resumeSession('s1'); + + assert.deepStrictEqual(capturedSystemMessage, { + mode: 'replace', + content: 'You are an evaluation agent.', + }); + } finally { + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + test('does not restore a persisted custom agent that is absent from the current plugin snapshot', async () => { const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/resume-agent-`); const sessionDataService = disposables.add(new TestSessionDataService()); diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index dc6cee45761dfa..ade6ec52a30297 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -800,6 +800,24 @@ Copilot errors currently end a turn without offering an in-place retry. The retr --grep "restores and resumes a turn interrupted by host shutdown" ``` +### Codex client-plugin discovery can stall the first turn + +A user can attach a client-provided plugin containing agents, rules, and skills to a Codex session and immediately send the first message. Plugin synchronization can overlap that turn and leave it incomplete, so the user receives no response. + +- Test: `customization discovery: configured plugin exposes its agent rule and skill children`. +- Scope: Codex on all platforms. +- Expected: the plugin is synchronized, its children are published, and the first turn completes. +- Observed: the first turn can time out after receiving plugin customization updates without receiving `chat/turnComplete` or `chat/error`. +- Gate: `supportsPluginCustomizationDiscoveryE2E: false`. +- Related failure: [build 469961](https://dev.azure.com/monacotools/Monaco/_build/results?buildId=469961&view=logs&j=e352877c-ff47-5dec-32e2-b206099d9704&t=b83513b4-f303-5ddc-d18a-1b47c02d8dad). +- Reproduce: temporarily set the gate to `true`, then run: + + ```bash + ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/codexAgentHostE2E.integrationTest.ts \ + --grep "customization discovery: configured plugin exposes its agent rule and skill children" + ``` + ## Test-design limitations ### Claude plan-mode prompt diff --git a/src/vs/platform/agentHost/test/node/e2e/README.md b/src/vs/platform/agentHost/test/node/e2e/README.md index 63bdebed0f3524..fee63fbf569091 100644 --- a/src/vs/platform/agentHost/test/node/e2e/README.md +++ b/src/vs/platform/agentHost/test/node/e2e/README.md @@ -437,10 +437,11 @@ File-operation capability and coverage are separate concerns. A provider with no ### Interpreting Codex pending tests -On platforms where Codex unified-shell replay is stable, the baseline suite has 11 intentionally pending registrations: +On platforms where Codex unified-shell replay is stable, the baseline suite has 12 intentionally pending registrations: - freeform and multi-select questions, because `request_user_input` requires non-empty, mutually exclusive options; - native streaming file creation and the two subagent scenarios, because Codex advertises neither capability; +- client-plugin discovery, because plugin synchronization can race the first turn and leave it incomplete; - the three live workspace-agent watcher scenarios, because Codex discovers workspace customizations initially but does not watch them; - mid-turn abort, which is record-only for every provider; - worktree include-file coverage, which remains behind its documented known-issue gate; and diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts index 301ec4fd3533e1..82befd2669a3ab 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/codexTestConfiguration.ts @@ -45,7 +45,8 @@ export const CODEX_CONFIG: IAgentHostE2EProviderConfig = { supportsPausedTurnCancellationE2E: true, supportsCustomizationDiscoveryE2E: true, supportsFixedInstructionDiscoveryE2E: true, - supportsPluginCustomizationDiscoveryE2E: true, + // Client-plugin synchronization can race the first turn and leave it incomplete. + supportsPluginCustomizationDiscoveryE2E: false, supportsChatFork: true, supportsChatForkE2E: true, supportsSideChats: true, diff --git a/src/vs/platform/agentHost/test/node/tunnelHostMainService.test.ts b/src/vs/platform/agentHost/test/node/tunnelHostMainService.test.ts deleted file mode 100644 index 5ff96915c15ad4..00000000000000 --- a/src/vs/platform/agentHost/test/node/tunnelHostMainService.test.ts +++ /dev/null @@ -1,219 +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 { Emitter, Event } from '../../../../base/common/event.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { INativeEnvironmentService } from '../../../environment/common/environment.js'; -import { LogLevel, NullLoggerService } from '../../../log/common/log.js'; -import { IAgentHostSharingRequest, ITunnelProcessCoordinator, ITunnelProcessMachineStatus, ITunnelProcessOutput, ITunnelProcessStatus } from '../../../remoteTunnel/node/tunnelProcessCoordinator.js'; -import { TunnelMode, TunnelStatus } from '../../../remoteTunnel/common/remoteTunnel.js'; -import { TunnelHostMainService } from '../../node/tunnelHostMainService.js'; - -class TestTunnelProcessCoordinator implements ITunnelProcessCoordinator { - declare readonly _serviceBrand: undefined; - - private readonly _onDidChangeStatus = new Emitter(); - readonly onDidChangeStatus = this._onDidChangeStatus.event; - readonly onDidOutput = Event.None as Event; - readonly onDidMachineStatus = Event.None as Event; - - constructor(private _status: ITunnelProcessStatus) { - } - - lastSharingRequest: IAgentHostSharingRequest | undefined; - sharingRequests: (IAgentHostSharingRequest | undefined)[] = []; - failNextSharingRequest = false; - - getStatus(): ITunnelProcessStatus { - return this._status; - } - - getIntendedTunnelName(): string { - return this._status.tunnelName ?? 'test_host'; - } - - setRemoteAccess(_mode: TunnelMode, _logLevel: LogLevel): Promise { - return Promise.resolve(); - } - - setAgentHostSharing(request: IAgentHostSharingRequest | undefined): Promise { - this.lastSharingRequest = request; - this.sharingRequests.push(request); - if (this.failNextSharingRequest) { - this.failNextSharingRequest = false; - return Promise.reject(new Error('coordinator refused the sharing intent')); - } - return Promise.resolve(); - } - - restart(): Promise { - return Promise.resolve(); - } - - setRemoteAccessStatus(_status: TunnelStatus): void { - } - - setStatus(status: ITunnelProcessStatus): void { - this._status = status; - this._onDidChangeStatus.fire(status); - } - - dispose(): void { - this._onDidChangeStatus.dispose(); - } -} - -suite('TunnelHostMainService', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('becomes ready when the coordinator reports a connected tunnel', async () => { - const coordinator = new TestTunnelProcessCoordinator({ mode: 'agentHost', tunnelName: 'agent', connectionState: 'connecting', serviceInstallFailed: false }); - const loggerService = new NullLoggerService(); - const service = new TunnelHostMainService( - loggerService, - { logsHome: URI.file('logs') } as INativeEnvironmentService, - coordinator, - ); - try { - const startHosting = service.startHosting('token', 'github'); - coordinator.setStatus({ mode: 'agentHost', tunnelName: 'agent', connectionState: 'connected', serviceInstallFailed: false }); - assert.deepStrictEqual(await startHosting, { tunnelName: 'agent' }); - } finally { - service.dispose(); - coordinator.dispose(); - loggerService.dispose(); - } - }); - - test('forwards the auth provider so Microsoft accounts can host', async () => { - const coordinator = new TestTunnelProcessCoordinator({ mode: 'agentHost', tunnelName: 'agent', connectionState: 'connecting', serviceInstallFailed: false }); - const loggerService = new NullLoggerService(); - const service = new TunnelHostMainService( - loggerService, - { logsHome: URI.file('logs') } as INativeEnvironmentService, - coordinator, - ); - try { - const startHosting = service.startHosting('token', 'microsoft'); - coordinator.setStatus({ mode: 'agentHost', tunnelName: 'agent', connectionState: 'connected', serviceInstallFailed: false }); - await startHosting; - assert.deepStrictEqual( - { token: coordinator.lastSharingRequest?.token, authProvider: coordinator.lastSharingRequest?.authProvider }, - { token: 'token', authProvider: 'microsoft' }, - ); - } finally { - service.dispose(); - coordinator.dispose(); - loggerService.dispose(); - } - }); - - test('fails when the agent host exits before reporting connected', async () => { - const coordinator = new TestTunnelProcessCoordinator({ mode: 'agentHost', tunnelName: 'agent', connectionState: 'connecting', serviceInstallFailed: false }); - const loggerService = new NullLoggerService(); - const service = new TunnelHostMainService( - loggerService, - { logsHome: URI.file('logs') } as INativeEnvironmentService, - coordinator, - ); - try { - const startHosting = service.startHosting('token', 'github'); - coordinator.setStatus({ mode: 'agentHost', tunnelName: 'agent', connectionState: 'disconnected', serviceInstallFailed: false }); - await assert.rejects(startHosting, /exited before it became ready/); - } finally { - service.dispose(); - coordinator.dispose(); - loggerService.dispose(); - } - }); - - test('clears the sharing intent when the agent host fails to start', async () => { - const coordinator = new TestTunnelProcessCoordinator({ mode: 'agentHost', tunnelName: 'agent', connectionState: 'connecting', serviceInstallFailed: false }); - const loggerService = new NullLoggerService(); - const service = new TunnelHostMainService( - loggerService, - { logsHome: URI.file('logs') } as INativeEnvironmentService, - coordinator, - ); - try { - const startHosting = service.startHosting('token', 'github'); - coordinator.setStatus({ mode: 'agentHost', tunnelName: 'agent', connectionState: 'disconnected', serviceInstallFailed: false }); - await assert.rejects(startHosting, /exited before it became ready/); - - // A stale intent would let a later reconcile bring hosting online - // even though the caller was told it failed. - assert.deepStrictEqual(coordinator.sharingRequests.at(-1), undefined); - } finally { - service.dispose(); - coordinator.dispose(); - loggerService.dispose(); - } - }); - - test('clears the sharing intent when the coordinator rejects the request', async () => { - const coordinator = new TestTunnelProcessCoordinator({ mode: 'agentHost', tunnelName: 'agent', connectionState: 'connecting', serviceInstallFailed: false }); - const loggerService = new NullLoggerService(); - const service = new TunnelHostMainService( - loggerService, - { logsHome: URI.file('logs') } as INativeEnvironmentService, - coordinator, - ); - try { - coordinator.failNextSharingRequest = true; - await assert.rejects(service.startHosting('token', 'github'), /refused the sharing intent/); - assert.deepStrictEqual(coordinator.sharingRequests.at(-1), undefined); - } finally { - service.dispose(); - coordinator.dispose(); - loggerService.dispose(); - } - }); - - test('derives public status from sharing intent and coordinator state', async () => { - const coordinator = new TestTunnelProcessCoordinator({ mode: 'remoteAccess', tunnelName: 'remote', connectionState: 'connected', serviceInstallFailed: false }); - const loggerService = new NullLoggerService(); - const service = new TunnelHostMainService( - loggerService, - { logsHome: URI.file('logs') } as INativeEnvironmentService, - coordinator, - ); - try { - const withoutRequest = await service.getStatus(); - coordinator.setStatus({ mode: 'agentHost', tunnelName: 'agent', connectionState: 'connected', serviceInstallFailed: false }); - await service.startHosting('token', 'github'); - const requestedAgentHost = await service.getStatus(); - coordinator.setStatus({ mode: 'agentHost', tunnelName: 'agent', connectionState: 'connecting', serviceInstallFailed: false }); - const requestedConnecting = await service.getStatus(); - coordinator.setStatus({ mode: 'agentHost', tunnelName: undefined, connectionState: 'connected', serviceInstallFailed: false }); - const requestedWithoutName = await service.getStatus(); - coordinator.setStatus({ mode: 'remoteAccess', tunnelName: 'remote', connectionState: 'connected', serviceInstallFailed: false }); - const requestedRemoteAccess = await service.getStatus(); - coordinator.setStatus({ mode: 'service', tunnelName: 'service', connectionState: 'connected', serviceInstallFailed: false }); - const requestedService = await service.getStatus(); - - assert.deepStrictEqual({ - withoutRequest, - requestedConnecting, - requestedWithoutName, - requestedAgentHost, - requestedRemoteAccess, - requestedService, - }, { - withoutRequest: { active: false }, - requestedConnecting: { active: false }, - requestedWithoutName: { active: false }, - requestedAgentHost: { active: true, info: { tunnelName: 'agent' } }, - requestedRemoteAccess: { active: true, info: { tunnelName: 'remote', viaRemoteTunnelAccess: true } }, - requestedService: { active: true, info: { tunnelName: 'service', viaRemoteTunnelAccess: true } }, - }); - } finally { - service.dispose(); - coordinator.dispose(); - loggerService.dispose(); - } - }); -}); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index a604525d9f1612..d186581be34e18 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -256,13 +256,19 @@ export function matchesBrowserViewAudience(candidate: IBrowserViewAudience, patt && (pattern.sessionId === undefined || pattern.sessionId === candidate.sessionId); } +/** Identifies the workbench window and optional Agents Window session that host a browser view. */ +export interface IBrowserViewHost { + readonly windowId: number; + readonly sessionId?: string; +} + /** * Summary information about a browser view, including its current state and * ownership. Returned by the main service when listing or creating views. */ export interface IBrowserViewInfo { readonly id: string; - readonly hostWindowId: number; + readonly host: IBrowserViewHost; readonly owner: IBrowserViewOwner; readonly associatedResource?: UriComponents; readonly state: IBrowserViewState; @@ -288,7 +294,7 @@ export interface IBrowserViewCreatedEvent { /** Host, ownership, storage, and initial access for a newly created browser view. */ export interface IBrowserViewCreationContext { - readonly hostWindowId: number; + readonly host: IBrowserViewHost; readonly owner: IBrowserViewOwner; readonly session: BrowserViewSessionSelector; /** Grants automation clients access before the view is announced to other processes. */ diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 3d5e5a67985d61..6831bae3194932 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -7,7 +7,7 @@ import { screen, WebContentsView, webContents } from 'electron'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewAudience, IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewEditorOpenOptions, IBrowserViewPermissionRequestEvent, equalsBrowserViewAudience, isBrowserViewAssociatedResourceNavigation, matchesBrowserViewAudience } from '../common/browserView.js'; +import { IBrowserViewAudience, IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewEditorOpenOptions, IBrowserViewPermissionRequestEvent, equalsBrowserViewAudience, isBrowserViewAssociatedResourceNavigation, matchesBrowserViewAudience, IBrowserViewHost } from '../common/browserView.js'; import { BrowserViewEmulator } from './browserViewEmulator.js'; import { BrowserViewInspector } from './browserViewInspector.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; @@ -123,7 +123,7 @@ export class BrowserView extends Disposable { constructor( public readonly id: string, - public readonly hostWindowId: number, + public readonly host: IBrowserViewHost, owner: IBrowserViewOwner, public readonly associatedResource: URI | undefined, public readonly session: BrowserSession, @@ -167,9 +167,9 @@ export class BrowserView extends Disposable { this._view.setBounds({ x: 0, y: 0, width: 1024, height: 768 }); this._view.setBackgroundColor('#FFFFFF'); - this._ownerWindow = this.windowsMainService.getWindowById(hostWindowId)!; + this._ownerWindow = this.windowsMainService.getWindowById(host.windowId)!; if (!this._ownerWindow) { - throw new Error(`Window with ID ${hostWindowId} not found`); + throw new Error(`Window with ID ${host.windowId} not found`); } this._register(this._ownerWindow.onDidClose(() => this.dispose())); this._register(this._ownerWindow.onWillLoad((e) => { diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index b235c432f59c51..a42b41d8cdb700 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -58,7 +58,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I super(); this._register(this.browserViewMainService.onDidCreateBrowserView(({ info }) => { - if (info.hostWindowId !== this.targetContext.hostWindowId) { + if (info.host.windowId !== this.targetContext.host.windowId) { return; } @@ -91,7 +91,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I } this._isActive = true; - const views = await this.browserViewMainService.getBrowserViews(this.targetContext.hostWindowId); + const views = await this.browserViewMainService.getBrowserViews(this.targetContext.host.windowId); await Promise.all(views.map(async info => { const view = this.browserViewMainService.tryGetBrowserView(info.id); if (view) { @@ -241,7 +241,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I const view = target.view.getWebContentsView(); const viewBounds = view.getBounds(); return { - windowId: this.targetContext.hostWindowId, + windowId: this.targetContext.host.windowId, bounds: { left: viewBounds.x, top: viewBounds.y, diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index a21b231300fd9e..d7676a4a66c9cb 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -128,7 +128,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private _getViewInfo(view: BrowserView): IBrowserViewInfo { return { id: view.id, - hostWindowId: view.hostWindowId, + host: view.host, owner: view.owner, associatedResource: view.associatedResource, state: view.getState() @@ -138,7 +138,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa async getBrowserViews(windowId?: number): Promise { const result: IBrowserViewInfo[] = []; for (const [, view] of this.browserViews) { - if (windowId !== undefined && view.hostWindowId !== windowId) { + if (windowId !== undefined && view.host.windowId !== windowId) { continue; } result.push(this._getViewInfo(view)); @@ -379,7 +379,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa this._ensureWindowCloseSubscription(windowId); for (const [, view] of this.browserViews) { - if (view.hostWindowId === windowId) { + if (view.host.windowId === windowId) { if (didThemeChange) { view.inspector.setTheme(config.theme); } @@ -427,13 +427,13 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa /** * Create a browser view backed by the given {@link BrowserSession}. */ - private _createNativeBrowserView(id: string, hostWindowId: number, owner: IBrowserViewOwner, browserSession: BrowserSession, associatedResource?: URI, options?: Electron.WebContentsViewConstructorOptions): BrowserView { + private _createNativeBrowserView(id: string, host: IBrowserViewCreationContext['host'], owner: IBrowserViewOwner, browserSession: BrowserSession, associatedResource?: URI, options?: Electron.WebContentsViewConstructorOptions): BrowserView { if (this.browserViews.has(id)) { throw new Error(`Browser view with id ${id} already exists`); } browserSession.connectStorage(this.applicationStorageMainService); - const windowConfiguration = this._windowConfigurations.get(hostWindowId); + const windowConfiguration = this._windowConfigurations.get(host.windowId); if (typeof windowConfiguration?.maxHistoryEntries === 'number') { browserSession.history.setMaxEntries(windowConfiguration.maxHistoryEntries); } @@ -444,14 +444,14 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa const view = this.instantiationService.createInstance( BrowserView, id, - hostWindowId, + host, owner, associatedResource, browserSession, // Child views share their host, owner, and storage, but do not implicitly inherit agent access. (childOwner, url, electronOptions, editorOptions) => { return this._createBrowserView(generateUuid(), { - hostWindowId, + host, owner: childOwner, session: browserSession.id, initialUrl: url || undefined @@ -474,8 +474,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } private _createBrowserView(id: string, options: IBrowserViewCreateOptions, editorOpenRequest?: IBrowserViewEditorOpenOptions, electronOptions?: Electron.WebContentsViewConstructorOptions): BrowserView { - const browserSession = this._resolveBrowserSession(id, options.hostWindowId, options.session); - const view = this._createNativeBrowserView(id, options.hostWindowId, options.owner, browserSession, URI.revive(options.associatedResource), electronOptions); + const browserSession = this._resolveBrowserSession(id, options.host.windowId, options.session); + const view = this._createNativeBrowserView(id, options.host, options.owner, browserSession, URI.revive(options.associatedResource), electronOptions); if (options.initialAudiences) { view.setAudiences(options.initialAudiences); } @@ -516,7 +516,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return; } - const windowConfiguration = this._windowConfigurations.get(view.hostWindowId); + const windowConfiguration = this._windowConfigurations.get(view.host.windowId); const inspectTarget = windowConfiguration?.aiFeaturesDisabled ? undefined : params.frame && await view.inspector.getElementHandle(BrowserViewInspectElementId.ContextMenuTarget, params.frame); @@ -527,7 +527,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa label: localize('browser.contextMenu.openLinkInNewTab', 'Open Link in New Tab'), click: () => { void this.openNew(params.linkURL, { - hostWindowId: view.hostWindowId, + host: view.host, owner: view.owner, session: view.session.id, }, { preserveFocus: true, background: true }, 'browserLinkBackground'); @@ -557,7 +557,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa label: localize('browser.contextMenu.openImageInNewTab', 'Open Image in New Tab'), click: () => { void this.openNew(params.srcURL!, { - hostWindowId: view.hostWindowId, + host: view.host, owner: view.owner, session: view.session.id, }, { preserveFocus: true, background: true }, 'browserLinkBackground'); diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 25fdd3ef5d3225..d3bb90c07c6fc3 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -117,7 +117,9 @@ export class PlaywrightService extends Disposable implements IPlaywrightService const group = await this.browserViewGroupRemoteService.createGroup( { audience: { type: 'agent', sessionId } }, { - hostWindowId: this.windowId, + host: { + windowId: this.windowId + }, ...getAgentBrowserViewCreationDefaults(sessionId, this.useSessionStorageAffinity ? sessionId : undefined) } ); diff --git a/src/vs/platform/remoteTunnel/browser/remoteTunnelService.ts b/src/vs/platform/remoteTunnel/browser/remoteTunnelService.ts new file mode 100644 index 00000000000000..22e57886b4f63d --- /dev/null +++ b/src/vs/platform/remoteTunnel/browser/remoteTunnelService.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; +import { ActiveTunnelMode, INACTIVE_TUNNEL_MODE, IRemoteTunnelService, TunnelMode, TunnelStates, TunnelStatus } from '../common/remoteTunnel.js'; + +/** + * Browser-safe Remote Tunnel service. Tunnel hosting requires the local CLI, + * but discovery consumers still need the authoritative inactive state. + */ +export class BrowserRemoteTunnelService implements IRemoteTunnelService { + + declare readonly _serviceBrand: undefined; + + readonly onDidChangeTunnelStatus = Event.None; + readonly onDidChangeMode = Event.None; + readonly onDidTokenFailed = Event.None; + + async getTunnelStatus(): Promise { + return TunnelStates.uninitialized; + } + + async getMode(): Promise { + return INACTIVE_TUNNEL_MODE; + } + + async initialize(_mode: TunnelMode): Promise { + return TunnelStates.uninitialized; + } + + async startTunnel(_mode: ActiveTunnelMode): Promise { + return TunnelStates.uninitialized; + } + + async stopTunnel(): Promise { } + + async getTunnelName(): Promise { + return undefined; + } +} + +registerSingleton(IRemoteTunnelService, BrowserRemoteTunnelService, InstantiationType.Delayed); diff --git a/src/vs/platform/remoteTunnel/common/remoteTunnel.ts b/src/vs/platform/remoteTunnel/common/remoteTunnel.ts index cc1c42ff351906..d56509f89de9cb 100644 --- a/src/vs/platform/remoteTunnel/common/remoteTunnel.ts +++ b/src/vs/platform/remoteTunnel/common/remoteTunnel.ts @@ -78,6 +78,7 @@ export interface ConnectionInfo { link?: string; domain?: string; tunnelName: string; + tunnelId?: string; isAttached: boolean; } diff --git a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts index bb94f444307258..53a88fbd770754 100644 --- a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts +++ b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts @@ -221,8 +221,13 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ return; } if (event.status.type === 'connected') { - const { tunnelName, isAttached, link, domain } = event.status; - const info: ConnectionInfo = { tunnelName, isAttached, ...(link === undefined ? {} : { link, domain }) }; + const { tunnelName, tunnelId, isAttached, link, domain } = event.status; + const info: ConnectionInfo = { + tunnelName, + isAttached, + ...(tunnelId === undefined ? {} : { tunnelId }), + ...(link === undefined ? {} : { link, domain }), + }; this.telemetryService.publicLog2('remoteTunnel.connected', { tunnelName: info.tunnelName, isAttached: info.isAttached, diff --git a/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts b/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts index eb6927627b804d..7eec16ff5d2113 100644 --- a/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts +++ b/src/vs/platform/remoteTunnel/node/tunnelProcessCoordinator.ts @@ -17,18 +17,11 @@ import { hostname } from 'os'; type TunnelCliFactory = (onLog: (message: string) => void) => CodeTunnelCli; -/** The process mode selected from the combined tunnel intents. */ -export type TunnelProcessMode = 'none' | 'agentHost' | 'remoteAccess' | 'service'; +/** The process mode selected from the Remote Tunnel Access intent. */ +export type TunnelProcessMode = 'none' | 'remoteAccess' | 'service'; /** The connection lifecycle state reported by the coordinator. */ export type TunnelProcessConnectionState = 'disconnected' | 'connecting' | 'connected'; -/** The requested agent-host-only sharing session. */ -export interface IAgentHostSharingRequest { - readonly token: string; - readonly authProvider: 'github' | 'microsoft'; - readonly logLevel: LogLevel; -} - /** Credentials passed to `tunnel user login`. */ interface ITunnelLoginCredentials { readonly providerId: string; @@ -69,7 +62,7 @@ export interface ITunnelProcessMachineStatus { cancel(): void; } -/** The single, resolved tunnel state shared by Remote Tunnel Access and agent host sharing. */ +/** The tunnel process state shared by Remote Tunnel Access consumers. */ export interface ITunnelProcessStatus { readonly mode: TunnelProcessMode; readonly tunnelName: string | undefined; @@ -81,7 +74,7 @@ export interface ITunnelProcessStatus { /** Service identifier for the shared-process tunnel coordinator. */ export const ITunnelProcessCoordinator = createDecorator('tunnelProcessCoordinator'); -/** Coordinates the one `code tunnel` process used by both shared-process tunnel consumers. */ +/** Coordinates the `code tunnel` process used by Remote Tunnel Access. */ export interface ITunnelProcessCoordinator { readonly _serviceBrand: undefined; readonly onDidChangeStatus: Event; @@ -90,17 +83,13 @@ export interface ITunnelProcessCoordinator { getStatus(): ITunnelProcessStatus; getIntendedTunnelName(): string; setRemoteAccess(mode: TunnelMode, logLevel: LogLevel): Promise; - setAgentHostSharing(request: IAgentHostSharingRequest | undefined): Promise; restart(): Promise; setRemoteAccessStatus(status: TunnelStatus): void; } -/** Resolves the process mode from the two independent tunnel intents. */ -export function resolveTunnelProcessMode(agentHostSharing: boolean, remoteAccess: TunnelMode): TunnelProcessMode { - if (remoteAccess.active) { - return remoteAccess.asService ? 'service' : 'remoteAccess'; - } - return agentHostSharing ? 'agentHost' : 'none'; +/** Resolves the process mode from the Remote Tunnel Access intent. */ +export function resolveTunnelProcessMode(remoteAccess: TunnelMode): TunnelProcessMode { + return remoteAccess.active ? (remoteAccess.asService ? 'service' : 'remoteAccess') : 'none'; } /** @@ -125,7 +114,6 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce private readonly _tunnelCli: CodeTunnelCli; private _remoteAccess: { mode: TunnelMode; logLevel: LogLevel } = { mode: { active: false }, logLevel: LogLevel.Info }; - private _agentHostSharing: IAgentHostSharingRequest | undefined; private _currentProcess: ICodeTunnelCliRun | undefined; private _queue: Promise = Promise.resolve(); private _generation = 0; @@ -174,11 +162,6 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce return this._schedule(wasService && (!mode.active || !mode.asService)); } - setAgentHostSharing(request: IAgentHostSharingRequest | undefined): Promise { - this._agentHostSharing = request; - return this._schedule(false); - } - restart(): Promise { return this._schedule(false, true); } @@ -277,9 +260,7 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce } this._setStatus({ mode: target.mode, tunnelName, tunnelId: undefined, connectionState: 'connecting', serviceInstallFailed: false }); - const isServiceInstalled = target.mode === 'service' || target.mode === 'remoteAccess' - ? await this._isServiceInstalled(generation) - : false; + const isServiceInstalled = await this._isServiceInstalled(generation); if (generation !== this._generation) { return; } @@ -311,14 +292,9 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce } const args = ['tunnel']; - if (target.mode === 'agentHost') { - args.push('--agent-host-only', '--name', tunnelName!, '--user-data-dir', this.environmentService.userDataPath); - args.push('--delegate-to-editor', '--parent-process-id', String(process.pid)); - } else { - args.push('--accept-server-license-terms', '--log', LogLevelToString(target.logLevel)); - args.push('--user-data-dir', this.environmentService.userDataPath, '--delegate-to-editor', '--name', tunnelName!, '--parent-process-id', String(process.pid)); - } - if (target.mode !== 'agentHost' && this._preventSleep()) { + args.push('--accept-server-license-terms', '--log', LogLevelToString(target.logLevel)); + args.push('--user-data-dir', this.environmentService.userDataPath, '--delegate-to-editor', '--name', tunnelName!, '--parent-process-id', String(process.pid)); + if (this._preventSleep()) { args.push('--no-sleep'); } this._launched = this._describeLaunch(target); @@ -326,26 +302,17 @@ export class TunnelProcessCoordinator extends Disposable implements ITunnelProce } /** - * The credentials the CLI needs for `tunnel user login`. Deliberately not an - * {@link IRemoteTunnelSession}: agent host sharing has no session, only a - * token, and fabricating one with empty ids would misrepresent that. + * The credentials the CLI needs for `tunnel user login`. */ private _getTarget(): ITunnelTarget { if (this._remoteAccess.mode.active) { const session = this._remoteAccess.mode.session; return { - mode: resolveTunnelProcessMode(!!this._agentHostSharing, this._remoteAccess.mode), + mode: resolveTunnelProcessMode(this._remoteAccess.mode), login: session.token ? { providerId: session.providerId, token: session.token } : undefined, logLevel: this._remoteAccess.logLevel, }; } - if (this._agentHostSharing) { - return { - mode: resolveTunnelProcessMode(true, this._remoteAccess.mode), - login: { providerId: this._agentHostSharing.authProvider, token: this._agentHostSharing.token }, - logLevel: this._agentHostSharing.logLevel, - }; - } return { mode: 'none', login: undefined, logLevel: LogLevel.Info }; } diff --git a/src/vs/platform/remoteTunnel/test/browser/remoteTunnelService.test.ts b/src/vs/platform/remoteTunnel/test/browser/remoteTunnelService.test.ts new file mode 100644 index 00000000000000..86fe167d74f407 --- /dev/null +++ b/src/vs/platform/remoteTunnel/test/browser/remoteTunnelService.test.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { getSingletonServiceDescriptors } from '../../../instantiation/common/extensions.js'; +import { INACTIVE_TUNNEL_MODE, IRemoteTunnelService } from '../../common/remoteTunnel.js'; +import { BrowserRemoteTunnelService } from '../../browser/remoteTunnelService.js'; + +suite('BrowserRemoteTunnelService', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('provides inactive remote tunnel state without requiring CLI hosting', async () => { + const service = new BrowserRemoteTunnelService(); + const descriptor = getSingletonServiceDescriptors().find(([id]) => id === IRemoteTunnelService)?.[1]; + + assert.deepStrictEqual({ + registeredConstructor: descriptor?.ctor, + mode: await service.getMode(), + status: await service.getTunnelStatus(), + startStatus: await service.startTunnel({ + active: true, + asService: false, + session: { providerId: 'github', sessionId: 'session', accountLabel: 'account' }, + }), + name: await service.getTunnelName(), + }, { + registeredConstructor: BrowserRemoteTunnelService, + mode: INACTIVE_TUNNEL_MODE, + status: { type: 'uninitialized' }, + startStatus: { type: 'uninitialized' }, + name: undefined, + }); + }); +}); diff --git a/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts b/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts index b6d65f7eb5ebf0..090ae5a64f7f10 100644 --- a/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts +++ b/src/vs/platform/remoteTunnel/test/node/remoteTunnelService.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { Event, Emitter } from '../../../../base/common/event.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; @@ -17,7 +18,7 @@ import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.j import { ActiveTunnelMode, TunnelMode, TunnelStatus } from '../../common/remoteTunnel.js'; import { TunnelMachineStatus } from '../../common/tunnelMachineStatus.js'; import { RemoteTunnelService } from '../../node/remoteTunnelService.js'; -import { IAgentHostSharingRequest, ITunnelProcessCoordinator, ITunnelProcessMachineStatus, ITunnelProcessOutput, ITunnelProcessStatus, TunnelProcessConnectionState, TunnelProcessMode } from '../../node/tunnelProcessCoordinator.js'; +import { ITunnelProcessCoordinator, ITunnelProcessMachineStatus, ITunnelProcessOutput, ITunnelProcessStatus, TunnelProcessConnectionState, TunnelProcessMode } from '../../node/tunnelProcessCoordinator.js'; import sinon from 'sinon'; class TestTunnelProcessCoordinator implements ITunnelProcessCoordinator { @@ -48,10 +49,6 @@ class TestTunnelProcessCoordinator implements ITunnelProcessCoordinator { return Promise.resolve(); } - setAgentHostSharing(_request: IAgentHostSharingRequest | undefined): Promise { - return Promise.resolve(); - } - restart(): Promise { return Promise.resolve(); } @@ -101,10 +98,13 @@ suite('Remote tunnel', () => { }; const tokenFailures: (typeof mode.session | undefined)[] = []; const tokenFailureListener = service.onDidTokenFailed(session => tokenFailures.push(session)); + const statusChanges: TunnelStatus[] = []; + let statusChangeListener: IDisposable | undefined; try { await service.initialize(mode); + statusChangeListener = service.onDidChangeTunnelStatus(status => statusChanges.push(status)); let didCancel = false; - coordinator.fireMachineStatus('remoteAccess', { type: 'connected', tunnelName: 'test_host', isAttached: true, link: 'https://vscode.dev/tunnel/test_host', domain: 'vscode.dev' }); + coordinator.fireMachineStatus('remoteAccess', { type: 'connected', tunnelName: 'test_host', tunnelId: 'tunnel-id', isAttached: true, link: 'https://vscode.dev/tunnel/test_host', domain: 'vscode.dev' }); const linkedStatus = await service.getTunnelStatus(); coordinator.fireMachineStatus('remoteAccess', { type: 'connected', tunnelName: 'test_host', isAttached: false }); const noLinkStatus = await service.getTunnelStatus(); @@ -115,6 +115,7 @@ suite('Remote tunnel', () => { linkedStatus, noLinkStatus, disconnectedStatus, + statusChanges, tokenFailures, didCancel, telemetryCallCount: publicLog2.callCount, @@ -125,6 +126,7 @@ suite('Remote tunnel', () => { link: 'https://vscode.dev/tunnel/test_host', domain: 'vscode.dev', tunnelName: 'test_host', + tunnelId: 'tunnel-id', isAttached: true, }, serviceInstallFailed: false, @@ -141,11 +143,37 @@ suite('Remote tunnel', () => { type: 'disconnected', onTokenFailed: mode.session, }, + statusChanges: [ + { + type: 'connected', + info: { + link: 'https://vscode.dev/tunnel/test_host', + domain: 'vscode.dev', + tunnelName: 'test_host', + tunnelId: 'tunnel-id', + isAttached: true, + }, + serviceInstallFailed: false, + }, + { + type: 'connected', + info: { + tunnelName: 'test_host', + isAttached: false, + }, + serviceInstallFailed: false, + }, + { + type: 'disconnected', + onTokenFailed: mode.session, + }, + ], tokenFailures: [mode.session], didCancel: true, telemetryCallCount: 3, }); } finally { + statusChangeListener?.dispose(); tokenFailureListener.dispose(); publicLog2.restore(); service.dispose(); diff --git a/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts b/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts index 1b0b4fb43da5b7..563d0c5facb9f3 100644 --- a/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts +++ b/src/vs/platform/remoteTunnel/test/node/tunnelProcessCoordinator.test.ts @@ -13,7 +13,7 @@ import { INativeEnvironmentService } from '../../../environment/common/environme import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { ActiveTunnelMode, INACTIVE_TUNNEL_MODE } from '../../common/remoteTunnel.js'; import { CodeTunnelCli, CodeTunnelSpawn } from '../../node/codeTunnelCliProcess.js'; -import { IAgentHostSharingRequest, resolveTunnelProcessMode, TunnelProcessCoordinator } from '../../node/tunnelProcessCoordinator.js'; +import { resolveTunnelProcessMode, TunnelProcessCoordinator } from '../../node/tunnelProcessCoordinator.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; interface TestChildProcess { @@ -58,24 +58,20 @@ function activeMode(asService = false): ActiveTunnelMode { return { active: true, asService, session: { providerId: 'github', sessionId: 'session', accountLabel: 'account', token: 'token' } }; } -function agentRequest(): IAgentHostSharingRequest { - return { token: 'agent-token', authProvider: 'github', logLevel: LogLevel.Info }; -} - function createCoordinator(exitOnKill = true, ordering?: string[], installExitCode = 0) { const processes: TestChildProcess[] = []; const spawn: CodeTunnelSpawn = (_command: string, args: readonly string[], options: SpawnOptions) => { const complete = args.includes('login') || args.includes('status') || args.includes('install') || args.includes('kill') || args.includes('uninstall'); const isTunnelProcess = args[0] === 'tunnel' && !args.includes('status') && !args.includes('login') && !args.includes('install') && !args.includes('kill') && !args.includes('uninstall'); if (isTunnelProcess) { - ordering?.push(args.includes('--agent-host-only') ? 'spawn-agent-host' : 'spawn-remote-access'); + ordering?.push('spawn-remote-access'); } const process = createProcess(args, complete, args.includes('status') ? '{"service_installed":false,"tunnel":null}\n' : undefined, exitOnKill || complete, options.env, args.includes('install') ? installExitCode : 0); if (isTunnelProcess && ordering) { - process.child.on('exit', () => ordering.push(args.includes('--agent-host-only') ? 'exit-agent-host' : 'exit-remote-access')); + process.child.on('exit', () => ordering.push('exit-remote-access')); const kill = process.child.kill; process.child.kill = () => { - ordering.push(args.includes('--agent-host-only') ? 'kill-agent-host' : 'kill-remote-access'); + ordering.push('kill-remote-access'); return kill.call(process.child); }; } @@ -99,66 +95,31 @@ function createCoordinator(exitOnKill = true, ordering?: string[], installExitCo suite('TunnelProcessCoordinator', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('resolves the combined intent modes', () => { + test('resolves Remote Tunnel Access modes', () => { assert.deepStrictEqual([ - resolveTunnelProcessMode(false, INACTIVE_TUNNEL_MODE), - resolveTunnelProcessMode(true, INACTIVE_TUNNEL_MODE), - resolveTunnelProcessMode(false, activeMode()), - resolveTunnelProcessMode(true, activeMode()), - resolveTunnelProcessMode(false, activeMode(true)), - resolveTunnelProcessMode(true, activeMode(true)), - ], ['none', 'agentHost', 'remoteAccess', 'remoteAccess', 'service', 'service']); - }); - - test('stops agent-host-only before starting a full tunnel with the same name', async () => { - const { coordinator, processes } = createCoordinator(); - try { - await coordinator.setAgentHostSharing(agentRequest()); - const agentHost = processes.find(process => process.args.includes('--agent-host-only'))!; - await coordinator.setRemoteAccess(activeMode(), LogLevel.Info); - const fullTunnel = processes.filter(process => process.args[0] === 'tunnel' && !process.args.includes('--agent-host-only')).at(-1)!; - - assert.deepStrictEqual({ - agentHostKilledBeforeFullTunnel: processes.indexOf(agentHost) < processes.indexOf(fullTunnel), - agentHostWasStopped: agentHost.wasKilled(), - names: [agentHost.args[agentHost.args.indexOf('--name') + 1], fullTunnel.args[fullTunnel.args.indexOf('--name') + 1]], - }, { - agentHostKilledBeforeFullTunnel: true, - agentHostWasStopped: true, - names: ['test_host', 'test_host'], - }); - - } finally { - for (const process of processes) { - process.emitExit(); - } - await new Promise(resolve => setImmediate(resolve)); - coordinator.dispose(); - } + resolveTunnelProcessMode(INACTIVE_TUNNEL_MODE), + resolveTunnelProcessMode(activeMode()), + resolveTunnelProcessMode(activeMode(true)), + ], ['none', 'remoteAccess', 'service']); }); - test('leaves a healthy tunnel running when the resolved target is unchanged', async () => { + test('stops the tunnel instead of resuming a narrower mode when Remote Tunnel Access is disabled', async () => { const { coordinator, processes } = createCoordinator(); try { await coordinator.setRemoteAccess(activeMode(), LogLevel.Info); const tunnel = processes.find(process => process.args.includes('--accept-server-license-terms'))!; - - // Remote Tunnel Access stays the winning target, so toggling agent - // host sharing must not disturb the running tunnel. - await coordinator.setAgentHostSharing(agentRequest()); + await coordinator.setRemoteAccess(INACTIVE_TUNNEL_MODE, LogLevel.Info); assert.deepStrictEqual({ - wasKilled: tunnel.wasKilled(), - tunnelProcessCount: processes.filter(process => process.args[0] === 'tunnel' - && !process.args.includes('status') - && !process.args.includes('login') - && !process.args.includes('install') - && !process.args.includes('kill') - && !process.args.includes('uninstall')).length, + tunnelWasStopped: tunnel.wasKilled(), + status: coordinator.getStatus(), + agentHostProcessStarted: processes.some(process => process.args.includes('--agent-host-only')), }, { - wasKilled: false, - tunnelProcessCount: 1, + tunnelWasStopped: true, + status: { mode: 'none', tunnelName: undefined, tunnelId: undefined, connectionState: 'disconnected', serviceInstallFailed: false }, + agentHostProcessStarted: false, }); + } finally { for (const process of processes) { process.emitExit(); @@ -291,15 +252,19 @@ suite('TunnelProcessCoordinator', () => { const ordering: string[] = []; const { coordinator, processes } = createCoordinator(false, ordering); try { - await coordinator.setAgentHostSharing(agentRequest()); - const agentHost = processes.find(process => process.args.includes('--agent-host-only'))!; - const transition = coordinator.setRemoteAccess(activeMode(), LogLevel.Info); + await coordinator.setRemoteAccess(activeMode(), LogLevel.Info); + const tunnel = processes.find(process => process.args.includes('--accept-server-license-terms'))!; + const transition = coordinator.setRemoteAccess({ + active: true, + asService: false, + session: { providerId: 'github', sessionId: 'session', accountLabel: 'account', token: 'refreshed-token' }, + }, LogLevel.Info); await new Promise(resolve => setImmediate(resolve)); - assert.deepStrictEqual(ordering, ['spawn-agent-host', 'kill-agent-host']); + assert.deepStrictEqual(ordering, ['spawn-remote-access', 'kill-remote-access']); - agentHost.emitExit(); + tunnel.emitExit(); await transition; - assert.deepStrictEqual(ordering, ['spawn-agent-host', 'kill-agent-host', 'exit-agent-host', 'spawn-remote-access']); + assert.deepStrictEqual(ordering, ['spawn-remote-access', 'kill-remote-access', 'exit-remote-access', 'spawn-remote-access']); } finally { for (const process of processes) { process.emitExit(); @@ -309,22 +274,6 @@ suite('TunnelProcessCoordinator', () => { } }); - test('resumes agent-host-only when remote access stops', async () => { - const { coordinator, processes } = createCoordinator(); - try { - await coordinator.setAgentHostSharing(agentRequest()); - await coordinator.setRemoteAccess(activeMode(), LogLevel.Info); - await coordinator.setRemoteAccess(INACTIVE_TUNNEL_MODE, LogLevel.Info); - - assert.deepStrictEqual(processes.filter(process => process.args.includes('--agent-host-only')).map(process => process.args), [ - ['tunnel', '--agent-host-only', '--name', 'test_host', '--user-data-dir', 'custom-user-data', '--delegate-to-editor', '--parent-process-id', String(process.pid)], - ['tunnel', '--agent-host-only', '--name', 'test_host', '--user-data-dir', 'custom-user-data', '--delegate-to-editor', '--parent-process-id', String(process.pid)], - ]); - } finally { - coordinator.dispose(); - } - }); - test('preserves remote access session and service CLI arguments', async () => { const session = createCoordinator(); const service = createCoordinator(); @@ -348,24 +297,22 @@ suite('TunnelProcessCoordinator', () => { } }); - test('uninstalls the service even when a sharing update preempts the reconcile', async () => { + test('uninstalls the service when a restart preempts the reconcile', async () => { const { coordinator, processes } = createCoordinator(); try { await coordinator.setRemoteAccess(activeMode(true), LogLevel.Info); - // Turning the service off owes an uninstall. Starting agent host - // sharing in the same tick bumps the generation and preempts the - // reconcile that would have run it, so the requirement has to - // survive into the replacement generation. + // Turning the service off owes an uninstall. A restart in the same + // tick preempts that reconcile, so the requirement must survive. const stopService = coordinator.setRemoteAccess(INACTIVE_TUNNEL_MODE, LogLevel.Info); - const share = coordinator.setAgentHostSharing(agentRequest()); - await Promise.all([stopService, share]); + const restart = coordinator.restart(); + await Promise.all([stopService, restart]); assert.deepStrictEqual({ uninstalled: processes.some(process => process.args.includes('uninstall')), - agentHostStarted: processes.some(process => process.args.includes('--agent-host-only')), + status: coordinator.getStatus().mode, }, { uninstalled: true, - agentHostStarted: true, + status: 'none', }); } finally { coordinator.dispose(); @@ -379,19 +326,21 @@ suite('TunnelProcessCoordinator', () => { const firstListener = coordinator.onDidMachineStatus(event => first.push(event.status.type)); const secondListener = coordinator.onDidMachineStatus(event => second.push(event.status.type)); try { - await coordinator.setAgentHostSharing(agentRequest()); - const agentHost = processes.find(process => process.args.includes('--agent-host-only'))!; - agentHost.stdout.write('__VSCODE_CLI_STATUS__{"type":"connected","tunnelName":"test_host","isAttached":false}\n'); + await coordinator.setRemoteAccess(activeMode(), LogLevel.Info); + const tunnel = processes.find(process => process.args.includes('--accept-server-license-terms'))!; + tunnel.stdout.write('__VSCODE_CLI_STATUS__{"type":"connected","tunnelName":"test_host","tunnelId":"tunnel-id","isAttached":false}\n'); await new Promise(resolve => setImmediate(resolve)); assert.deepStrictEqual({ first, second, status: coordinator.getStatus().connectionState, - machineStatusEnvironment: agentHost.env?.VSCODE_CLI_MACHINE_STATUS, + tunnelId: coordinator.getStatus().tunnelId, + machineStatusEnvironment: tunnel.env?.VSCODE_CLI_MACHINE_STATUS, }, { first: ['connected'], second: ['connected'], status: 'connected', + tunnelId: 'tunnel-id', machineStatusEnvironment: '1', }); } finally { diff --git a/src/vs/sessions/LAYOUT_CONTROLLER.md b/src/vs/sessions/LAYOUT_CONTROLLER.md index 499eaa83c0e17f..bb1af231401a4d 100644 --- a/src/vs/sessions/LAYOUT_CONTROLLER.md +++ b/src/vs/sessions/LAYOUT_CONTROLLER.md @@ -55,7 +55,7 @@ This guarantees that after collapsing back to a single session the **default vis Skipped entirely on mobile web (`isWeb && isMobile`) to avoid disruptive auto-expand on narrow viewports. -> **Docked detail panel (experimental).** With `sessions.layout.singlePaneDetailPanel` enabled, the auxiliary bar is docked inside the editor part rather than being a grid column (see [Editor presentation](LAYOUT.md#editor-presentation)). `SinglePaneExistingSessionStrategy` persists one shared Existing Session Editor/Details profile (via `SinglePaneVisibilityProfileStore`) under `sessions.singlePane.sidePaneVisibility`. New Sessions do not apply or capture an Editor profile; submitting preserves Editor visibility and seeds the Existing profile. `SinglePaneQuickChatStrategy` shares the Existing profile's overall side-pane visibility when Quick Chat has a saved editor working set, mapping any visible composition to Editor-only because Quick Chat has no Details. Opening the first editor or changing visibility in an editor-bearing Quick Chat updates that shared profile, even before the chat has a saved working set. A Quick Chat without editors hides the side pane transiently without changing the profile, so navigating away restores the shared visibility. The per-session rules below apply to the classic layout only. The docked detail panel opens at a 300px preferred width unless the user explicitly resized it; cached editor node sizes and temporary sidebar-collapse growth are not allowed to widen the first/opened detail-only pane. Docked sash collapse is also expressed through the same visibility API: the left grid sash hides editor content when the editor node reaches the detail width, and the middle docked sash hides the auxiliary bar when the raw dragged detail width reaches ~0. Single-pane also keeps new-session views Files-first without owning side-pane visibility: when an uncreated workspace session is entered and its restored editor set contains only Empty Files, `SinglePaneNewSessionStrategy` hides Editor once under editor-auto-visibility suppression. Auxiliary Bar visibility is unchanged. A completed Toggle Side Panel reopen is a separate transition: after managed tabs settle, a sole Empty Files input produces dock-only Files. Closing the last non-Empty input is a third, authoritative transition that restores Empty Files and the exact pre-close visibility. New, Existing, and Quick Chat share one `SinglePaneDetailPanelCoordinator` for Changes/Files content selection and context publication. Auxiliary Bar visibility is not shared: each lifecycle strategy applies its own visibility rules before publishing its content target. +> **Docked detail panel (experimental).** With `sessions.layout.singlePaneDetailPanel` enabled, the auxiliary bar is docked inside the editor part rather than being a grid column (see [Editor presentation](LAYOUT.md#editor-presentation)). `SinglePaneExistingSessionStrategy` persists one shared Existing Session Editor/Details profile (via `SinglePaneVisibilityProfileStore`) under `sessions.singlePane.sidePaneVisibility`. New Sessions do not apply or capture an Editor profile; submitting preserves Editor visibility and seeds the Existing profile. `SinglePaneQuickChatStrategy` shares the Existing profile's overall side-pane visibility when Quick Chat has a saved editor working set, mapping any visible composition to Editor-only because Quick Chat has no Details. Opening the first editor or changing visibility in an editor-bearing Quick Chat updates that shared profile, even before the chat has a saved working set. A Quick Chat without editors hides the side pane transiently without changing the profile, so navigating away restores the shared visibility. The per-session rules below apply to the classic layout only. The docked detail panel opens at a 300px preferred width unless the user explicitly resized it; cached editor node sizes and temporary sidebar-collapse growth are not allowed to widen the first/opened detail-only pane. Docked sash collapse is also expressed through the same visibility API: the left grid sash hides editor content when the editor node reaches the detail width, and the middle docked sash hides the auxiliary bar when the raw dragged detail width reaches ~0. Single-pane also keeps new-session views Files-first without owning side-pane visibility: when an uncreated workspace session is entered and its restored editor set contains only Empty Files, `SinglePaneNewSessionStrategy` reveals Files Details before hiding Editor once under editor-auto-visibility suppression. Both the New and Existing strategies reveal Files Details when Empty Files is opened or restored in a visible editor area, while later user visibility changes remain authoritative until the editor is opened again. A completed Toggle Side Panel reopen is a separate transition: after managed tabs settle, a sole Empty Files input produces dock-only Files. Closing the last input closes the whole side pane without replacing a non-Empty input with Empty Files. New, Existing, and Quick Chat share one `SinglePaneDetailPanelCoordinator` for Changes/Files content selection and context publication. Auxiliary Bar visibility is not shared: each lifecycle strategy applies its own visibility rules before publishing its content target. ### 3.1 Switching away — capture diff --git a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md index c84a6e9ec1e112..84a85b4c987567 100644 --- a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md +++ b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md @@ -21,7 +21,7 @@ The third pane is a single visual card containing three regions: | **Editor content** | The editor pane below the tab bar (multi-diff Changes, a file, a browser) | Editor part, inset on the right by the detail width | | **Detail panel** | The docked auxiliary bar on the right (Branch Changes + Checks, or Explorer) | `DockedAuxiliaryBarController` (docks the aux bar inside the editor part) | -**Invariant:** the **tab bar is always visible** whenever the pane is shown — including when the editor content is hidden and in the new-session view. It is kept laid out by `MainEditorPart.layout`'s `keepForDockedTabBar` path (single-pane + detail visible), even while the editor part is logically hidden. +**Invariants:** the **tab bar is always visible** whenever the pane is shown — including when the editor content is hidden and in the new-session view. It is kept laid out by `MainEditorPart.layout`'s `keepForDockedTabBar` path (single-pane + detail visible), even while the editor part is logically hidden. Opening or restoring Empty Files in the visible side pane reveals the Files detail. The user may hide that detail afterward; it is revealed again the next time Empty Files is opened. --- @@ -40,9 +40,9 @@ Only **Existing Sessions** share a persisted Editor/Details visibility profile. **Size distribution when opening the side pane.** Opening the side pane from *closed* (e.g. clicking **Changes** while the chat is full-width) reveals the editor with `Sizing.Distribute`. The grid uses the revealed view's location to distribute its containing split. The Sessions part and side pane therefore receive equal space without either part computing a width. After that, side-pane sizes are **workbench-level, not per session**: the editor grid node width is owned by the workbench grid and persisted globally (`workbench.sessions.partSizes`), so once the user resizes the side pane it keeps that width — including across **session switches** (switching sessions does not change the side-pane width) and across reloads. -**Size distribution when toggling Details.** While Editor is visible, opening Details grows the editor grid node by the current Details width, taking that space from Sessions/chat. Hiding Details shrinks the node by the rendered Details width and returns that space to Sessions/chat. Grid minimum widths still take precedence when Sessions cannot yield the full width. +**Size distribution when toggling Details.** While Editor is visible, opening and hiding Details leave the editor grid node and Sessions/chat width unchanged. Details opens inside the existing side pane and takes its width from Editor content; hiding Details returns that width to Editor content. -**Reload is flicker-free (workbench owns the geometry).** On reload the workbench restores the editor node width from its own persisted part-sizes (`workbench.sessions.partSizes`, consumed by `createDesktopGridDescriptor`), so the grid is painted at the correct size in a single pass. (At the workbench level, hiding the editor still collapses the grid node to the detail width and caches it, and a captured editor-hide width `_dockedEditorSizeBeforeHide` takes precedence for the immediate re-show only when Details remains visible; Editor-only restoration uses the persisted pure Editor-content width.) **Reopening after the sessions list is collapsed.** Closing the **whole** side pane collapses the editor grid node to `0px`, but its Editor-before-Details close order first captures the current combined width while the node is still visible. Reopening restores that composition without treating `0px` as a user width. If a New Session then settles to Files-only, hiding Editor while Details remains visible shrinks the node to the Details width and captures the combined width for the next file open. Returning instead to an Existing Session's Editor-only profile restores its pure Editor width, so repeated session switches do not add the hidden Details width. +**Reload is flicker-free (workbench owns the geometry).** On reload the workbench restores the side-pane editor node width from its own persisted part-sizes (`workbench.sessions.partSizes`, consumed by `createDesktopGridDescriptor`), so the grid is painted at the correct size in a single pass. (At the workbench level, hiding the editor still collapses the grid node to the detail width and caches it, and a captured editor-hide width `_dockedEditorSizeBeforeHide` takes precedence for the immediate re-show regardless of the current Details visibility.) **Reopening after the sessions list is collapsed.** Closing the **whole** side pane collapses the editor grid node to `0px`, but its Editor-before-Details close order first captures the current side-pane width while the node is still visible. Reopening restores that composition without treating `0px` as a user width. If a New Session then settles to Files-only, hiding Editor while Details remains visible shrinks the node to the Details width and captures the side-pane width for the next file open. Returning instead to an Existing Session's Editor-only profile restores that same width, so repeated session switches do not change the Sessions/chat boundary. --- @@ -58,16 +58,16 @@ Only **Existing Sessions** share a persisted Editor/Details visibility profile. | **`+` Add Tab** | End of the tab strip | Opens the Add Tab menu (Browser `⇧⌘K B`; Search `⌘K S` for workspace-backed sessions; a **Changes** entry when the Changes editor tab is absent, and a **Files** entry `⌘K B` when the Files tab is absent — both for any workspace session). Restored managed Changes/Files tabs are inserted at the **end** of the tab strip. Search opens a new Search editor and is unavailable for Quick Chats. **Hidden when the editor area is closed.** | | **Toggle Side Panel** | Command / keybinding | Closes/opens the **whole** side pane (editor + detail together) → chat-only and back. The mechanics live on the workbench layout service (`toggleSidePane`); while the editor area is maximized, the shared `Workbench.toggleSidePane()` remembers maximization, un-maximizes, then performs the collapse so the restored detail is also hidden. Reopening restores the complete side-pane composition before re-maximizing the editor. Hiding a focused side pane moves focus to the sessions list. | | **Toggle Sessions List** | Title bar / command | Collapses/opens the left sessions list. Collapsing it gives the freed width to the editor/detail side pane (not the chat); reopening restores the previous editor/detail width so the chat gets that space back. No single-pane editor or detail action changes this visibility. | -| **Grid sash** | Between the chat and the third pane | Dragging the side pane never changes Details visibility. Details keeps its minimum width while editor content yields and eventually collapses under width pressure. Dragging a detail-only side pane wider keeps the editor content closed. Double-clicking with Details visible preserves the current Details width and splits all remaining width equally between chat and editor content, with no 600px cap. Grid minimum widths take precedence in narrow layouts. Hiding Details after a reset restores an equal chat/Editor split even when the reset itself did not visibly move the sash. With Details hidden it uses the native equal split, and in detail-only mode it resets Details to 300px. | +| **Grid sash** | Between the chat and the third pane | Dragging the side pane never changes Details visibility. Details keeps its minimum width while editor content yields and eventually collapses under width pressure. Dragging a detail-only side pane wider keeps the editor content closed. Double-clicking with Details visible preserves the current Details width and splits all remaining width equally between chat and editor content, with no 600px cap. Grid minimum widths take precedence in narrow layouts. Hiding Details after a reset leaves the chat/side-pane boundary unchanged and returns the Details width to Editor content. With Details hidden the sash uses the native equal split, and in detail-only mode it resets Details to 300px. | | **Changes pill** | Session header meta row | Opens the managed Changes multi-diff editor and explicitly reveals the editor area when the side pane was closed or in detail-only mode. The managed Changes tab still remains excluded from automatic reveal-on-open, so merely activating its tab does not reveal the editor. | -**Editor action visibility.** Maximize/Restore, Toggle Details, and Open in Modal are hidden while the **editor area is closed** (`MainEditorAreaVisibleContext`). Hide Editor and Show Editor are the mutually-exclusive pair that controls that very state: both render in the tab strip's editor-title layout cluster (`MenuId.EditorTitleLayout`), immediately after Maximize/Restore, gated only on `MainEditorAreaVisibleContext` being true/false respectively — unlike Toggle Details, they always show and are always enabled regardless of whether the active tab has a docked detail panel or the detail panel is currently visible (no `HasDockedDetailsContext` gate and no `AuxiliaryBarVisibleContext` precondition), consistent with Maximize/Restore's own always-shown behavior in that same cluster. Hide Editor unconditionally reveals the auxiliary bar as part of its `run()`, so it always has somewhere to fall back to even if the detail panel was hidden beforehand — the New/Existing Session strategy's detail-panel mapping (via the shared `SinglePaneDetailPanelCoordinator`) decides what that panel actually shows (the active tab's own detail, or the Changes/Files fallback for a Browser tab with none of its own; see §5). Show Editor reveals the editor via the same explicit-reveal API (`revealEditorPartExplicitly()`) used by the session-header Changes pill, then focuses the editor group. Toggle Details remains alone in its own trailing editor-header cluster and keeps its **has a docked detail panel** (`HasDockedDetailsContext`) gating — a managed Changes/Files tab or a text file editor — since toggling a nonexistent detail panel is never meaningful. +**Editor action visibility.** Maximize/Restore, Toggle Details, and Open in Modal are hidden while the **editor area is closed** (`MainEditorAreaVisibleContext`). Toggle Details is ordered immediately before Maximize/Restore. Hide Editor and Show Editor are the mutually-exclusive pair that controls the editor-area state: both render in the tab strip's editor-title layout cluster (`MenuId.EditorTitleLayout`), immediately after Maximize/Restore, gated only on `MainEditorAreaVisibleContext` being true/false respectively — unlike Toggle Details, they always show and are always enabled regardless of whether the active tab has a docked detail panel or the detail panel is currently visible (no `HasDockedDetailsContext` gate and no `AuxiliaryBarVisibleContext` precondition), consistent with Maximize/Restore's own always-shown behavior in that same cluster. Hide Editor unconditionally reveals the auxiliary bar as part of its `run()`, so it always has somewhere to fall back to even if the detail panel was hidden beforehand — the New/Existing Session strategy's detail-panel mapping (via the shared `SinglePaneDetailPanelCoordinator`) decides what that panel actually shows (the active tab's own detail, or the Changes/Files fallback for a Browser tab with none of its own; see §5). Show Editor reveals the editor via the same explicit-reveal API (`revealEditorPartExplicitly()`) used by the session-header Changes pill, then focuses the editor group. Toggle Details keeps its **has a docked detail panel** (`HasDockedDetailsContext`) gating — a managed Changes/Files tab or a text file editor — since toggling a nonexistent detail panel is never meaningful. -**Managed Files tab.** The empty Files placeholder tab (and the Changes tab) is opened when the editor group is **empty** on a view-open trigger (a session switch or a side-pane reveal), and both remain present whenever the layout is **Detail only**. The agent-feedback navigation overlay is hidden while the empty Files placeholder is active. Opening a real workspace file **tidies away** the empty placeholder (a `[Changes][file]` strip) as a **one-shot reaction to that open** — not a standing rule — so the user can still add the Files tab via **`+` Files** while a real file is open (that opens an `EmptyFileEditorInput`, not a real file, so it is not tidied away). Existing Sessions do not re-add the placeholder when that file closes while Editor is visible; a New Session instead uses its close fallback to replace the last non-Empty input with Empty Files while preserving Editor/Detail visibility. +**Managed Files tab.** The empty Files placeholder tab (and the Changes tab) is opened when the editor group is **empty** on a view-open trigger (a session switch or a side-pane reveal), and both remain present whenever the layout is **Detail only**. The agent-feedback navigation overlay is hidden while the empty Files placeholder is active. Opening a real workspace file **tidies away** the empty placeholder (a `[Changes][file]` strip) as a **one-shot reaction to that open** — not a standing rule — so the user can still add the Files tab via **`+` Files** while a real file is open (that opens an `EmptyFileEditorInput`, not a real file, so it is not tidied away). Neither Existing nor New Sessions re-add the placeholder when a real file closes. They close the whole side pane only when that close leaves every main editor group empty. -**New-session transitions have separate owners.** Entry owns only the one-shot redundant-Editor hide after session restoration. A completed closed-to-open **Toggle Side Panel** transition owns only the dock-only Files conversion after managed tabs settle. Last-editor close listens to the editor service's did-close event and uses the shared all-main-groups-empty predicate; it ignores programmatic closes while editor-part auto-visibility is suppressed, then installs Empty Files in the exact closing group, preserves Editor visibility, and opens Files Details. Generic side-pane reveal notifications never start the toggle rule, so editor opens and close-fallback restoration cannot feed back into it. +**New-session transitions have separate owners.** Entry owns only the one-shot redundant-Editor hide after session restoration. A completed closed-to-open **Toggle Side Panel** transition owns only the dock-only Files conversion after managed tabs settle. Last-editor close listens to the editor service's did-close event and uses the shared all-main-groups-empty predicate; it ignores programmatic closes while editor-part auto-visibility is suppressed, then closes the whole side pane. Generic side-pane reveal notifications never start the toggle rule, so editor opens cannot feed back into it. -**Empty editor groups are lifecycle-owned.** `SinglePaneWorkbench` does not change visibility when all editors close. New Session replaces a last non-Empty editor with Empty Files, but closing Empty Files itself closes the whole side pane; Existing Session closes the whole side pane when its last editor closes; Quick Chat leaves the side pane open. +**Empty editor groups are lifecycle-owned.** `SinglePaneWorkbench` does not change visibility when all editors close. New and Existing Sessions close the whole side pane when the last editor closes; Quick Chat leaves the side pane open. **Layout-driven vs user editor changes.** The default docked tabs are (re)opened into an empty group on a **settled** session-switch restore — the base controller fires `onDidEndSessionLayoutRestore` once the restore epoch (working-set apply + aux restore) completes, and the strategy reconciles off that. This matters for a new session: its **empty** working set closes the previous session's docked tabs, emptying the group *after* the switch; reconciling on the settled restore-end reads the reliably-empty group and re-opens both managed tabs. Reacting to the transient editor-change *during* the async apply would race the empty state. A **user-driven** editor change (opening a file, closing a tab) does not re-open defaults while Editor is visible; in Detail only, standard close actions cannot remove the managed inputs and every reconcile restores either input removed by lifecycle work. diff --git a/src/vs/sessions/browser/singlePaneWorkbench.ts b/src/vs/sessions/browser/singlePaneWorkbench.ts index 27a79d4c476b60..8047c56f78a682 100644 --- a/src/vs/sessions/browser/singlePaneWorkbench.ts +++ b/src/vs/sessions/browser/singlePaneWorkbench.ts @@ -36,7 +36,6 @@ export class SinglePaneWorkbench extends Workbench { private _dockedAuxiliaryBarWidth = DockedAuxiliaryBarController.DEFAULT_WIDTH; private _syncingEditorVisibility = false; - private _restoreEqualSplitOnDetailsHide = false; private readonly _memento = new DockedEditorSizeMemento(); override get isSinglePaneLayoutEnabled(): boolean { @@ -110,7 +109,6 @@ export class SinglePaneWorkbench extends Workbench { const sessionsWidth = this.workbenchGrid.getViewSize(this.sessionsPartView).width; const editorNodeWidth = this.workbenchGrid.getViewSize(this.editorPartView).width; const totalWidth = sessionsWidth + editorNodeWidth; - this._restoreEqualSplitOnDetailsHide = true; return Math.round(this._dockedAuxiliaryBarWidth + (totalWidth - this._dockedAuxiliaryBarWidth) / 2); } @@ -309,10 +307,6 @@ export class SinglePaneWorkbench extends Workbench { } protected override _applyEditorVisibility(hidden: boolean): void { - if (hidden) { - this._restoreEqualSplitOnDetailsHide = false; - } - // Part sizes are workbench-global, so hiding the side pane must not discard the // user's chosen editor width. Capture the current editor content width before the // grid collapses the node, so revealing later — e.g. switching back from a session @@ -330,7 +324,7 @@ export class SinglePaneWorkbench extends Workbench { const dockedEditorSizeBeforeHide = this._memento.dockedEditorSizeBeforeHide; const savedEditorWidth = this._savedPartSizes.editor; const canRestoreSavedWidth = savedEditorWidth !== undefined && savedEditorWidth >= EDITOR_PART_MINIMUM_WIDTH; - const shouldRestoreDockedEditorSize = !hidden && this.partVisibility.auxiliaryBar && !!dockedEditorSizeBeforeHide; + const shouldRestoreDockedEditorSize = !hidden && !!dockedEditorSizeBeforeHide; const shouldRestoreSavedWidth = !hidden && !shouldRestoreDockedEditorSize && canRestoreSavedWidth; const shouldApplyEvenSplit = !hidden && !shouldRestoreDockedEditorSize && !shouldRestoreSavedWidth; @@ -407,34 +401,6 @@ export class SinglePaneWorkbench extends Workbench { this.editorPartView, this._editorNodeShouldBeVisible() ); - if (hidden && !source && this._effectiveVisible(Parts.EDITOR_PART)) { - const editorNodeSize = this.workbenchGrid.getViewSize(this.editorPartView); - const targetWidth = this._restoreEqualSplitOnDetailsHide - ? Math.round((this.workbenchGrid.getViewSize(this.sessionsPartView).width + editorNodeSize.width) / 2) - : editorNodeSize.width - DockedAuxiliaryBarController.getEffectiveWidth(this._dockedAuxiliaryBarWidth, editorNodeSize.width); - this._restoreEqualSplitOnDetailsHide = false; - this._runWithEditorResizeSyncSuspended(() => { - this.workbenchGrid.resizeView(this.editorPartView, { - width: Math.max(this.editorPartView.minimumWidth, targetWidth), - height: editorNodeSize.height - }); - }); - } else if (!hidden && !source && this._effectiveVisible(Parts.EDITOR_PART)) { - const editorNodeSize = this.workbenchGrid.getViewSize(this.editorPartView); - const savedEditorWidth = this._savedPartSizes.editor; - const canRestoreSavedWidth = this._isEditorPartAutoVisibilitySuppressed - && savedEditorWidth !== undefined - && savedEditorWidth >= EDITOR_PART_MINIMUM_WIDTH; - const targetWidth = canRestoreSavedWidth - ? savedEditorWidth + this._dockedAuxiliaryBarWidth - : editorNodeSize.width + this._dockedAuxiliaryBarWidth; - this._runWithEditorResizeSyncSuspended(() => { - this.workbenchGrid.resizeView(this.editorPartView, { - width: targetWidth, - height: editorNodeSize.height - }); - }); - } if (!hidden && !this.partVisibility.editor) { this._syncingEditorVisibility = true; try { diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 05dd28f12566fa..9959c2f7ff4962 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -54,7 +54,7 @@ import { ISessionsChatViewStateService, SessionsChatViewStateService } from './c import { SessionsChatResponseFileChangesService } from './sessionTurnChanges.js'; import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; import { SessionsChatPetAchievementContribution } from './chatPetAchievements.js'; -import { AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatBackgroundService, SessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; +import { AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatBackgroundService, SessionsChatBackgroundService } from '../../../services/chatBackground/browser/chatBackgroundService.js'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackground'; const CHANGE_AGENT_SESSIONS_CHAT_BACKGROUND_LAYOUT_COMMAND_ID = 'workbench.action.chat.changeAgentSessionsBackgroundLayout'; @@ -140,6 +140,12 @@ const chatBackgroundImageLayoutItems = chatBackgroundImageLayoutValues.map(layou ...chatBackgroundImageLayoutMetadata[layout], })); +const chatBackgroundImageLayoutEnumConfiguration = { + enum: [...chatBackgroundImageLayoutValues], + enumItemLabels: chatBackgroundImageLayoutItems.map(item => item.label), + enumDescriptions: chatBackgroundImageLayoutItems.map(item => item.detail), +}; + class NewChatInSessionsWindowAction extends Action2 { constructor() { @@ -392,15 +398,23 @@ Registry.as(ConfigurationExtensions.Configuration).regis tags: ['experimental'], ignoreSync: true, }, - [AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: { + [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: { type: 'string', - enum: [...chatBackgroundImageLayoutValues], - enumItemLabels: chatBackgroundImageLayoutItems.map(item => item.label), - enumDescriptions: chatBackgroundImageLayoutItems.map(item => item.detail), + ...chatBackgroundImageLayoutEnumConfiguration, default: 'repeat', - scope: ConfigurationScope.APPLICATION, - markdownDescription: localize('chat.agentSessions.backgroundImageLayout', "Controls how the dark and light chat background images are laid out in the Agents Window."), + scope: ConfigurationScope.MACHINE, + markdownDescription: localize('chat.agentSessions.preferredDarkBackgroundImageLayout', "Controls how the chat background image is laid out in the Agents Window when using a dark color theme."), tags: ['experimental'], + ignoreSync: true, + }, + [AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: { + type: 'string', + ...chatBackgroundImageLayoutEnumConfiguration, + default: 'repeat', + scope: ConfigurationScope.MACHINE, + markdownDescription: localize('chat.agentSessions.preferredLightBackgroundImageLayout', "Controls how the chat background image is laid out in the Agents Window when using a light color theme."), + tags: ['experimental'], + ignoreSync: true, }, }, }); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 1427bcea7824c5..8b9d5d51bd7d13 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -315,6 +315,17 @@ overflow: visible; } +.monaco-workbench .sessions-chat-config-toolbar .action-label:focus, +.monaco-workbench .new-chat-bottom-container .sessions-chat-picker-slot > .action-label:focus { + outline: none; +} + +.monaco-workbench .sessions-chat-config-toolbar .action-label:focus-visible, +.monaco-workbench .new-chat-bottom-container .sessions-chat-picker-slot > .action-label:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + .sessions-chat-config-toolbar .action-label:hover { background-color: var(--vscode-toolbar-hoverBackground); color: var(--vscode-foreground); diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 968d7c1cf6a87c..5ecc8dd4b9f2be 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -51,7 +51,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. A quick chat has no workspace, so the workspace picker does not apply and the Toggle Side Panel command is disabled.", '')); content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); content.push(localize('sessionsChat.history', "Use up and down arrows to navigate your request history in the input box.")); - content.push(localize('sessionsChat.background', "Outside high contrast themes, use Set Background to choose no background, the built-in theme-aware Codicons pattern, a new image, or one of the five most recently selected images. Use Change Background Layout to choose whether an image repeats, stretches, or appears at an edge or corner. Moving through the layout picker previews each option; select one to save it, or press Escape to restore the previous layout. Both commands are available from the Command Palette and by right-clicking empty chat space. Change Background Layout is shown only for images. Background customization is unavailable while a high contrast theme is active.")); + content.push(localize('sessionsChat.background', "Outside high contrast themes, use Set Background to choose no background, the built-in theme-aware Codicons pattern, a new image, or one of the five most recently selected images. Use Change Background Layout to choose whether an image repeats, stretches, or appears at an edge or corner for the current color theme. Moving through the layout picker previews each option; select one to save it, or press Escape to restore the previous layout. Both commands are available from the Command Palette and by right-clicking empty chat space. Change Background Layout is shown only for images. Background customization is unavailable while a high contrast theme is active.")); content.push(localize('sessionsChat.vscodePet', "Use the checked Pet item in the new-session view context menu, or type /vscode-pet, to show or hide the VS Code pet above the input. Drag it horizontally to reposition it, or use Tab to focus it and the left and right arrow keys to move it. Press Enter or Space to show it some love.")); content.push(localize('sessionsChat.vscodePetAchievements', "When the pet is enabled, the user account menu lists unlocked achievement badges before locked badges and provides a View Achievements button. A gold star on the pet announces a newly unlocked achievement; activate the pet while the star is visible to open Achievements.")); content.push(localize('sessionsChat.aquariumAction', "To show or hide the aquarium action on the new-session view, use the checked Aquarium item in the context menu outside the composer, or run the Toggle Aquarium Action Visibility command.")); diff --git a/src/vs/sessions/contrib/chat/browser/variableCompletions.ts b/src/vs/sessions/contrib/chat/browser/variableCompletions.ts index 2cd3034b8713c2..c216d524361b67 100644 --- a/src/vs/sessions/contrib/chat/browser/variableCompletions.ts +++ b/src/vs/sessions/contrib/chat/browser/variableCompletions.ts @@ -23,7 +23,7 @@ import { CommandsRegistry } from '../../../../platform/commands/common/commands. import { FileKind, IFileService } from '../../../../platform/files/common/files.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { ISearchService } from '../../../../workbench/services/search/common/search.js'; -import { searchFilesAndFolders } from '../../../../workbench/contrib/search/browser/searchChatContext.js'; +import { MAX_CHAT_FILE_COMPLETION_RESULTS, searchFilesAndFolders } from '../../../../workbench/contrib/search/browser/searchChatContext.js'; import { IEditorDecorationsCollection } from '../../../../editor/common/editorCommon.js'; import { IHistoryService } from '../../../../workbench/services/history/common/history.js'; import { isDiffEditorInput } from '../../../../workbench/common/editor.js'; @@ -256,7 +256,7 @@ export class VariableCompletionHandler extends Disposable { token: CancellationToken, ): Promise { try { - const { files, folders } = await searchFilesAndFolders(workspaceUri, pattern || '', true, token, undefined, this.configurationService, this.searchService); + const { files, folders } = await searchFilesAndFolders(workspaceUri, pattern || '', true, token, undefined, this.configurationService, this.searchService, MAX_CHAT_FILE_COMPLETION_RESULTS); for (const file of files) { if (!seen.has(file)) { @@ -371,4 +371,3 @@ export class VariableCompletionHandler extends Disposable { } } - diff --git a/src/vs/sessions/contrib/editor/browser/addTabActions.ts b/src/vs/sessions/contrib/editor/browser/addTabActions.ts index e47880c69f9cba..b0462f1d840f54 100644 --- a/src/vs/sessions/contrib/editor/browser/addTabActions.ts +++ b/src/vs/sessions/contrib/editor/browser/addTabActions.ts @@ -17,7 +17,7 @@ import { openNewSearchEditor } from '../../../../workbench/contrib/searchEditor/ import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { EditorTabsVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext } from '../../../../workbench/common/contextkeys.js'; -import { IsQuickChatSessionContext, SessionIsCreatedContext, SinglePaneChangesTabAvailableContext, SinglePaneChangesTabMissingContext, SinglePaneFilesTabAvailableContext, SinglePaneFilesTabMissingContext } from '../../../common/contextkeys.js'; +import { IsQuickChatSessionContext, SinglePaneChangesTabAvailableContext, SinglePaneChangesTabMissingContext, SinglePaneFilesTabAvailableContext, SinglePaneFilesTabMissingContext } from '../../../common/contextkeys.js'; import { SessionsCategories } from '../../../common/categories.js'; import { NEW_FILE_TAB_COMMAND_ID } from '../../../common/sessionCommands.js'; import { ISessionChangesService } from '../../changes/browser/sessionChangesService.js'; @@ -43,7 +43,6 @@ const singleEditorTitleWhen = EditorTabsVisibleContext.negate(); const changesTabActionWhen = ContextKeyExpr.and( addTabActionWhen, - SessionIsCreatedContext, SinglePaneChangesTabAvailableContext); const filesTabActionWhen = ContextKeyExpr.and( @@ -192,7 +191,7 @@ export class NewChangesTabAction extends Action2 { const sessionChangesService = accessor.get(ISessionChangesService); const session = sessionsService.activeSession.get(); - if (session?.isCreated.get()) { + if (session) { const group = editorGroupsService.mainPart.activeGroup; await sessionChangesService.openChangesEditor(session.resource, { index: group.count }, group); } diff --git a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts index e4e7349d9c6d97..11c8f1ebe22a7e 100644 --- a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts +++ b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts @@ -38,7 +38,7 @@ import { NewChangesTabAction, NewFileTabAction, NewSearchTabAction } from '../.. import { EmptyFileEditorInput, EmptyFileEditorSerializer } from '../../browser/emptyFileEditorInput.js'; import { EditorTabsVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext } from '../../../../../workbench/common/contextkeys.js'; import { TestEnvironmentService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; -import { IsQuickChatSessionContext, SessionIsCreatedContext, SinglePaneChangesTabAvailableContext, SinglePaneChangesTabMissingContext, SinglePaneFilesTabAvailableContext, SinglePaneFilesTabMissingContext } from '../../../../common/contextkeys.js'; +import { IsQuickChatSessionContext, SinglePaneChangesTabAvailableContext, SinglePaneChangesTabMissingContext, SinglePaneFilesTabAvailableContext, SinglePaneFilesTabMissingContext } from '../../../../common/contextkeys.js'; // Import editor contribution to trigger action registration. import '../../browser/editor.contribution.js'; @@ -137,7 +137,6 @@ suite('Sessions - Editor Contribution', () => { [IsSessionsWindowContext.key]: true, [IsAuxiliaryWindowContext.key]: false, [IsTopRightEditorGroupContext.key]: true, - [SessionIsCreatedContext.key]: true, }; const scenarios = (availableKey: string, missingKey: string) => { const when = availableKey === SinglePaneFilesTabAvailableContext.key @@ -165,22 +164,30 @@ suite('Sessions - Editor Contribution', () => { }); }); - test('new changes tab action requires a created session with Changes available', () => { + test('new changes tab action is enabled for an uncreated workspace session with Changes available', () => { const action = new NewChangesTabAction(); const precondition = action.desc.precondition?.serialize() ?? ''; const keybinding = Array.isArray(action.desc.keybinding) ? action.desc.keybinding[0] : action.desc.keybinding; const when = keybinding?.when?.serialize() ?? ''; + const values: Record = { + [IsSessionsWindowContext.key]: true, + [IsAuxiliaryWindowContext.key]: false, + [SinglePaneChangesTabAvailableContext.key]: true, + }; + const context: IContext = { + getValue: (key: string) => values[key] as T | undefined + }; assert.deepStrictEqual({ - preconditionHasCreated: precondition.includes(SessionIsCreatedContext.key), preconditionHasAvailability: precondition.includes(SinglePaneChangesTabAvailableContext.key), - keybindingHasCreated: when.includes(SessionIsCreatedContext.key), keybindingHasAvailability: when.includes(SinglePaneChangesTabAvailableContext.key), + preconditionEnabled: action.desc.precondition?.evaluate(context), + keybindingEnabled: keybinding?.when?.evaluate(context), }, { - preconditionHasCreated: true, preconditionHasAvailability: true, - keybindingHasCreated: true, keybindingHasAvailability: true, + preconditionEnabled: true, + keybindingEnabled: true, }); }); @@ -341,23 +348,24 @@ suite('Sessions - Editor Contribution', () => { assert.deepStrictEqual(opened, [{ resource, index: 5 }]); }); - test('new changes tab action is a no-op for an uncreated session', async () => { + test('new changes tab action opens the changes editor for an uncreated session', async () => { const instantiationService = store.add(new TestInstantiationService()); - stubEditorGroupCount(instantiationService, 0); + const resource = URI.parse('session:new'); + stubEditorGroupCount(instantiationService, 2); instantiationService.stub(ISessionsService, new class extends mock() { - override readonly activeSession = constObservable({ resource: URI.parse('session:new'), isCreated: constObservable(false) } as IActiveSession); + override readonly activeSession = constObservable({ resource, isCreated: constObservable(false) } as IActiveSession); }); - let opened = false; + const opened: { resource: URI; index: number | undefined }[] = []; instantiationService.stub(ISessionChangesService, new class extends mock() { - override async openChangesEditor(): Promise { - opened = true; + override async openChangesEditor(sessionResource: URI, options?: IEditorOptions): Promise { + opened.push({ resource: sessionResource, index: options?.index }); return undefined; } }); await new NewChangesTabAction().run(instantiationService); - assert.strictEqual(opened, false); + assert.deepStrictEqual(opened, [{ resource, index: 2 }]); }); test('new changes tab action is a no-op when there is no active session', async () => { diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneExistingSessionStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneExistingSessionStrategy.ts index ddabd6460666b3..98671811db9d49 100644 --- a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneExistingSessionStrategy.ts +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneExistingSessionStrategy.ts @@ -25,6 +25,7 @@ import { HasDockedDetailsContext, SinglePaneLayoutEnabledContext } from '../../. import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionChangesService } from '../../../changes/browser/sessionChangesService.js'; +import { EmptyFileEditorInput } from '../../../editor/browser/emptyFileEditorInput.js'; import { DetailPanelTarget, SinglePaneDetailPanelCoordinator } from './singlePaneDetailPanelCoordinator.js'; import { SinglePaneDockedTabsCoordinator } from './singlePaneDockedTabsCoordinator.js'; import { isChangesEditorInput, isEditorWithoutDockedDetails, isFileEditorInput, isMainPartEmpty } from './singlePaneSharedHelpers.js'; @@ -33,7 +34,7 @@ import { SessionVisibilityProfile, SinglePaneVisibilityProfileStore } from './si /** Command that toggles the single-pane detail panel (auxiliary bar) from the editor header. */ export const TOGGLE_DETAILS_COMMAND_ID = 'workbench.action.agentSessions.toggleDetails'; -const singlePaneHeaderToggleDetailsOrder = 10; +const singlePaneHeaderToggleDetailsOrder = 9; /** * Behaviour for the **Existing Session** lifecycle stage — a created, workspace-backed @@ -269,6 +270,9 @@ export class SinglePaneExistingSessionStrategy extends SinglePaneLayoutStrategy let activeSessionKey: string | undefined; let pendingSessionKey: string | undefined; let pendingOutgoingEditor: EditorInput | undefined; + let previousActiveEditor: EditorInput | undefined; + let previousEditorPartVisible = false; + let previousEditorSessionKey: string | undefined; const sync = (reader: IReader | undefined) => { const activeSession = this._sessionsService.activeSession.read(reader); @@ -277,6 +281,9 @@ export class SinglePaneExistingSessionStrategy extends SinglePaneLayoutStrategy || !activeSession.workspace.read(reader) || !activeSession.isCreated.read(reader)) { wasExistingActive = false; + previousActiveEditor = undefined; + previousEditorPartVisible = false; + previousEditorSessionKey = undefined; return; } @@ -304,9 +311,15 @@ export class SinglePaneExistingSessionStrategy extends SinglePaneLayoutStrategy return; } + const emptyFilesShown = activeEditor instanceof EmptyFileEditorInput + && editorPartVisible + && (activeEditor !== previousActiveEditor || !previousEditorPartVisible || sessionKey !== previousEditorSessionKey); + previousActiveEditor = activeEditor; + previousEditorPartVisible = editorPartVisible; + previousEditorSessionKey = sessionKey; const target = this._computeTarget(activeEditor, mainPartEmpty, editorMaximized, editorPartVisible); const revealOnly = this._ctx.multipleSessionsVisibleObs.read(reader); - this._syncDetailVisibility(target, revealOnly); + this._syncDetailVisibility(target, revealOnly, emptyFilesShown); this._detailPanel.sync(target); }; @@ -328,12 +341,18 @@ export class SinglePaneExistingSessionStrategy extends SinglePaneLayoutStrategy })); } - private _syncDetailVisibility(target: DetailPanelTarget, revealOnly: boolean): void { + private _syncDetailVisibility(target: DetailPanelTarget, revealOnly: boolean, emptyFilesShown: boolean): void { + const detailVisible = this._layoutService.isVisible(Parts.AUXILIARYBAR_PART); + if (emptyFilesShown && !detailVisible) { + this._detailHiddenTransiently = false; + this._detailHiddenByEditor = false; + this._setDetailHiddenTransiently(false); + return; + } if (this._ctx.isRestoringSessionLayout || target === DetailPanelTarget.Preserve) { return; } - const detailVisible = this._layoutService.isVisible(Parts.AUXILIARYBAR_PART); if (target === DetailPanelTarget.Hidden || target === DetailPanelTarget.EditorHidden) { if ((target === DetailPanelTarget.EditorHidden || !revealOnly) && detailVisible) { this._detailHiddenTransiently = true; diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneNewSessionStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneNewSessionStrategy.ts index 2b17fdba22115d..6556fd9cd692e7 100644 --- a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneNewSessionStrategy.ts +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneNewSessionStrategy.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { mainWindow } from '../../../../../base/browser/window.js'; -import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Event } from '../../../../../base/common/event.js'; import { autorun, @@ -13,13 +12,8 @@ import { observableFromEvent, observableSignalFromEvent, } from '../../../../../base/common/observable.js'; -import { EditorActivation } from '../../../../../platform/editor/common/editor.js'; -import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; -import { - IEditorGroup, - IEditorGroupsService, -} from '../../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorGroupsService } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; @@ -42,7 +36,7 @@ import { } from './singlePaneLayoutStrategy.js'; /** - * Owns the independent entry, side-pane-toggle, close-fallback, and detail transitions for New Sessions. + * Owns the independent entry, side-pane-toggle, last-editor-close, and detail transitions for New Sessions. */ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { private _pendingEntryHideSessionKey: string | undefined; @@ -61,14 +55,12 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { private readonly _editorGroupsService: IEditorGroupsService, @ISessionChangesService private readonly _sessionChangesService: ISessionChangesService, - @IInstantiationService - private readonly _instantiationService: IInstantiationService, ) { super(ctx); this._registerEntryEditorHide(); this._registerSidePaneOpenEditorHide(); - this._registerEmptyFilesCloseFallback(); + this._registerLastEditorClose(); this._registerDetailPanel(); } @@ -114,6 +106,7 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { if (!editors.every(editor => editor instanceof EmptyFileEditorInput || isChangesEditorInput(editor, this._sessionChangesService))) { return; } + const hasEmptyFiles = editors.some(editor => editor instanceof EmptyFileEditorInput); this._pendingEntryHideSessionKey = undefined; if (!this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { @@ -123,6 +116,9 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { const suppression = this._layoutService.suppressEditorPartAutoVisibility(); try { + if (hasEmptyFiles && !this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { + this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + } this._layoutService.setPartHidden(true, Parts.EDITOR_PART); } finally { suppression.dispose(); @@ -234,12 +230,11 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { ); } - private _registerEmptyFilesCloseFallback(): void { + private _registerLastEditorClose(): void { this._register( - this._editorService.onDidCloseEditor((event) => { - const sessionKey = this._getActiveNewSessionKey(); + this._editorService.onDidCloseEditor(() => { if ( - !sessionKey || + !this._getActiveNewSessionKey() || this._ctx.multipleSessionsVisibleObs.get() || this._ctx.isRestoringSessionLayout || this._layoutService.isEditorPartAutoVisibilitySuppressed() || @@ -249,77 +244,11 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { } this._pendingEntryHideSessionKey = undefined; this._pendingSidePaneOpenHideSessionKey = undefined; - if (event.editor instanceof EmptyFileEditorInput) { - this._hideSidePane(); - return; - } - const group = this._editorGroupsService.mainPart.getGroup( - event.groupId, - ); - if (!group) { - return; - } - const suppression = - this._layoutService.suppressEditorPartAutoVisibility(); - void this._openEmptyFiles( - group, - sessionKey, - this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow), - ) - .finally(() => suppression.dispose()) - .catch(onUnexpectedError); + this._layoutService.hideSidePane(); }), ); } - private _hideSidePane(): void { - this._layoutService.hideSidePane(); - } - - private async _openEmptyFiles( - group: IEditorGroup, - sessionKey: string, - editorVisible: boolean, - ): Promise { - const session = this._sessionsService.activeSession.get(); - const workspace = session?.workspace.get(); - if ( - !session || - this._getActiveNewSessionKey() !== sessionKey || - !workspace || - !isMainPartEmpty(this._editorGroupsService) - ) { - return; - } - await this._editorService.openEditor( - this._instantiationService.createInstance( - EmptyFileEditorInput, - workspace, - ), - { - pinned: true, - inactive: true, - preserveFocus: true, - activation: EditorActivation.PRESERVE, - isExplicit: false, - }, - group, - ); - if (this._getActiveNewSessionKey() !== sessionKey) { - return; - } - if ( - this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) !== - editorVisible - ) { - this._layoutService.setPartHidden(!editorVisible, Parts.EDITOR_PART); - } - if (!this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { - this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); - } - this._detailPanel.sync(DetailPanelTarget.FilesForced); - } - private _getMainPartEditors(): EditorInput[] { return this._editorGroupsService.mainPart.groups.flatMap((group) => [ ...group.editors, @@ -372,28 +301,45 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { this._layoutService.onDidChangeEditorMaximized, () => this._layoutService.isEditorMaximized(), ); + let previousActiveEditor: EditorInput | undefined; + let previousEditorPartVisible = false; + let previousEditorSessionKey: string | undefined; this._register( autorun((reader) => { const activeSession = this._sessionsService.activeSession.read(reader); if (!activeSession) { + previousActiveEditor = undefined; + previousEditorPartVisible = false; + previousEditorSessionKey = undefined; return; } const isQuickChat = activeSession.isQuickChat?.read(reader) ?? false; const workspace = activeSession.workspace.read(reader); if (isQuickChat || !workspace || activeSession.isCreated.read(reader)) { + previousActiveEditor = undefined; + previousEditorPartVisible = false; + previousEditorSessionKey = undefined; return; } const activeEditor = activeEditorObs.read(reader); + const editorPartVisible = editorPartVisibleObs.read(reader); + const sessionKey = activeSession.resource.toString(); + const emptyFilesShown = activeEditor instanceof EmptyFileEditorInput + && editorPartVisible + && (activeEditor !== previousActiveEditor || !previousEditorPartVisible || sessionKey !== previousEditorSessionKey); + previousActiveEditor = activeEditor; + previousEditorPartVisible = editorPartVisible; + previousEditorSessionKey = sessionKey; const target = this._computeTarget( reader, activeEditor, editorMaximizedObs, - editorPartVisibleObs, + editorPartVisible, ); const revealOnly = this._ctx.multipleSessionsVisibleObs.read(reader); - this._syncDetailVisibility(target, revealOnly); + this._syncDetailVisibility(target, revealOnly, emptyFilesShown); this._detailPanel.sync(target); }), ); @@ -413,7 +359,15 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { private _syncDetailVisibility( target: DetailPanelTarget, revealOnly: boolean, + emptyFilesShown: boolean, ): void { + const detailVisible = this._layoutService.isVisible(Parts.AUXILIARYBAR_PART); + if (emptyFilesShown && !detailVisible) { + this._detailHiddenTransiently = false; + this._detailHiddenByEditor = false; + this._layoutService.setAuxiliaryBarHiddenForResize(false); + return; + } if ( this._ctx.isRestoringSessionLayout || target === DetailPanelTarget.Preserve @@ -421,9 +375,6 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { return; } - const detailVisible = this._layoutService.isVisible( - Parts.AUXILIARYBAR_PART, - ); if ( target === DetailPanelTarget.Hidden || target === DetailPanelTarget.EditorHidden @@ -455,7 +406,7 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { reader: IReader, activeEditor: EditorInput | undefined, editorMaximizedObs: IObservable, - editorPartVisibleObs: IObservable, + editorPartVisible: boolean, ): DetailPanelTarget { // A New Session's empty editor group is normal (the Files detail is owned by the // managed-tabs reconcile while its Files tab is (re)ensured), unlike an Existing @@ -463,7 +414,7 @@ export class SinglePaneNewSessionStrategy extends SinglePaneLayoutStrategy { // Existing, New never hides on an empty group. if (activeEditor && isEditorWithoutDockedDetails(activeEditor)) { - return editorPartVisibleObs.read(reader) ? DetailPanelTarget.EditorHidden : DetailPanelTarget.Files; + return editorPartVisible ? DetailPanelTarget.EditorHidden : DetailPanelTarget.Files; } if (editorMaximizedObs.read(reader)) { diff --git a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts index 5f01ccb083e264..aa89b34957fdad 100644 --- a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts @@ -36,6 +36,7 @@ import '../../../changes/browser/changesActions.js'; import { SESSIONS_FILES_CONTAINER_ID } from '../../../files/browser/files.contribution.js'; import { NewChangesTabAction, NewFileTabAction } from '../../../editor/browser/addTabActions.js'; import { createTestHarness, ICreateOptions, ITestLayoutHarness, makeChange, makeSession, TestStubEditorInput } from './layoutControllerTestUtils.js'; +import '../../../editor/browser/editor.contribution.js'; suite('LayoutController (desktop)', () => { @@ -1472,7 +1473,7 @@ suite('LayoutController (desktop)', () => { assert.ok(!harness.openedViews.includes(CHANGES_VIEW_ID), 'untitled sessions are governed by D3b/D4, not D8'); }); - test('[single-pane] entering a new-session view hides only Editor when Empty Files is the only input', async () => { + test('[single-pane] entering a new-session view shows Files Details and hides Editor when Empty Files is the only input', async () => { createSinglePaneController({ activateAux: true }); await timeout(0); const existing = makeSession(URI.parse('session:existing')); @@ -1495,8 +1496,9 @@ suite('LayoutController (desktop)', () => { call.part === Parts.EDITOR_PART || call.part === Parts.AUXILIARYBAR_PART), }, { editorVisible: false, - detailVisible: false, + detailVisible: true, visibilityRestores: [ + { part: Parts.AUXILIARYBAR_PART, hidden: false }, { part: Parts.EDITOR_PART, hidden: true }, ], }); @@ -1551,7 +1553,7 @@ suite('LayoutController (desktop)', () => { }); }); - test('[single-pane] closing the last non-Empty editor while Editor is hidden opens Empty Files', async () => { + test('[single-pane] closing the last non-Empty editor while Editor is hidden closes the side pane', async () => { createSinglePaneController({ activateAux: true, singlePaneLayoutEnabled: true }); await settle(); harness.activeSessionObs.set(makeSession(URI.parse('session:new'), { status: SessionStatus.Untitled, isCreated: false }), undefined); @@ -1574,13 +1576,13 @@ suite('LayoutController (desktop)', () => { editorVisible: harness.partVisibility.get(Parts.EDITOR_PART), auxiliaryBarVisible: harness.partVisibility.get(Parts.AUXILIARYBAR_PART), }, { - hasFilesTab: true, + hasFilesTab: false, editorVisible: false, - auxiliaryBarVisible: true, + auxiliaryBarVisible: false, }); }); - test('[single-pane] closing the last visible file editor opens Empty Files and keeps Editor visible', async () => { + test('[single-pane] closing the last visible file editor closes the side pane without opening Empty Files', async () => { createSinglePaneController({ activateAux: true, singlePaneLayoutEnabled: true }); await settle(); harness.activeSessionObs.set(makeSession(URI.parse('session:new'), { status: SessionStatus.Untitled, isCreated: false }), undefined); @@ -1603,9 +1605,9 @@ suite('LayoutController (desktop)', () => { editorVisible: harness.partVisibility.get(Parts.EDITOR_PART), auxiliaryBarVisible: harness.partVisibility.get(Parts.AUXILIARYBAR_PART), }, { - hasFilesTab: true, - editorVisible: true, - auxiliaryBarVisible: true, + hasFilesTab: false, + editorVisible: false, + auxiliaryBarVisible: false, }); }); @@ -2480,26 +2482,32 @@ suite('LayoutController (desktop)', () => { }); }); - test('[D7 single-pane] contributes Toggle Details with the editor title layout actions', () => { + test('[D7 single-pane] contributes Toggle Details before Maximize with the editor title layout actions', () => { createSinglePaneController(); const items = MenuRegistry.getMenuItems(MenuId.EditorTitleLayout) .filter(isIMenuItem) .filter(item => item.command.id === TOGGLE_DETAILS_COMMAND_ID); + const maximizeItem = MenuRegistry.getMenuItems(MenuId.EditorTitleLayout) + .filter(isIMenuItem) + .find(item => item.command.id === 'workbench.action.agentSessions.maximizeMainEditorPart'); assert.strictEqual(items.length, 1, 'exactly one Toggle Details item on the editor header'); + assert.ok(maximizeItem, 'Maximize item should be registered'); const when = items[0].when?.serialize() ?? ''; assert.deepStrictEqual({ group: items[0].group, icon: ThemeIcon.isThemeIcon(items[0].command.icon) ? items[0].command.icon.id : undefined, order: items[0].order, + beforeMaximize: (items[0].order ?? 0) < (maximizeItem.order ?? 0), hasToggled: !!items[0].command.toggled, gatedOnEditorArea: when.includes(MainEditorAreaVisibleContext.key), gatedOnDockedDetails: when.includes(HasDockedDetailsContext.key), }, { group: 'navigation', icon: Codicon.listSelection.id, - order: 10, + order: 9, + beforeMaximize: true, hasToggled: true, gatedOnEditorArea: true, gatedOnDockedDetails: true, diff --git a/src/vs/sessions/contrib/layout/test/browser/singlePaneStrategies.test.ts b/src/vs/sessions/contrib/layout/test/browser/singlePaneStrategies.test.ts index b08404d952cab0..38b470bf5d7b82 100644 --- a/src/vs/sessions/contrib/layout/test/browser/singlePaneStrategies.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/singlePaneStrategies.test.ts @@ -121,21 +121,139 @@ suite('SinglePane layout strategies', () => { }); }); - test('New Session entry hides Editor when Empty Files is the only input', () => { + test('New Session entry shows Files Details before hiding Editor when Empty Files is the only input', () => { const ctx = setup(); const session = makeSession(URI.parse('session:/new'), { status: SessionStatus.Untitled, isCreated: false }); - harness.activeGroupEditors.push(store.add(harness.instaService.createInstance(EmptyFileEditorInput, session.workspace.get()))); + const emptyFiles = store.add(harness.instaService.createInstance(EmptyFileEditorInput, session.workspace.get())); + harness.activeGroupEditors.push(emptyFiles); + harness.activeEditorInput = emptyFiles; + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); store.add(harness.instaService.createInstance(SinglePaneNewSessionStrategy, ctx, createDetailPanel())); harness.setPartHiddenCalls.length = 0; activate(session); - assert.deepStrictEqual(harness.setPartHiddenCalls.filter(call => call.part === Parts.EDITOR_PART), [ + assert.deepStrictEqual(harness.setPartHiddenCalls, [ + { hidden: false, part: Parts.AUXILIARYBAR_PART }, { hidden: true, part: Parts.EDITOR_PART }, ]); }); - test('New Session close fallback replaces the last file and opens Details', async () => { + test('New Session allows Details to stay hidden after Empty Files opens it', () => { + const ctx = setup(); + const session = makeSession(URI.parse('session:/new'), { status: SessionStatus.Untitled, isCreated: false }); + const emptyFiles = store.add(harness.instaService.createInstance(EmptyFileEditorInput, session.workspace.get())); + harness.activeGroupEditors.push(emptyFiles); + harness.activeEditorInput = emptyFiles; + store.add(harness.instaService.createInstance(SinglePaneNewSessionStrategy, ctx, createDetailPanel())); + activate(session); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + harness.setPartHiddenCalls.length = 0; + + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + harness.editorMaximized = true; + harness.onDidChangeEditorMaximized.fire(); + + assert.deepStrictEqual({ + auxiliaryBarVisible: harness.partVisibility.get(Parts.AUXILIARYBAR_PART), + visibilityChanges: harness.setPartHiddenCalls, + }, { + auxiliaryBarVisible: false, + visibilityChanges: [], + }); + }); + + test('Existing Session restoration shows Details when Empty Files is active', () => { + const ctx = setup(); + const session = makeSession(URI.parse('session:/existing')); + const emptyFiles = store.add(harness.instaService.createInstance(EmptyFileEditorInput, session.workspace.get())); + const visibilityStore = createVisibilityStore(); + visibilityStore.set(SessionVisibilityProfile.Existing, { editorVisible: true, auxiliaryBarVisible: false }); + harness.activeGroupEditors.push(emptyFiles); + harness.activeEditorInput = emptyFiles; + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + store.add(harness.instaService.createInstance( + SinglePaneExistingSessionStrategy, + ctx, + visibilityStore, + createDetailPanel() + )); + harness.setPartHiddenCalls.length = 0; + + activate(session); + + assert.deepStrictEqual({ + editorVisible: harness.partVisibility.get(Parts.EDITOR_PART), + auxiliaryBarVisible: harness.partVisibility.get(Parts.AUXILIARYBAR_PART), + visibilityChanges: harness.setPartHiddenCalls, + }, { + editorVisible: true, + auxiliaryBarVisible: true, + visibilityChanges: [ + { hidden: false, part: Parts.AUXILIARYBAR_PART }, + ], + }); + }); + + test('Existing Session allows Details to hide until Empty Files is opened again', () => { + const ctx = setup(); + const session = makeSession(URI.parse('session:/existing')); + const otherEditor = store.add(new TestStubEditorInput(URI.parse('search-editor://other'))); + const emptyFiles = store.add(harness.instaService.createInstance(EmptyFileEditorInput, session.workspace.get())); + harness.activeGroupEditors.push(otherEditor, emptyFiles); + harness.activeEditorInput = emptyFiles; + const strategy = store.add(harness.instaService.createInstance( + SinglePaneExistingSessionStrategy, + ctx, + createVisibilityStore(), + createDetailPanel() + )); + activate(session); + harness.setPartHiddenCalls.length = 0; + + const nowVisible = strategy.toggleDetails(); + + assert.deepStrictEqual({ + nowVisible, + auxiliaryBarVisible: harness.partVisibility.get(Parts.AUXILIARYBAR_PART), + visibilityChanges: harness.setPartHiddenCalls, + }, { + nowVisible: false, + auxiliaryBarVisible: false, + visibilityChanges: [ + { hidden: true, part: Parts.AUXILIARYBAR_PART }, + ], + }); + + harness.editorMaximized = true; + harness.onDidChangeEditorMaximized.fire(); + + assert.deepStrictEqual({ + auxiliaryBarVisible: harness.partVisibility.get(Parts.AUXILIARYBAR_PART), + lastVisibilityChange: harness.setPartHiddenCalls.at(-1), + }, { + auxiliaryBarVisible: false, + lastVisibilityChange: { hidden: true, part: Parts.AUXILIARYBAR_PART }, + }); + + harness.editorMaximized = false; + harness.activeEditorInput = otherEditor; + harness.onDidActiveEditorChange.fire(); + harness.activeEditorInput = emptyFiles; + harness.onDidActiveEditorChange.fire(); + + assert.deepStrictEqual({ + auxiliaryBarVisible: harness.partVisibility.get(Parts.AUXILIARYBAR_PART), + lastVisibilityChange: harness.setPartHiddenCalls.at(-1), + }, { + auxiliaryBarVisible: true, + lastVisibilityChange: { hidden: false, part: Parts.AUXILIARYBAR_PART }, + }); + }); + + test('New Session closes the side pane instead of opening Empty Files when its last file closes', () => { const ctx = setup(); const session = makeSession(URI.parse('session:/new'), { status: SessionStatus.Untitled, isCreated: false }); const editor = store.add(new TestStubEditorInput(URI.file('/repo/file.ts'))); @@ -148,20 +266,16 @@ suite('SinglePane layout strategies', () => { harness.activeGroupEditors.length = 0; harness.editorGroupsHaveContent = false; harness.onDidCloseEditor.fire({ editor, groupId: 1 }); - const replacementDuringClose = harness.activeGroupEditors.find(input => input instanceof EmptyFileEditorInput); harness.onDidEditorsChange.fire(); - await Promise.resolve(); assert.deepStrictEqual({ - replacementPreservedAfterClose: replacementDuringClose === harness.activeGroupEditors[0], editorsAfterCloseCompleted: harness.activeGroupEditors.map(input => input.typeId), editorVisible: harness.partVisibility.get(Parts.EDITOR_PART), auxiliaryBarVisible: harness.partVisibility.get(Parts.AUXILIARYBAR_PART), }, { - replacementPreservedAfterClose: true, - editorsAfterCloseCompleted: [EmptyFileEditorInput.ID], - editorVisible: true, - auxiliaryBarVisible: true, + editorsAfterCloseCompleted: [], + editorVisible: false, + auxiliaryBarVisible: false, }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 5d4104446ed63e..f649b7e1323862 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -23,7 +23,7 @@ import { supportsAgentHostDetachedWorktrees } from '../../../../../platform/agen import { withAgentDevContainerWorktreeMetadata } from '../../../../../platform/agentHost/common/meta/agentDevContainerWorktreeMeta.js'; import { SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { workspacelessScratchDir } from '../../../../../platform/agentHost/common/workspacelessScratchDir.js'; -import type { AgentCustomization, ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { type AgentCustomization, type ISessionGitState, readSessionEhcliAdoptable } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; @@ -148,14 +148,25 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide if (!isLegacyMigrationEnabledAtStartup(this._configurationService)) { return undefined; } - const rawId = getCopilotCliSessionRawId(migratedCopilotCliResource(resource)); - if (rawId && this._sessionCache.has(rawId)) { - return migratedCopilotCliResource(resource); // already adopted; no round-trip + const twin = migratedCopilotCliResource(resource); + const rawId = getCopilotCliSessionRawId(twin); + // An un-adopted legacy chat still carries the adoptable marker and must take + // the migration probe; only a surfaced external / already-adopted session + // short-circuits, since opening its twin is a plain (non-migrating) open. + const adoptable = rawId ? readSessionEhcliAdoptable(this._getSessionMetadataByRawId(rawId)) : false; + if (rawId && this._sessionCache.has(rawId) && !adoptable) { + return twin; // already surfaced and not an un-adopted legacy chat; no round-trip } // Startup restore reopens persisted slots against a cold host, where the // first catalog pass is far slower than an interactive open. const timeoutMs = reason === 'restore' ? LEGACY_MIGRATION_RESTORE_TIMEOUT_MS : LEGACY_MIGRATION_TIMEOUT_MS; - return adoptLegacyCopilotCliResource(this.connection, resource, this._logService, this._configurationService, this._telemetryService, reason ?? 'open', timeoutMs); + const adopted = await adoptLegacyCopilotCliResource(this.connection, resource, this._logService, this._configurationService, this._telemetryService, reason ?? 'open', timeoutMs); + // On decline or timeout, redirect only a non-adoptable (external / + // already-adopted) session to its surfaced twin so it opens as-is instead + // of the extension-host resource. An adoptable session that failed to adopt + // keeps the original `undefined` behavior and opens unmigrated. Mirrors the + // chat-editor open path. + return adopted ?? (adoptable ? undefined : twin); } constructor( diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 20defd3f7cf537..5c97d1d9d9d704 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -1400,6 +1400,14 @@ class AgentSessionAdapter implements ICopilotChatSession { */ export class CopilotChatSessionsProvider extends Disposable implements ISessionsProvider { + /** + * How long the first sandbox turn waits for the session's model catalog to arrive before + * dispatching without the user's model. Long enough to cover the gap between the relay + * connecting and the host publishing its models, short enough not to strand a send behind a + * catalog that is never coming. + */ + private static readonly SANDBOX_MODEL_WAIT_MS = 5_000; + readonly id = COPILOT_PROVIDER_ID; readonly label = localize('copilotChatSessionsProvider', "Copilot Chat"); readonly icon = Codicon.copilot; @@ -2150,6 +2158,9 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._onDidChangeSessions.fire({ added: [placeholder], removed: [], changed: [] }); let provisioned: ICloudSandboxProvisionedSession | undefined; + // Read before provisioning: the composer session is retired below, and its selection is the + // only record of what the user picked for this turn. + const selectedRawModelId = this._rawCloudModelId(session); try { provisioned = await this._getCloudSandboxContribution().provisionSession({ repoNwo, @@ -2160,6 +2171,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // Send into the session's main chat rather than `createNewChat`, which would mint an // *additional* peer chat inside a session that already has one. const chat = provisioned.session.mainChat.get(); + await this._carryModelToSandbox(provisioned, chat.resource, selectedRawModelId); const committed = await provisioned.provider.sendRequest(provisioned.session.sessionId, chat.resource, options); // Retire only once the turn is dispatched; swapping earlier bounces the view home. @@ -2187,6 +2199,82 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions provisioned.provider.publishWithheldSession(AgentSession.id(provisioned.session.resource), options); } + /** + * The backend model id behind this composer's selection, as the sandbox knows it. + * + * Cloud sessions pick from the extension host's `models` option group, whose ids are the + * group's own item ids, while a sandbox registers its models from what the agent host + * advertises. The two are different id spaces, so only the underlying model id crosses over. + */ + private _rawCloudModelId(session: RemoteNewSession): string | undefined { + const selectedModelId = session.selectedModelId; + if (!selectedModelId) { + return undefined; + } + const { modelOption } = session.getModelOptionsSnapshot(); + const item = modelOption?.group.items.find(i => i.id === selectedModelId); + return item?.modelMetadata?.id ?? item?.id ?? selectedModelId; + } + + /** + * Apply the model the user picked in the composer to the sandbox session before its first turn. + * + * Mission Control starts no run, so this client sends that turn — and a session that has never + * run has no model of its own to restore. Without this the turn carries no model at all and + * silently runs on whatever the agent host defaults to, discarding the user's pick along with + * the thinking level and context tier configured against it. + * + * A freshly connected sandbox publishes its models asynchronously, so an empty catalog here is + * "not yet" rather than "no": the model resolution is awaited while it reports `pending`, which + * is the wait {@link ISessionsProvider.getModelsSnapshot} documents. Bounded, because the turn + * cannot be held indefinitely — on timeout, or a model the sandbox genuinely does not offer, + * the host chooses, which is the behavior this had before. + */ + private async _carryModelToSandbox(provisioned: ICloudSandboxProvisionedSession, chatResource: URI, rawModelId: string | undefined): Promise { + if (!rawModelId) { + return; + } + const sessionId = provisioned.session.sessionId; + const provider = provisioned.provider; + + // Agent-host models are published under the session's model target, so that is the vendor + // prefix their identifiers carry. Without it there is nothing to resolve against. + const modelTarget = provider.getModelsSnapshot(sessionId).modelTarget; + if (!modelTarget) { + this.logService.info(`[CopilotChatSessionsProvider] Sandbox session ${sessionId} reported no model target; letting the agent host choose.`); + return; + } + const desiredModelId = `${modelTarget}:${rawModelId}`; + + const store = new DisposableStore(); + try { + const deadline = Date.now() + CopilotChatSessionsProvider.SANDBOX_MODEL_WAIT_MS; + for (; ;) { + const resolution = provider.getModelsSnapshot(sessionId, desiredModelId).desiredModelResolution; + if (resolution.kind === 'available') { + provider.setModel(sessionId, chatResource, resolution.model.identifier, ChatModelSource.CarriedOver); + return; + } + if (resolution.kind !== 'pending') { + this.logService.info(`[CopilotChatSessionsProvider] Sandbox session ${sessionId} does not advertise model '${rawModelId}'; letting the agent host choose.`); + return; + } + const remaining = deadline - Date.now(); + // `raceTimeout` signals a timeout with `undefined`, which is also what a `void` + // event resolves to — map the event to a value that tells the two apart. + const published = remaining > 0 + ? await raceTimeout(Event.toPromise(provider.onDidChangeModels, store).then(() => true), remaining) + : undefined; + if (!published) { + this.logService.warn(`[CopilotChatSessionsProvider] Sandbox session ${sessionId} had not published model '${rawModelId}' in time; letting the agent host choose.`); + return; + } + } + } finally { + store.dispose(); + } + } + /** Retire the optimistic placeholder in favour of the session that now exists. */ private _retirePlaceholder(session: RemoteNewSession, placeholder: ISession, committed: ISession): void { this._sessionCache.delete(session.resource.toString()); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 460f421622f3a0..34f041f2d1a271 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -30,7 +30,7 @@ import { AgentSessionProviders } from '../../../../../../workbench/contrib/chat/ import { IChatService, ChatSendResult, IChatSendRequestData, IChatSendRequestOptions } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ChatSessionStatus, IChatSessionProviderOptionGroup, IChatSessionsService, SessionType } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IChatWidget, IChatWidgetService } from '../../../../../../workbench/contrib/chat/browser/chat.js'; -import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import { ILanguageModelToolsService } from '../../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { IChatResponseModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { IChatAgentData } from '../../../../../../workbench/contrib/chat/common/participants/chatAgents.js'; @@ -360,7 +360,7 @@ function createProviderForSendTests( disposables: DisposableStore, model: MockAgentSessionsModel, sendRequest: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise, - opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; configurationService?: TestConfigurationService; agentHostEnabled?: boolean }, + opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; configurationService?: TestConfigurationService; agentHostEnabled?: boolean; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined }, ): TestSandboxCopilotProvider { const instantiationService = disposables.add(new TestInstantiationService()); @@ -384,7 +384,7 @@ function createProviderForSendTests( getChatSessionContribution: () => ({ type: 'test-copilot', name: 'test', displayName: 'Test', description: 'test', icon: undefined }), getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }), onDidCommitSession: opts?.onDidCommitSession ?? Event.None, - getOptionGroupsForSessionType: () => undefined, + getOptionGroupsForSessionType: () => opts?.getOptionGroups?.(), updateSessionOptions: () => true, setSessionOption: () => true, getSessionOption: () => undefined, @@ -1958,7 +1958,7 @@ suite('CopilotChatSessionsProvider', () => { // `repoNwo` has to strip back down to `owner/repo`. const repoWorkspace = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, path: '/osortega/simple-server/HEAD' }); - function createSandboxProvider(opts: { enabled?: boolean; provision?: () => Promise } = {}) { + function createSandboxProvider(opts: { enabled?: boolean; provision?: () => Promise; getOptionGroups?: () => IChatSessionProviderOptionGroup[] | undefined } = {}) { const configurationService = new TestConfigurationService(); configurationService.setUserConfiguration(CloudSandboxEnabledSettingId, opts.enabled ?? true); configurationService.setUserConfiguration(RemoteAgentHostsEnabledSettingId, true); @@ -1968,7 +1968,7 @@ suite('CopilotChatSessionsProvider', () => { cloudSends.push(message); // Never settles: these tests only assert which path the send took. return new Promise(() => { }); - }, { configurationService }); + }, { configurationService, getOptionGroups: opts.getOptionGroups }); const provisionRequests: ICloudSandboxCreateSessionRequest[] = []; provider.sandboxContribution = { @@ -1983,8 +1983,14 @@ suite('CopilotChatSessionsProvider', () => { return { provider, provisionRequests, cloudSends }; } - /** A provisioned session whose provider immediately commits the send. */ - function provisionedSession(sendRequest?: () => Promise): ICloudSandboxProvisionedSession & { published: string[] } { + /** + * A provisioned session whose provider immediately commits the send. + * + * `sandboxModels` is a function so a test can model a catalog that is still arriving: + * resolution reports `pending` until it yields the model, mirroring an agent host that has + * connected but not yet published. + */ + function provisionedSession(sendRequest?: () => Promise, sandboxModels: () => readonly ILanguageModelChatMetadataAndIdentifier[] = () => []): ICloudSandboxProvisionedSession & { published: string[]; modelSelections: { modelId: string; source: ChatModelSource }[]; modelsChanged: Emitter } { const committed = upcastPartial({ sessionId: 'agenthost:sess-new', resource: URI.parse('agent-host-copilot:/sess-new'), @@ -1995,19 +2001,125 @@ suite('CopilotChatSessionsProvider', () => { mainChat: constObservable(upcastPartial({ resource: URI.parse('agent-host-copilot:/sess-new') })), }); const published: string[] = []; + const modelSelections: { modelId: string; source: ChatModelSource }[] = []; + const modelsChanged = disposables.add(new Emitter()); return { taskId: 'task-new', sessionId: 'sess-new', environmentId: 'env-new', session: sandboxSession, published, + modelSelections, + modelsChanged, provider: upcastPartial({ sendRequest: sendRequest ?? (async () => committed), publishWithheldSession: (rawId: string) => { published.push(rawId); }, + onDidChangeModels: modelsChanged.event, + getModelsSnapshot: (_sessionId: string, desiredModelId?: string) => { + const models = sandboxModels(); + const model = models.find(m => m.identifier === desiredModelId); + return { + models, + desiredModelResolution: !desiredModelId + ? { kind: 'notRequested' as const } + : model + ? { kind: 'available' as const, model } + // An empty catalog is "not yet"; a populated one that lacks the + // model is conclusive. + : models.length === 0 + ? { kind: 'pending' as const, identifier: desiredModelId } + : { kind: 'unavailable' as const, identifier: desiredModelId }, + modelTarget: 'agent-host-copilot', + }; + }, + setModel: (_sessionId: string, _chatResource: URI, modelId: string, source: ChatModelSource) => { modelSelections.push({ modelId, source }); }, }) as CloudSandboxSessionsProvider, }; } + /** A model as the sandbox advertises it: vendor-prefixed identifier, bare backend id. */ + function sandboxModel(rawId: string): ILanguageModelChatMetadataAndIdentifier { + return upcastPartial({ + identifier: `agent-host-copilot:${rawId}`, + metadata: upcastPartial({ id: rawId, name: rawId }), + }); + } + + /** The `models` option group a cloud composer picks from, whose ids are its own. */ + function cloudModelOptionGroup(itemId: string, backendModelId: string): IChatSessionProviderOptionGroup[] { + return [{ + id: 'models', + name: 'Models', + items: [{ id: itemId, name: backendModelId, modelMetadata: { id: backendModelId, name: backendModelId } }], + }]; + } + + test('carries the composer model into the sandbox before the first turn', async () => { + // Mission Control starts no run, so a session that has never run has no model to + // restore: without this the first turn would silently take the agent host default. + const provisioned = provisionedSession(undefined, () => [sandboxModel('claude-sonnet-4.6')]); + const { provider } = createSandboxProvider({ + provision: async () => provisioned, + getOptionGroups: () => cloudModelOptionGroup('synthetic-cloud-model', 'claude-sonnet-4.6'), + }); + const sessionInfo = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); + const session = provider.getSession(sessionInfo.sessionId)!; + session.setUseSandbox(true); + provider.setModel(sessionInfo.sessionId, session.mainChat.get().resource, 'synthetic-cloud-model', ChatModelSource.Chosen); + + await provider.sendRequest(sessionInfo.sessionId, session.mainChat.get().resource, { query: 'fix it' }); + + // The id crosses id spaces by backend model id, and arrives as carried over: the user + // picked it for the composer, not for the session that replaced it. + assert.deepStrictEqual(provisioned.modelSelections, [{ modelId: 'agent-host-copilot:claude-sonnet-4.6', source: ChatModelSource.CarriedOver }]); + }); + + test('waits for a sandbox catalog that is still arriving rather than sending without the model', async () => { + // A freshly connected sandbox publishes its models asynchronously. Treating that empty + // window as a miss would reinstate the very race this carries the model to avoid. + let models: readonly ILanguageModelChatMetadataAndIdentifier[] = []; + const provisioned = provisionedSession(undefined, () => models); + const { provider } = createSandboxProvider({ + provision: async () => provisioned, + getOptionGroups: () => cloudModelOptionGroup('synthetic-cloud-model', 'claude-sonnet-4.6'), + }); + const sessionInfo = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); + const session = provider.getSession(sessionInfo.sessionId)!; + session.setUseSandbox(true); + provider.setModel(sessionInfo.sessionId, session.mainChat.get().resource, 'synthetic-cloud-model', ChatModelSource.Chosen); + + const sent = provider.sendRequest(sessionInfo.sessionId, session.mainChat.get().resource, { query: 'fix it' }); + // Publish only once the send is already waiting on the pending catalog. + await timeout(0); + const beforeCatalog = [...provisioned.modelSelections]; + models = [sandboxModel('claude-sonnet-4.6')]; + provisioned.modelsChanged.fire(); + await sent; + + assert.deepStrictEqual( + { beforeCatalog, afterCatalog: provisioned.modelSelections }, + { beforeCatalog: [], afterCatalog: [{ modelId: 'agent-host-copilot:claude-sonnet-4.6', source: ChatModelSource.CarriedOver }] } + ); + }); + + test('leaves the model to the agent host when the sandbox does not advertise it', async () => { + // Sending an unroutable id would fail the turn outright, so an unmatched pick keeps + // the previous behavior of letting the host choose. + const provisioned = provisionedSession(undefined, () => [sandboxModel('gpt-5')]); + const { provider } = createSandboxProvider({ + provision: async () => provisioned, + getOptionGroups: () => cloudModelOptionGroup('synthetic-cloud-model', 'claude-sonnet-4.6'), + }); + const sessionInfo = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); + const session = provider.getSession(sessionInfo.sessionId)!; + session.setUseSandbox(true); + provider.setModel(sessionInfo.sessionId, session.mainChat.get().resource, 'synthetic-cloud-model', ChatModelSource.Chosen); + + await provider.sendRequest(sessionInfo.sessionId, session.mainChat.get().resource, { query: 'fix it' }); + + assert.deepStrictEqual(provisioned.modelSelections, []); + }); + test('provisions a sandbox and replaces the draft with the committed session', async () => { const { provider, provisionRequests, cloudSends } = createSandboxProvider({ provision: async () => provisionedSession() }); const sessionInfo = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index a3af5d4431b5ce..8f64e57ce2392f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -26,7 +26,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { OpenAgentHostStateFileAction } from '../../agentHost/browser/openAgentHostStateFileAction.js'; -import { authenticateProtectedResources, AgentHostAuthenticationRecovery, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js'; +import { authenticateProtectedResources, AgentHostAuthenticationRecovery, AgentHostAuthTokenCache, resolveAuthenticationInteractively, revokeAuthenticationForRemovedSessions } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js'; import { AgentHostLanguageModelProvider, agentHostProviderSupportsAutoModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; @@ -34,7 +34,7 @@ import { ChatSessionsExtensions, IAsyncChatSessionActivationRegistry, IChatSessi import { ICustomizationHarnessService } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { IAgentHostFileSystemService } from '../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; -import { IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; +import { AuthenticationSession, IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { SessionStatus } from '../../../../services/sessions/common/session.js'; import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, remoteAgentHostSessionTypeId } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; @@ -160,7 +160,10 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc this._register(this._remoteAgentHostService.onDidChangeConnections(() => this._reconcile())); this._register(this._defaultAccountService.onDidChangeDefaultAccount(() => this._authenticateAllConnections())); - this._register(this._authenticationService.onDidChangeSessions(() => this._authenticateAllConnections())); + this._register(this._authenticationService.onDidRegisterAuthenticationProvider(() => this._authenticateAllConnections())); + this._register(this._authenticationService.onDidChangeSessions(event => { + void this._handleAuthenticationSessionsChanged(event.providerId, event.event.removed ?? []); + })); this._reconcile(); } @@ -434,7 +437,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc const vendorDescriptor = { vendor, displayName, configuration: undefined, managementCommand: undefined, when: undefined }; this._languageModelsService.deltaLanguageModelChatProviderDescriptors([vendorDescriptor], []); agentStore.add(toDisposable(() => this._languageModelsService.deltaLanguageModelChatProviderDescriptors([], [vendorDescriptor]))); - const modelProvider = agentStore.add(new AgentHostLanguageModelProvider(sessionType, vendor)); + const modelProvider = agentStore.add(new AgentHostLanguageModelProvider(sessionType, vendor, this._languageModelsService)); connState.modelProviders.set(agent.provider, modelProvider); agentStore.add(toDisposable(() => connState.modelProviders.delete(agent.provider))); agentStore.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider)); @@ -452,6 +455,27 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } } + private async _handleAuthenticationSessionsChanged(providerId: string, removedSessions: readonly AuthenticationSession[]): Promise { + if (removedSessions.length > 0) { + for (const [address, connState] of this._connections) { + const rootState = connState.connection.rootState.value; + if (!rootState || rootState instanceof Error) { + continue; + } + try { + await this._instantiationService.invokeFunction(revokeAuthenticationForRemovedSessions, rootState.agents, providerId, removedSessions, { + authTokenCache: connState.authTokenCache, + logPrefix: '[RemoteAgentHost]', + authenticate: this._authenticateCallback(address, connState.connection), + }); + } catch (error) { + this._logService.error(`[RemoteAgentHost] Failed to revoke removed authentication session for ${address}`, error); + } + } + } + this._authenticateAllConnections(); + } + /** * Authenticate using protectedResources from agent info in root state. * Resolves tokens via the standard VS Code authentication service. @@ -517,7 +541,16 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc if (!transform) { return request => connection.authenticate(request); } - return async request => connection.authenticate(await transform(request)); + return async request => { + // An empty token is the protocol's revocation sentinel, not a credential. + // Token transforms substitute a live credential for an unsealed one, which + // would turn a sign-out into a re-authentication and leave the remote host + // holding a credential the user just revoked. + if (!request.token) { + return connection.authenticate(request); + } + return connection.authenticate(await transform(request)); + }; } /** diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts index 4de49bb7ea9aff..3f8652cb77324d 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -21,7 +21,6 @@ import { ICodeEditor, isCodeEditor } from '../../../../../editor/browser/editorB import { EndOfLinePreference } from '../../../../../editor/common/model.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { SnippetController2 } from '../../../../../editor/contrib/snippet/browser/snippetController2.js'; -import { ITunnelHostService } from '../../../../../workbench/contrib/chat/common/tunnelHost.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -33,7 +32,8 @@ import { IWSLRemoteAgentHostService, WSL_INSTALL_DOCS_URL, type IWSLDistro } fro import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; -import { IQuickInputButton, IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IRemoteTunnelService, TunnelStatus } from '../../../../../platform/remoteTunnel/common/remoteTunnel.js'; import { IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; @@ -855,7 +855,42 @@ async function promptToConnectViaTunnel( const instantiationService = accessor.get(IInstantiationService); const productService = accessor.get(IProductService); const dialogService = accessor.get(IDialogService); - const tunnelHostService = accessor.get(ITunnelHostService); + const remoteTunnelService = accessor.get(IRemoteTunnelService); + const store = new DisposableStore(); + let remoteTunnelStatus: TunnelStatus = { type: 'uninitialized' }; + let hasReceivedRemoteTunnelStatus = false; + let tunnels: ITunnelInfo[] = []; + // eslint-disable-next-line prefer-const + let tunnelPicker: IQuickPick | undefined; + const deleteTunnelButton: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.trash), + tooltip: localize('tunnelDeleteTooltip', "Delete Dev Tunnel"), + }; + const isHostedTunnel = (tunnel: ITunnelInfo): boolean => isTunnelHosted(remoteTunnelStatus.type === 'connected' ? remoteTunnelStatus.info : undefined, tunnel); + const toTunnelPickItems = (tunnelInfos: readonly ITunnelInfo[]): ITunnelPickItem[] => sortTunnelsByName(tunnelInfos) + .filter(tunnel => !isHostedTunnel(tunnel)) + .map(tunnel => ({ + label: tunnel.name, + description: tunnel.hostConnectionCount > 0 + ? localize('tunnelPickOnline', "{0} · Online", tunnel.tunnelId) + : localize('tunnelPickOffline', "{0} · Offline", tunnel.tunnelId), + buttons: tunnelService.canDeleteTunnels ? [deleteTunnelButton] : undefined, + tunnel, + })); + const updateTunnelPickerItems = () => { + if (tunnelPicker) { + tunnelPicker.items = toTunnelPickItems(tunnels); + } + }; + store.add(remoteTunnelService.onDidChangeTunnelStatus(status => { + hasReceivedRemoteTunnelStatus = true; + remoteTunnelStatus = status; + updateTunnelPickerItems(); + })); + const initialRemoteTunnelStatus = await remoteTunnelService.getTunnelStatus(); + if (!hasReceivedRemoteTunnelStatus) { + remoteTunnelStatus = initialRemoteTunnelStatus; + } // Step 1: Determine auth provider — try cached sessions first, then prompt // This used to call tunnelService.getAuthProvider, but for now we're Github- @@ -869,13 +904,13 @@ async function promptToConnectViaTunnel( await authenticationService.createSession(authProvider, scopes, { activateImmediate: true }); } } catch { + store.dispose(); notificationService.error(localize('tunnelAuthFailed', "Authentication failed. Please try again.")); return; } // Step 2: Show tunnel picker immediately in busy state while enumerating - const store = new DisposableStore(); - const tunnelPicker = store.add(quickInputService.createQuickPick()); + tunnelPicker = store.add(quickInputService.createQuickPick()); tunnelPicker.title = localize('tunnelPickTitle', "Connect via Dev Tunnel"); tunnelPicker.placeholder = localize('tunnelPickPlaceholder', "Select a dev tunnel to connect to"); tunnelPicker.busy = true; @@ -884,7 +919,6 @@ async function promptToConnectViaTunnel( } tunnelPicker.show(); - let tunnels: ITunnelInfo[]; try { tunnels = await tunnelService.listTunnels(); } catch (err) { @@ -899,25 +933,6 @@ async function promptToConnectViaTunnel( return; } - const deleteTunnelButton: IQuickInputButton = { - iconClass: ThemeIcon.asClassName(Codicon.trash), - tooltip: localize('tunnelDeleteTooltip', "Delete Dev Tunnel"), - }; - const isHostedTunnel = (tunnel: ITunnelInfo): boolean => isTunnelHosted(tunnelHostService.sharingInfo, tunnel); - const toTunnelPickItems = (tunnelInfos: readonly ITunnelInfo[]): ITunnelPickItem[] => sortTunnelsByName(tunnelInfos) - .filter(tunnel => !isHostedTunnel(tunnel)) - .map(tunnel => ({ - label: tunnel.name, - description: tunnel.hostConnectionCount > 0 - ? localize('tunnelPickOnline', "{0} · Online", tunnel.tunnelId) - : localize('tunnelPickOffline', "{0} · Offline", tunnel.tunnelId), - buttons: tunnelService.canDeleteTunnels ? [deleteTunnelButton] : undefined, - tunnel, - })); - - const updateTunnelPickerItems = () => { - tunnelPicker.items = toTunnelPickItems(tunnels); - }; if (toTunnelPickItems(tunnels).length === 0) { store.dispose(); notificationService.info(localize('tunnelOnlyLocalFound', "This machine is already hosting the only available dev tunnel.")); @@ -925,7 +940,6 @@ async function promptToConnectViaTunnel( } updateTunnelPickerItems(); - store.add(tunnelHostService.onDidChangeStatus(updateTunnelPickerItems)); tunnelPicker.busy = false; // Step 3: Wait for user selection @@ -957,6 +971,10 @@ async function promptToConnectViaTunnel( if (event.button !== deleteTunnelButton || isDeleting) { return; } + if (isHostedTunnel(event.item.tunnel)) { + updateTunnelPickerItems(); + return; + } const previousIgnoreFocusOut = tunnelPicker.ignoreFocusOut; isDeleting = true; @@ -972,6 +990,10 @@ async function promptToConnectViaTunnel( if (!confirmation.confirmed) { return; } + if (isHostedTunnel(event.item.tunnel)) { + updateTunnelPickerItems(); + return; + } tunnelPicker.busy = true; await tunnelService.deleteTunnel(event.item.tunnel); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts index 51cfad7f102e71..2882a3751ca1b2 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts @@ -11,9 +11,9 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; +import { IRemoteTunnelService, TunnelStatus } from '../../../../../platform/remoteTunnel/common/remoteTunnel.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; -import { ITunnelHostService } from '../../../../../workbench/contrib/chat/common/tunnelHost.js'; import { AuthenticationSessionsChangeEvent, IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; import { logTunnelConnectAttempt, logTunnelConnectResolved, logTunnelDiscoveryResult, TunnelDiscoveryTrigger } from '../../../../common/sessionsTelemetry.js'; @@ -34,6 +34,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc private readonly _pendingConnects = new Map>(); private _lastStatusCheck = 0; private readonly _hostedTunnelSuppressions = new Set(); + private _remoteTunnelStatus: TunnelStatus = { type: 'uninitialized' }; + private _hasReceivedRemoteTunnelStatus = false; /** * `false` until the first {@link _silentStatusCheck} resolves. Until then * we keep newly-created providers in the `Connecting` state so the picker @@ -54,7 +56,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IHostService private readonly _hostService: IHostService, - @ITunnelHostService private readonly _tunnelHostService: ITunnelHostService, + @IRemoteTunnelService private readonly _remoteTunnelService: IRemoteTunnelService, @IAgentHostFilterService agentHostFilterService: IAgentHostFilterService, ) { super(); @@ -80,10 +82,13 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc this._reconcileProviders(); })); - this._register(this._tunnelHostService.onDidChangeStatus(() => { + this._register(this._remoteTunnelService.onDidChangeTunnelStatus(status => { + this._hasReceivedRemoteTunnelStatus = true; + this._remoteTunnelStatus = status; this._syncHostedTunnelSuppression(); void this._silentStatusCheck(); })); + void this._loadRemoteTunnelStatus(); this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) { @@ -155,7 +160,15 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc } private _isHostedTunnel(tunnel: Pick): boolean { - return isTunnelHosted(this._tunnelHostService.sharingInfo, tunnel); + return isTunnelHosted(this._remoteTunnelStatus.type === 'connected' ? this._remoteTunnelStatus.info : undefined, tunnel); + } + + private async _loadRemoteTunnelStatus(): Promise { + const status = await this._remoteTunnelService.getTunnelStatus(); + if (!this._hasReceivedRemoteTunnelStatus) { + this._remoteTunnelStatus = status; + } + this._syncHostedTunnelSuppression(); } private _syncHostedTunnelSuppression(): void { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/tunnelAgentHost.contribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/tunnelAgentHost.contribution.test.ts index e84e4eb25e8c7b..b90960759e36f6 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/tunnelAgentHost.contribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/tunnelAgentHost.contribution.test.ts @@ -20,7 +20,6 @@ import { ICachedTunnel, ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, - type ITunnelHostInfo, type ITunnelInfo, type TunnelAutoConnectMode, } from '../../../../../../platform/agentHost/common/tunnelAgentHost.js'; @@ -29,10 +28,10 @@ import { TestConfigurationService } from '../../../../../../platform/configurati import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { ActiveTunnelMode, INACTIVE_TUNNEL_MODE, IRemoteTunnelService, TunnelMode, TunnelStatus } from '../../../../../../platform/remoteTunnel/common/remoteTunnel.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IAuthenticationService } from '../../../../../../workbench/services/authentication/common/authentication.js'; import { IHostService } from '../../../../../../workbench/services/host/browser/host.js'; -import { ITunnelHostService } from '../../../../../../workbench/contrib/chat/common/tunnelHost.js'; import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; import { IAgentHostFilterService } from '../../../../../services/agentHostFilter/common/agentHostFilter.js'; @@ -171,34 +170,42 @@ class StubHostService extends mock() { } } -class StubTunnelHostService extends Disposable implements ITunnelHostService { +class StubRemoteTunnelService extends Disposable implements IRemoteTunnelService { declare readonly _serviceBrand: undefined; - private readonly _onDidChangeStatus = this._register(new Emitter()); - readonly onDidChangeStatus = this._onDidChangeStatus.event; + private readonly _onDidChangeStatus = this._register(new Emitter()); + readonly onDidChangeTunnelStatus = this._onDidChangeStatus.event; + readonly onDidChangeMode = Event.None; + readonly onDidTokenFailed = Event.None; - private _sharingInfo: ITunnelHostInfo | undefined; + private _status: TunnelStatus = { type: 'uninitialized' }; - get isSharing(): boolean { - return this._sharingInfo !== undefined; + getTunnelStatus(): Promise { + return Promise.resolve(this._status); } - get isConnecting(): boolean { - return false; + getMode(): Promise { + return Promise.resolve(INACTIVE_TUNNEL_MODE); } - get sharingInfo(): ITunnelHostInfo | undefined { - return this._sharingInfo; + initialize(_mode: TunnelMode): Promise { + return this.getTunnelStatus(); } - setSharingInfo(tunnelName: string | undefined): void { - this._sharingInfo = tunnelName ? { tunnelName } : undefined; - this._onDidChangeStatus.fire(); + startTunnel(_mode: ActiveTunnelMode): Promise { + return this.getTunnelStatus(); } - async startSharing(): Promise { throw new Error('Not implemented'); } - async stopSharing(): Promise { this.setSharingInfo(undefined); } - async restartSharing(): Promise { throw new Error('Not implemented'); } + async stopTunnel(): Promise { } + + async getTunnelName(): Promise { + return this._status.type === 'connected' ? this._status.info.tunnelName : undefined; + } + + setStatus(status: TunnelStatus): void { + this._status = status; + this._onDidChangeStatus.fire(status); + } } class StubSessionsProvidersService extends Disposable { @@ -265,7 +272,7 @@ suite('TunnelAgentHostContribution', () => { instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None } as unknown as IAuthenticationService); instantiationService.stub(ITelemetryService, { publicLog2: () => { } } as unknown as ITelemetryService); instantiationService.stub(IHostService, hostService); - instantiationService.stub(ITunnelHostService, store.add(new StubTunnelHostService())); + instantiationService.stub(IRemoteTunnelService, store.add(new StubRemoteTunnelService())); instantiationService.stub(IAgentHostFilterService, new StubFilterService() as unknown as IAgentHostFilterService); const contribution = store.add(instantiationService.createInstance(TestTunnelContribution)); @@ -318,7 +325,7 @@ suite('TunnelAgentHostContribution', () => { instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None } as unknown as IAuthenticationService); instantiationService.stub(ITelemetryService, { publicLog2: () => { } } as unknown as ITelemetryService); instantiationService.stub(IHostService, hostService); - instantiationService.stub(ITunnelHostService, store.add(new StubTunnelHostService())); + instantiationService.stub(IRemoteTunnelService, store.add(new StubRemoteTunnelService())); instantiationService.stub(IAgentHostFilterService, new StubFilterService() as unknown as IAgentHostFilterService); const contribution = store.add(instantiationService.createInstance(TestTunnelContribution)); @@ -350,7 +357,7 @@ suite('TunnelAgentHostContribution', () => { const remoteService = store.add(new StubRemoteAgentHostService()); const providersService = store.add(new StubSessionsProvidersService()); const configurationService = new TestConfigurationService({ [RemoteAgentHostsEnabledSettingId]: true }); - const tunnelHostService = store.add(new StubTunnelHostService()); + const remoteTunnelService = store.add(new StubRemoteTunnelService()); const instantiationService = store.add(new TestInstantiationService()); instantiationService.stub(ITunnelAgentHostService, tunnelService); instantiationService.stub(IRemoteAgentHostService, remoteService as unknown as IRemoteAgentHostService); @@ -361,13 +368,13 @@ suite('TunnelAgentHostContribution', () => { instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None } as unknown as IAuthenticationService); instantiationService.stub(ITelemetryService, { publicLog2: () => { } } as unknown as ITelemetryService); instantiationService.stub(IHostService, new StubHostService()); - instantiationService.stub(ITunnelHostService, tunnelHostService); + instantiationService.stub(IRemoteTunnelService, remoteTunnelService); instantiationService.stub(IAgentHostFilterService, new StubFilterService() as unknown as IAgentHostFilterService); const contribution = store.add(instantiationService.createInstance(TestTunnelContribution)); const tunnelId = 'tunnel-hosted'; const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`; - tunnelHostService.setSharingInfo('Hosted Tunnel'); + remoteTunnelService.setStatus({ type: 'connected', info: { tunnelName: 'Hosted Tunnel', tunnelId, isAttached: false }, serviceInstallFailed: false }); tunnelService.setCached([{ tunnelId, clusterId: 'use', name: 'Hosted Tunnel' }]); assert.deepStrictEqual({ @@ -380,7 +387,7 @@ suite('TunnelAgentHostContribution', () => { hasProvider: true, }); - tunnelHostService.setSharingInfo(undefined); + remoteTunnelService.setStatus({ type: 'disconnected' }); assert.strictEqual(tunnelService.isAutoConnectSuppressed(tunnelId), false); }); @@ -399,7 +406,7 @@ suite('TunnelAgentHostContribution', () => { instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None } as unknown as IAuthenticationService); instantiationService.stub(ITelemetryService, { publicLog2: () => { } } as unknown as ITelemetryService); instantiationService.stub(IHostService, new StubHostService()); - instantiationService.stub(ITunnelHostService, store.add(new StubTunnelHostService())); + instantiationService.stub(IRemoteTunnelService, store.add(new StubRemoteTunnelService())); instantiationService.stub(IAgentHostFilterService, new StubFilterService() as unknown as IAgentHostFilterService); const contribution = store.add(instantiationService.createInstance(TestTunnelContribution)); const tunnel: ITunnelInfo = { @@ -478,7 +485,7 @@ suite('TunnelAgentHostContribution', () => { instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None } as unknown as IAuthenticationService); instantiationService.stub(ITelemetryService, { publicLog2: () => { } } as unknown as ITelemetryService); instantiationService.stub(IHostService, hostService); - instantiationService.stub(ITunnelHostService, store.add(new StubTunnelHostService())); + instantiationService.stub(IRemoteTunnelService, store.add(new StubRemoteTunnelService())); instantiationService.stub(IAgentHostFilterService, new StubFilterService() as unknown as IAgentHostFilterService); const contribution = store.add(instantiationService.createInstance(TestTunnelContribution)); const tunnelId = 'tunnel-disconnect'; diff --git a/src/vs/sessions/contrib/sessions/electron-browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/electron-browser/sessions.contribution.ts index dae65a2f8fb49f..09549eeb07ccc9 100644 --- a/src/vs/sessions/contrib/sessions/electron-browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/electron-browser/sessions.contribution.ts @@ -5,6 +5,7 @@ import { localize } from '../../../../nls.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { SESSIONS_APPLICATION_BADGE_SETTING, SessionsApplicationBadge } from './sessionsApplicationBadge.js'; @@ -16,7 +17,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis type: 'boolean', tags: ['preview'], description: localize('sessions.showApplicationBadge', "Controls whether the application icon shows a badge with the number of unarchived sessions that are unread and no longer in progress, need input, or are no longer in progress and have failing CI checks on an open, non-draft pull request. The badge appears on the dock icon on macOS, on the launcher icon on Linux and over the taskbar icon on Windows."), - default: false, + default: product.quality !== 'stable', experiment: { mode: 'auto' } }, }, diff --git a/src/vs/sessions/contrib/tunnelHost/browser/webTunnelHostService.contribution.ts b/src/vs/sessions/contrib/tunnelHost/browser/webTunnelHostService.contribution.ts deleted file mode 100644 index e1027328816203..00000000000000 --- a/src/vs/sessions/contrib/tunnelHost/browser/webTunnelHostService.contribution.ts +++ /dev/null @@ -1,10 +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 { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { ITunnelHostService } from '../../../../workbench/contrib/chat/common/tunnelHost.js'; -import { WebTunnelHostService } from './webTunnelHostService.js'; - -registerSingleton(ITunnelHostService, WebTunnelHostService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/tunnelHost/browser/webTunnelHostService.ts b/src/vs/sessions/contrib/tunnelHost/browser/webTunnelHostService.ts deleted file mode 100644 index 1f5bfc6905e6f1..00000000000000 --- a/src/vs/sessions/contrib/tunnelHost/browser/webTunnelHostService.ts +++ /dev/null @@ -1,44 +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 { ITunnelHostInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; -import { ITunnelHostService } from '../../../../workbench/contrib/chat/common/tunnelHost.js'; - -/** - * Web implementation of {@link ITunnelHostService}. - * - * Hosting a dev tunnel requires spawning the VS Code CLI, which a browser - * cannot do, so the Agents Window on web is never itself a tunnel host. This - * service therefore reports a permanently inactive sharing state rather than - * being absent: consumers such as the tunnel agent host contribution depend on - * it to decide whether a discovered tunnel is the locally hosted one, and a - * missing registration fails their construction entirely. - */ -export class WebTunnelHostService implements ITunnelHostService { - - declare readonly _serviceBrand: undefined; - - /** Sharing can never start on web, so the status never changes. */ - readonly onDidChangeStatus: Event = Event.None; - - readonly isSharing = false; - - readonly isConnecting = false; - - readonly sharingInfo: ITunnelHostInfo | undefined = undefined; - - async startSharing(): Promise { - throw new Error('Sharing the agent host via a dev tunnel is not supported on web.'); - } - - async stopSharing(): Promise { - // Never sharing on web, so there is nothing to tear down. - } - - async restartSharing(): Promise { - // Never sharing on web, so there is nothing to restart. - } -} diff --git a/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts b/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts index ce85c69f2c19e7..ca00486b6c5caa 100644 --- a/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts +++ b/src/vs/sessions/contrib/tunnelHost/electron-browser/tunnelHost.contribution.ts @@ -7,43 +7,67 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { localize } from '../../../../nls.js'; import { IActionViewItemService, type IActionViewItemFactory } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { ITunnelHostService } from '../../../../workbench/contrib/chat/common/tunnelHost.js'; +import { IRemoteTunnelService } from '../../../../platform/remoteTunnel/common/remoteTunnel.js'; import { ToggleRemoteConnectionsActionViewItem } from '../../../../workbench/contrib/chat/electron-browser/toggleRemoteConnectionsActionViewItem.js'; -import { TOGGLE_SHARING_ID, TUNNEL_HOST_SHARING_KEY } from '../../../../workbench/contrib/chat/electron-browser/tunnelHost.contribution.js'; +import { executeToggleRemoteConnections, TUNNEL_HOST_SHARING_KEY } from '../../../../workbench/contrib/chat/electron-browser/tunnelHost.contribution.js'; import { Menus } from '../../../browser/menus.js'; -MenuRegistry.appendMenuItem(Menus.TitleBarRightLayout, { - command: { - id: TOGGLE_SHARING_ID, - title: localize('toggleSharing', "Allow Remote Connections"), - icon: Codicon.radioTower, - toggled: ContextKeyExpr.equals(TUNNEL_HOST_SHARING_KEY, true), - }, - group: 'navigation', - order: 90, - when: ContextKeyExpr.and(ChatContextKeys.enabled, IsSessionsWindowContext, IsAuxiliaryWindowContext.toNegated()) -}); +export const TOGGLE_SHARING_FROM_AGENTS_ID = 'sessions.tunnelHost.toggleSharingFromAgents'; -class SessionsTunnelHostTitlebarContribution extends Disposable implements IWorkbenchContribution { +export class SessionsTunnelHostTitlebarContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsTunnelHostTitlebar'; constructor( - @ITunnelHostService tunnelHostService: ITunnelHostService, + @IRemoteTunnelService remoteTunnelService: IRemoteTunnelService, @IActionViewItemService actionViewItemService: IActionViewItemService, ) { super(); + this._register(MenuRegistry.appendMenuItem(Menus.TitleBarRightLayout, { + command: { + id: TOGGLE_SHARING_FROM_AGENTS_ID, + title: localize('toggleSharing', "Allow Remote Connections"), + icon: Codicon.radioTower, + toggled: ContextKeyExpr.equals(TUNNEL_HOST_SHARING_KEY, true), + }, + group: 'navigation', + order: 90, + when: ContextKeyExpr.and(ChatContextKeys.enabled, IsSessionsWindowContext, IsAuxiliaryWindowContext.toNegated()) + })); + + this._register(registerAction2(class ToggleRemoteConnectionsFromAgentsAction extends Action2 { + constructor() { + super({ + id: TOGGLE_SHARING_FROM_AGENTS_ID, + title: localize('toggleSharing', "Allow Remote Connections"), + icon: Codicon.radioTower, + toggled: ContextKeyExpr.equals(TUNNEL_HOST_SHARING_KEY, true), + }); + } + + async run(accessor: ServicesAccessor): Promise { + await executeToggleRemoteConnections( + accessor.get(IRemoteTunnelService), + accessor.get(ICommandService), + { authenticationProviderId: 'github', showServiceOption: false, showSuccessNotification: false }, + ); + } + })); + const viewItemFactory: IActionViewItemFactory = (action, _options, instantiationService) => { return instantiationService.createInstance(ToggleRemoteConnectionsActionViewItem, action); }; - this._register(actionViewItemService.register(Menus.TitleBarRightLayout, TOGGLE_SHARING_ID, viewItemFactory, tunnelHostService.onDidChangeStatus)); + this._register(actionViewItemService.register(Menus.TitleBarRightLayout, TOGGLE_SHARING_FROM_AGENTS_ID, viewItemFactory, remoteTunnelService.onDidChangeTunnelStatus)); } } -registerWorkbenchContribution2(SessionsTunnelHostTitlebarContribution.ID, SessionsTunnelHostTitlebarContribution, WorkbenchPhase.BlockRestore); +// Remote Tunnel registers its delegated commands during the restored phase. +registerWorkbenchContribution2(SessionsTunnelHostTitlebarContribution.ID, SessionsTunnelHostTitlebarContribution, WorkbenchPhase.Eventually); diff --git a/src/vs/sessions/contrib/tunnelHost/test/browser/webTunnelHostService.test.ts b/src/vs/sessions/contrib/tunnelHost/test/browser/webTunnelHostService.test.ts deleted file mode 100644 index 67ea53cd41104f..00000000000000 --- a/src/vs/sessions/contrib/tunnelHost/test/browser/webTunnelHostService.test.ts +++ /dev/null @@ -1,46 +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 { isTunnelHosted } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; -import { WebTunnelHostService } from '../../browser/webTunnelHostService.js'; - -suite('Sessions - Web Tunnel Host Service', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('reports a permanently inactive sharing state', async () => { - const service = new WebTunnelHostService(); - const tunnel = { tunnelId: 'tunnel-1', name: 'my-host' }; - - let startError: string | undefined; - try { - await service.startSharing(); - } catch (err) { - startError = err instanceof Error ? err.message : String(err); - } - - // Stopping is a no-op rather than an error so generic teardown paths - // can call it unconditionally. - await service.stopSharing(); - - assert.deepStrictEqual({ - isSharing: service.isSharing, - isConnecting: service.isConnecting, - sharingInfo: service.sharingInfo, - // No tunnel is ever the locally hosted one on web, so discovered - // tunnels must never be filtered out of the picker. - hostedTunnel: isTunnelHosted(service.sharingInfo, tunnel), - startError, - }, { - isSharing: false, - isConnecting: false, - sharingInfo: undefined, - hostedTunnel: false, - startError: 'Sharing the agent host via a dev tunnel is not supported on web.', - }); - }); -}); diff --git a/src/vs/sessions/contrib/tunnelHost/test/electron-browser/tunnelHost.contribution.test.ts b/src/vs/sessions/contrib/tunnelHost/test/electron-browser/tunnelHost.contribution.test.ts index f6c625988fbd41..fa3345a27f5151 100644 --- a/src/vs/sessions/contrib/tunnelHost/test/electron-browser/tunnelHost.contribution.test.ts +++ b/src/vs/sessions/contrib/tunnelHost/test/electron-browser/tunnelHost.contribution.test.ts @@ -6,26 +6,56 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { CommandsRegistry, ICommandService } from '../../../../../platform/commands/common/commands.js'; import type { ContextKeyExpression, ContextKeyValue } from '../../../../../platform/contextkey/common/contextkey.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { INACTIVE_TUNNEL_MODE, IRemoteTunnelService, type TunnelMode, type TunnelStatus } from '../../../../../platform/remoteTunnel/common/remoteTunnel.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IsAuxiliaryWindowContext, IsSessionsWindowContext, RemoteNameContext } from '../../../../../workbench/common/contextkeys.js'; import { Menus } from '../../../../browser/menus.js'; +import { RemoteTunnelCommandIds } from '../../../../../workbench/contrib/remoteTunnel/electron-browser/remoteTunnel.contribution.js'; +import { TOGGLE_SHARING_ID } from '../../../../../workbench/contrib/chat/electron-browser/tunnelHost.contribution.js'; +import { SessionsTunnelHostTitlebarContribution, TOGGLE_SHARING_FROM_AGENTS_ID } from '../../electron-browser/tunnelHost.contribution.js'; -import '../../electron-browser/tunnelHost.contribution.js'; +class TestRemoteTunnelService extends mock() { + override getMode(): Promise { + return Promise.resolve(INACTIVE_TUNNEL_MODE); + } + + override getTunnelStatus(): Promise { + return Promise.resolve({ type: 'disconnected' }); + } +} + +class TestCommandService extends mock() { + readonly calls: Array<{ id: string; args: unknown[] }> = []; + + override executeCommand(id: string, ...args: unknown[]): Promise { + this.calls.push({ id, args }); + return Promise.resolve(undefined); + } +} suite('Sessions - Tunnel Host Contribution', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('remote connections toggle is in Agents titlebar and non-Agents chat input', () => { - const findToggle = (menu: MenuId) => MenuRegistry.getMenuItems(menu) + test('registers the remote connections toggle with the titlebar contribution', () => { + const findToggle = (menu: MenuId, id: string) => MenuRegistry.getMenuItems(menu) .filter(isIMenuItem) - .find(item => item.command.id === 'sessions.tunnelHost.toggleSharing'); + .find(item => item.command.id === id); + + assert.strictEqual(findToggle(Menus.TitleBarRightLayout, TOGGLE_SHARING_FROM_AGENTS_ID), undefined); + assert.strictEqual(CommandsRegistry.getCommand(TOGGLE_SHARING_FROM_AGENTS_ID), undefined); + + const contribution = new SessionsTunnelHostTitlebarContribution(new TestRemoteTunnelService(), new NullActionViewItemService()); - const summarize = (menu: MenuId) => { - const item = findToggle(menu); + const summarize = (menu: MenuId, id: string) => { + const item = findToggle(menu, id); return item && { group: item.group, order: item.order, @@ -33,42 +63,71 @@ suite('Sessions - Tunnel Host Contribution', () => { }; }; - assert.deepStrictEqual({ - titlebar: summarize(Menus.TitleBarRightLayout), - chatInput: summarize(MenuId.ChatInputSecondary), - }, { - titlebar: { group: 'navigation', order: 90, icon: Codicon.radioTower.id }, - chatInput: { group: 'navigation', order: 10, icon: Codicon.radioTower.id }, - }); - - const titlebarToggle = findToggle(Menus.TitleBarRightLayout); - const chatInputToggle = findToggle(MenuId.ChatInputSecondary); - if (!titlebarToggle?.when || !chatInputToggle?.when) { - assert.fail('remote connections menu items should have when clauses'); + try { + assert.deepStrictEqual({ + titlebar: summarize(Menus.TitleBarRightLayout, TOGGLE_SHARING_FROM_AGENTS_ID), + chatInput: summarize(MenuId.ChatInputSecondary, TOGGLE_SHARING_ID), + }, { + titlebar: { group: 'navigation', order: 90, icon: Codicon.radioTower.id }, + chatInput: { group: 'navigation', order: 10, icon: Codicon.radioTower.id }, + }); + + const titlebarToggle = findToggle(Menus.TitleBarRightLayout, TOGGLE_SHARING_FROM_AGENTS_ID); + const chatInputToggle = findToggle(MenuId.ChatInputSecondary, TOGGLE_SHARING_ID); + if (!titlebarToggle?.when || !chatInputToggle?.when) { + assert.fail('remote connections menu items should have when clauses'); + } + + const evalWhen = (when: ContextKeyExpression, values: Record) => { + return when.evaluate({ getValue: (key: string) => values[key] as T }); + }; + const agentHostChat = { + [ChatContextKeys.enabled.key]: true, + [ChatContextKeys.chatIsAgentHostSession.key]: true, + [IsAuxiliaryWindowContext.key]: false, + [RemoteNameContext.key]: '', + }; + + assert.deepStrictEqual({ + agentsTitlebar: evalWhen(titlebarToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: true }), + editorTitlebar: evalWhen(titlebarToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: false }), + agentsChatInput: evalWhen(chatInputToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: true }), + editorChatInput: evalWhen(chatInputToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: false }), + remoteEditorChatInput: evalWhen(chatInputToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: false, [RemoteNameContext.key]: 'ssh-remote' }), + }, { + agentsTitlebar: true, + editorTitlebar: false, + agentsChatInput: false, + editorChatInput: true, + remoteEditorChatInput: false, + }); + } finally { + contribution.dispose(); } - const evalWhen = (when: ContextKeyExpression, values: Record) => { - return when.evaluate({ getValue: (key: string) => values[key] as T }); - }; - const agentHostChat = { - [ChatContextKeys.enabled.key]: true, - [ChatContextKeys.chatIsAgentHostSession.key]: true, - [IsAuxiliaryWindowContext.key]: false, - [RemoteNameContext.key]: '', - }; + assert.strictEqual(findToggle(Menus.TitleBarRightLayout, TOGGLE_SHARING_FROM_AGENTS_ID), undefined); + assert.strictEqual(CommandsRegistry.getCommand(TOGGLE_SHARING_FROM_AGENTS_ID), undefined); + }); - assert.deepStrictEqual({ - agentsTitlebar: evalWhen(titlebarToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: true }), - editorTitlebar: evalWhen(titlebarToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: false }), - agentsChatInput: evalWhen(chatInputToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: true }), - editorChatInput: evalWhen(chatInputToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: false }), - remoteEditorChatInput: evalWhen(chatInputToggle.when, { ...agentHostChat, [IsSessionsWindowContext.key]: false, [RemoteNameContext.key]: 'ssh-remote' }), - }, { - agentsTitlebar: true, - editorTitlebar: false, - agentsChatInput: false, - editorChatInput: true, - remoteEditorChatInput: false, - }); + test('Agents turn-on forces GitHub without offering service installation', async () => { + const instantiationService = new TestInstantiationService(); + const commandService = new TestCommandService(); + instantiationService.stub(IRemoteTunnelService, new TestRemoteTunnelService()); + instantiationService.stub(ICommandService, commandService); + const contribution = new SessionsTunnelHostTitlebarContribution(new TestRemoteTunnelService(), new NullActionViewItemService()); + + try { + const command = CommandsRegistry.getCommand(TOGGLE_SHARING_FROM_AGENTS_ID); + assert.ok(command); + + await instantiationService.invokeFunction(command.handler); + + assert.deepStrictEqual(commandService.calls, [{ + id: RemoteTunnelCommandIds.turnOn, + args: [{ authenticationProviderId: 'github', showServiceOption: false, showSuccessNotification: false }], + }]); + } finally { + contribution.dispose(); + } }); }); diff --git a/src/vs/sessions/services/chatBackground/browser/chatBackgroundService.ts b/src/vs/sessions/services/chatBackground/browser/chatBackgroundService.ts index eb37084546c801..8307216b1b567d 100644 --- a/src/vs/sessions/services/chatBackground/browser/chatBackgroundService.ts +++ b/src/vs/sessions/services/chatBackground/browser/chatBackgroundService.ts @@ -20,7 +20,8 @@ import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundConfigure export const AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING = 'chat.agentSessions.preferredDarkBackgroundImage'; export const AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING = 'chat.agentSessions.preferredLightBackgroundImage'; -export const AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING = 'chat.agentSessions.backgroundImageLayout'; +export const AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING = 'chat.agentSessions.preferredDarkBackgroundImageLayout'; +export const AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING = 'chat.agentSessions.preferredLightBackgroundImageLayout'; export const AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET = 'codicons'; export type SessionsChatBackgroundPreset = typeof AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET; const RECENT_BACKGROUND_IMAGES_STORAGE_KEY = 'chat.agentSessions.recentBackgroundImages'; @@ -102,7 +103,8 @@ export class SessionsChatBackgroundService extends Disposable implements ISessio this._register(this.configurationService.onDidChangeConfiguration(event => { const backgroundImageChanged = event.affectsConfiguration(AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING) || event.affectsConfiguration(AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING); - const backgroundImageLayoutChanged = event.affectsConfiguration(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING); + const backgroundImageLayoutChanged = event.affectsConfiguration(AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING) + || event.affectsConfiguration(AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING); let backgroundChanged = backgroundImageChanged; if (backgroundImageChanged) { updateContextKeys(); @@ -119,6 +121,7 @@ export class SessionsChatBackgroundService extends Disposable implements ISessio } })); this._register(this.themeService.onDidColorThemeChange(() => { + this.backgroundImageLayout = this.readConfiguredBackgroundImageLayout(); updateContextKeys(); this._onDidChangeBackground.fire(); })); @@ -171,13 +174,14 @@ export class SessionsChatBackgroundService extends Disposable implements ISessio } async setBackgroundImageLayout(layout: ChatBackgroundImageLayout, persist = true): Promise { + const setting = this.getBackgroundImageLayoutSetting(this.themeService.getColorTheme().type); if (layout !== this.backgroundImageLayout) { this.backgroundImageLayout = layout; this._onDidChangeBackground.fire(); } if (persist) { try { - await this.configurationService.updateValue(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, layout, ConfigurationTarget.APPLICATION); + await this.configurationService.updateValue(setting, layout, ConfigurationTarget.USER); } catch (error) { const configuredLayout = this.readConfiguredBackgroundImageLayout(); if (configuredLayout !== this.backgroundImageLayout) { @@ -190,7 +194,8 @@ export class SessionsChatBackgroundService extends Disposable implements ISessio } private readConfiguredBackgroundImageLayout(): ChatBackgroundImageLayout { - const value = this.configurationService.getValue(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING); + const colorSchemeSetting = this.getBackgroundImageLayoutSetting(this.themeService.getColorTheme().type); + const value = this.configurationService.getValue(colorSchemeSetting); return chatBackgroundImageLayoutValues.includes(value as ChatBackgroundImageLayout) ? value as ChatBackgroundImageLayout : 'repeat'; @@ -202,6 +207,12 @@ export class SessionsChatBackgroundService extends Disposable implements ISessio : AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING; } + private getBackgroundImageLayoutSetting(colorScheme: ColorScheme): string { + return isDark(colorScheme) + ? AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING + : AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING; + } + private getConfiguredBackground(): { readonly kind: 'codicons' } | { readonly kind: 'image'; readonly image: URI } | undefined { const setting = this.getBackgroundImageSetting(this.themeService.getColorTheme().type); const value = this.configurationService.getValue(setting); diff --git a/src/vs/sessions/services/chatBackground/test/browser/chatBackgroundService.test.ts b/src/vs/sessions/services/chatBackground/test/browser/chatBackgroundService.test.ts index 144356f94ce8d5..acd42306b4b7d6 100644 --- a/src/vs/sessions/services/chatBackground/test/browser/chatBackgroundService.test.ts +++ b/src/vs/sessions/services/chatBackground/test/browser/chatBackgroundService.test.ts @@ -14,7 +14,7 @@ import { InMemoryStorageService } from '../../../../../platform/storage/common/s import { ColorScheme } from '../../../../../platform/theme/common/theme.js'; import { TestColorTheme, TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; import { SessionsChatBackgroundAvailableContext, SessionsChatBackgroundConfiguredContext, SessionsChatBackgroundImageConfiguredContext } from '../../../../common/contextkeys.js'; -import { AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatImageBackground, SessionsChatBackgroundService } from '../../browser/chatBackgroundService.js'; +import { AGENT_SESSIONS_CHAT_BACKGROUND_CODICONS_PRESET, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING, chatBackgroundImageLayoutValues, ChatBackgroundImageLayout, ISessionsChatImageBackground, SessionsChatBackgroundService } from '../../browser/chatBackgroundService.js'; class CapturingConfigurationService extends TestConfigurationService { readonly updates: { key: string; value: unknown; target: ConfigurationTarget | undefined }[] = []; @@ -26,7 +26,7 @@ class CapturingConfigurationService extends TestConfigurationService { override updateValue(key: string, value: unknown, overrides: IConfigurationOverrides | IConfigurationUpdateOverrides, target: ConfigurationTarget, options?: IConfigurationUpdateOptions): Promise; override updateValue(key: string, value: unknown, arg3?: ConfigurationTarget | IConfigurationOverrides | IConfigurationUpdateOverrides, target?: ConfigurationTarget): Promise { this.updates.push({ key, value, target: typeof arg3 === 'number' ? arg3 : target }); - return this.updateError ? Promise.reject(this.updateError) : Promise.resolve(); + return this.updateError ? Promise.reject(this.updateError) : this.setUserConfiguration(key, value); } } @@ -60,7 +60,8 @@ suite('Sessions Chat Background Service', () => { const configurationService = new TestConfigurationService({ [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING]: URI.file('/textures/dark.png').fsPath, [AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_SETTING]: URI.file('/textures/light.png').fsPath, - [AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: 'center', + [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: 'center', + [AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: 'center', }); const themeService = new TestThemeService(); const contextKeyService = disposables.add(new MockContextKeyService()); @@ -149,14 +150,14 @@ suite('Sessions Chat Background Service', () => { test('returns every configured image layout', async () => { const configurationService = new TestConfigurationService({ [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING]: URI.file('/textures/kirby.png').fsPath, - [AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: 'repeat', + [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: 'repeat', }); const service = disposables.add(new SessionsChatBackgroundService(configurationService, new TestThemeService(), disposables.add(new MockContextKeyService()), disposables.add(new InMemoryStorageService()))); const actual: Partial | undefined>> = {}; for (const layout of chatBackgroundImageLayoutValues) { - await configurationService.setUserConfiguration(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, layout); - fireConfigurationChange(configurationService, AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING); + await configurationService.setUserConfiguration(AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, layout); + fireConfigurationChange(configurationService, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING); const background = service.getBackground(); if (background?.kind === 'image') { actual[layout] = { @@ -182,10 +183,75 @@ suite('Sessions Chat Background Service', () => { }); }); - test('updates the image layout without persisting until the final value is committed', async () => { - const configurationService = new TestConfigurationService({ + test('keeps dark and light image layouts independent', async () => { + const configurationService = new CapturingConfigurationService(); + const themeService = new TestThemeService(); + const service = disposables.add(new SessionsChatBackgroundService(configurationService, themeService, disposables.add(new MockContextKeyService()), disposables.add(new InMemoryStorageService()))); + + await service.setBackgroundImageLayout('right'); + themeService.setTheme(new TestColorTheme({}, ColorScheme.LIGHT)); + const initialLightLayout = service.getBackgroundImageLayout(); + await service.setBackgroundImageLayout('left'); + themeService.setTheme(new TestColorTheme({}, ColorScheme.DARK)); + + assert.deepStrictEqual({ + initialLightLayout, + restoredDarkLayout: service.getBackgroundImageLayout(), + updates: configurationService.updates, + }, { + initialLightLayout: 'repeat', + restoredDarkLayout: 'right', + updates: [{ + key: AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, + value: 'right', + target: ConfigurationTarget.USER, + }, { + key: AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, + value: 'left', + target: ConfigurationTarget.USER, + }], + }); + }); + + test('reads and writes the image layout for the active color scheme', async () => { + const configurationService = new CapturingConfigurationService({ + [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: 'top', + [AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: 'bottom', + }); + const themeService = new TestThemeService(); + const service = disposables.add(new SessionsChatBackgroundService(configurationService, themeService, disposables.add(new MockContextKeyService()), disposables.add(new InMemoryStorageService()))); + + const initialDarkLayout = service.getBackgroundImageLayout(); + await configurationService.setUserConfiguration(AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, 'right'); + fireConfigurationChange(configurationService, AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING); + const updatedDarkLayout = service.getBackgroundImageLayout(); + themeService.setTheme(new TestColorTheme({}, ColorScheme.LIGHT)); + const initialLightLayout = service.getBackgroundImageLayout(); + await service.setBackgroundImageLayout('left'); + + assert.deepStrictEqual({ + initialDarkLayout, + updatedDarkLayout, + initialLightLayout, + updatedLightLayout: service.getBackgroundImageLayout(), + updates: configurationService.updates, + }, { + initialDarkLayout: 'top', + updatedDarkLayout: 'right', + initialLightLayout: 'bottom', + updatedLightLayout: 'left', + updates: [{ + key: AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, + value: 'left', + target: ConfigurationTarget.USER, + }], + }); + }); + + test('restores the active color scheme layout when preview is cancelled', async () => { + const configurationService = new CapturingConfigurationService({ [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_SETTING]: URI.file('/textures/kirby.png').fsPath, - [AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: 'center', + [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: 'center', }); const service = disposables.add(new SessionsChatBackgroundService(configurationService, new TestThemeService(), disposables.add(new MockContextKeyService()), disposables.add(new InMemoryStorageService()))); let changes = 0; @@ -198,28 +264,29 @@ suite('Sessions Chat Background Service', () => { const configuredPosition = getPosition(); await service.setBackgroundImageLayout('bottom-right', false); const previewPosition = getPosition(); - const persistedDuringPreview = configurationService.getValue(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING); - await service.setBackgroundImageLayout('center', true); + await service.setBackgroundImageLayout('center', false); assert.deepStrictEqual({ configuredPosition, previewPosition, - persistedDuringPreview, restoredPosition: getPosition(), - persistedLayout: configurationService.getValue(AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING), + persistedDarkLayout: configurationService.getValue(AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING), + updates: configurationService.updates, changes, }, { configuredPosition: 'center center', previewPosition: 'right bottom', - persistedDuringPreview: 'center', restoredPosition: 'center center', - persistedLayout: 'center', + persistedDarkLayout: 'center', + updates: [], changes: 2, }); }); test('restores the configured image layout when persistence fails', async () => { - const configurationService = new CapturingConfigurationService(); + const configurationService = new CapturingConfigurationService({ + [AGENT_SESSIONS_PREFERRED_DARK_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING]: 'center', + }); const service = disposables.add(new SessionsChatBackgroundService(configurationService, new TestThemeService(), disposables.add(new MockContextKeyService()), disposables.add(new InMemoryStorageService()))); let changes = 0; disposables.add(service.onDidChangeBackground(() => changes++)); @@ -232,7 +299,7 @@ suite('Sessions Chat Background Service', () => { layout: service.getBackgroundImageLayout(), changes, }, { - layout: 'repeat', + layout: 'center', changes: 2, }); }); @@ -267,7 +334,7 @@ suite('Sessions Chat Background Service', () => { }); }); - test('updates the background for the active color theme and the shared layout', async () => { + test('updates the background and image layout for the active color theme', async () => { const image = URI.file('/textures/kirby.png'); const configurationService = new CapturingConfigurationService(); const themeService = new TestThemeService(); @@ -297,9 +364,9 @@ suite('Sessions Chat Background Service', () => { value: image.fsPath, target: ConfigurationTarget.USER, }, { - key: AGENT_SESSIONS_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, + key: AGENT_SESSIONS_PREFERRED_LIGHT_CHAT_BACKGROUND_IMAGE_LAYOUT_SETTING, value: 'bottom-right', - target: ConfigurationTarget.APPLICATION, + target: ConfigurationTarget.USER, }]); }); }); diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 7a757d6ae4f28b..af563f59fc7087 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -78,6 +78,7 @@ import '../workbench/services/auxiliaryWindow/browser/auxiliaryWindowService.js' import '../workbench/services/power/browser/powerService.js'; import '../workbench/services/localTranscription/browser/localTranscriptionService.js'; import '../platform/sandbox/browser/sandboxHelperService.js'; +import '../platform/remoteTunnel/browser/remoteTunnelService.js'; import { InstantiationType, registerSingleton } from '../platform/instantiation/common/extensions.js'; import { IAccessibilityService } from '../platform/accessibility/common/accessibility.js'; @@ -159,11 +160,6 @@ import '../workbench/contrib/welcomeBanner/browser/welcomeBanner.contribution.js // Web tunnel agent host — discovers tunnels via Dev Tunnels REST API and connects via relay import './contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.contribution.js'; -// Tunnel hosting is CLI-backed and therefore unavailable in the browser, but -// the tunnel agent host contribution below still depends on the service to -// identify a locally hosted tunnel. Register the inert web implementation. -import './contrib/tunnelHost/browser/webTunnelHostService.contribution.js'; - // Tunnel agent host — reconciles discovered tunnels into session providers import './contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.js'; diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index ea049d2a10ca0b..defff36019518b 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -90,7 +90,6 @@ suite('Sessions - Workbench', () => { _restoreSidePaneEditorMaximizedOnShow: boolean; _hasAppliedInitialEditorSplit: boolean; _dockedAuxiliaryBarWidth: number; - _restoreEqualSplitOnDetailsHide: boolean; _memento: DockedEditorSizeMemento; readonly resizes: IViewSize[]; readonly distributions: object[]; @@ -307,7 +306,6 @@ suite('Sessions - Workbench', () => { viewDescriptorService: options.viewDescriptorService ?? { getDefaultViewContainer: () => undefined }, // docked bookkeeping _dockedAuxiliaryBarWidth: options.dockedWidth ?? DockedAuxiliaryBarController.DEFAULT_WIDTH, - _restoreEqualSplitOnDetailsHide: false, _syncingEditorVisibility: false, _memento: new DockedEditorSizeMemento(), // stubs for the heavy base helpers the hooks call @@ -1009,12 +1007,9 @@ suite('Sessions - Workbench', () => { }); test('persisted editor width excludes the detail only when the detail is visible', () => { - // Editor + detail visible: the node includes the detail, so it is excluded - // to store the pure editor-content width (reconstructed by adding it back). + // The persisted editor width represents editor content; the descriptor adds + // Details back when reconstructing the shared side-pane node. const withDetail = createHost({ single: true, dockedWidth: 300, partVisibility: { editor: true, auxiliaryBar: true } }); - // Editor-only (detail closed): the node is pure editor content, so nothing - // is subtracted — otherwise the side pane would shrink by the detail width - // on every reload (compounding toward zero). const editorOnly = createHost({ single: true, dockedWidth: 300, partVisibility: { editor: true, auxiliaryBar: false } }); assert.deepStrictEqual({ @@ -1443,13 +1438,11 @@ suite('Sessions - Workbench', () => { existingWidths, editorVisible: host.partVisibility.editor, detailsVisible: host.partVisibility.auxiliaryBar, - persistedEditorWidth: host._savedPartSizes.editor, snapshot: host._memento.dockedEditorSizeBeforeHide, }, { existingWidths: [758, 758], editorVisible: true, detailsVisible: false, - persistedEditorWidth: 758, snapshot: undefined, }); }); @@ -1500,7 +1493,6 @@ suite('Sessions - Workbench', () => { assert.deepStrictEqual({ medium: SinglePaneWorkbench.prototype.getPreferredEditorPartWidth.call(medium), - mediumRestoreEqualSplitOnHide: medium._restoreEqualSplitOnDetailsHide, wide: SinglePaneWorkbench.prototype.getPreferredEditorPartWidth.call(wide), wideDetails: SinglePaneWorkbench.prototype.getPreferredEditorPartWidth.call(wideDetails), constrained: SinglePaneWorkbench.prototype.getPreferredEditorPartWidth.call(constrained), @@ -1510,7 +1502,6 @@ suite('Sessions - Workbench', () => { editorHidden: SinglePaneWorkbench.prototype.getPreferredEditorPartWidth.call(editorHidden), }, { medium: 850, - mediumRestoreEqualSplitOnHide: true, wide: 950, wideDetails: 1050, constrained: 650, @@ -1521,7 +1512,7 @@ suite('Sessions - Workbench', () => { }); }); - test('closing Details after a no-op balanced sash reset restores an equal Sessions and Editor split', () => { + test('closing Details after a balanced sash reset leaves the side-pane boundary unchanged', () => { const host = createHost({ single: true, sessionsWidth: 560, editorWidth: 840, dockedWidth: 280, partVisibility: { editor: true, auxiliaryBar: true } }); const resetWidth = SinglePaneWorkbench.prototype.getPreferredEditorPartWidth.call(host); @@ -1529,16 +1520,14 @@ suite('Sessions - Workbench', () => { assert.deepStrictEqual({ resetWidth, - restoreEqualSplitOnHide: host._restoreEqualSplitOnDetailsHide, resizes: host.resizes, }, { resetWidth: 840, - restoreEqualSplitOnHide: false, - resizes: [{ width: 700, height: 800 }], + resizes: [], }); }); - test('hiding Details after a balanced reset uses the live width for an equal split', () => { + test('hiding Details after a balanced reset leaves a later sash resize unchanged', () => { const host = createHost({ single: true, sessionsWidth: 560, editorWidth: 840, dockedWidth: 280, partVisibility: { editor: true, auxiliaryBar: true } }); SinglePaneWorkbench.prototype.getPreferredEditorPartWidth.call(host); host.workbenchGrid.resizeView(host.sessionsPartView, { width: 800, height: 800 }); @@ -1547,16 +1536,10 @@ suite('Sessions - Workbench', () => { setAuxiliaryBarHidden.call(host, true); - assert.deepStrictEqual({ - restoreEqualSplitOnHide: host._restoreEqualSplitOnDetailsHide, - resizes: host.resizes, - }, { - restoreEqualSplitOnHide: false, - resizes: [{ width: 1000, height: 800 }], - }); + assert.deepStrictEqual(host.resizes, []); }); - test('manual sash resize preserves the pending Details-hide reset behavior', () => { + test('manual sash resize does not make hiding Details move the side-pane boundary', () => { const host = createHost({ single: true, sessionsWidth: 560, editorWidth: 840, dockedWidth: 280, partVisibility: { editor: true, auxiliaryBar: true } }); SinglePaneWorkbench.prototype.getPreferredEditorPartWidth.call(host); host.workbenchGrid.resizeView(host.sessionsPartView, { width: 700, height: 800 }); @@ -1566,33 +1549,20 @@ suite('Sessions - Workbench', () => { onEditorNodeResized.call(host, 700); setAuxiliaryBarHidden.call(host, true); - assert.deepStrictEqual({ - restoreEqualSplitOnHide: host._restoreEqualSplitOnDetailsHide, - resizes: host.resizes, - }, { - restoreEqualSplitOnHide: false, - resizes: [{ width: 700, height: 800 }], - }); + assert.deepStrictEqual(host.resizes, []); }); - test('hiding Editor clears the pending Details-hide reset behavior', () => { + test('hiding Details after Editor restores the captured side-pane width', () => { const host = createHost({ single: true, sessionsWidth: 560, editorWidth: 840, dockedWidth: 280, partVisibility: { editor: true, auxiliaryBar: true } }); SinglePaneWorkbench.prototype.getPreferredEditorPartWidth.call(host); setEditorHidden.call(host, true, true); setAuxiliaryBarHidden.call(host, true); - assert.deepStrictEqual({ - restoreEqualSplitOnHide: host._restoreEqualSplitOnDetailsHide, - resizes: host.resizes, - }, { - restoreEqualSplitOnHide: false, - resizes: [ - { width: 280, height: 800 }, - { width: 840, height: 800 }, - { width: 560, height: 800 }, - ], - }); + assert.deepStrictEqual(host.resizes, [ + { width: 280, height: 800 }, + { width: 840, height: 800 }, + ]); }); test('single-pane editor part is a snap view only while editor content is hidden (docked detail-only)', () => { @@ -2442,34 +2412,32 @@ suite('Sessions - Workbench', () => { // --- Docked auxiliary bar visibility ----------------------------------- - test('docked auxiliary bar takes its toggle width from the Sessions pane', () => { - const host = createHost({ single: true, editorWidth: 640, dockedWidth: 280, partVisibility: { editor: true, auxiliaryBar: false } }); + test('docked auxiliary bar takes its toggle width from the editor area', () => { + const host = createHost({ single: true, sessionsWidth: 720, editorWidth: 640, dockedWidth: 280, partVisibility: { editor: true, auxiliaryBar: false } }); setAuxiliaryBarHidden.call(host, false); setAuxiliaryBarHidden.call(host, true); assert.deepStrictEqual({ auxiliaryBarVisible: host.partVisibility.auxiliaryBar, + sessionsWidth: host.workbenchGrid.getViewSize(host.sessionsPartView).width, + editorWidth: host.workbenchGrid.getViewSize(host.editorPartView).width, resizes: host.resizes, }, { auxiliaryBarVisible: false, - resizes: [ - { width: 920, height: 800 }, - { width: 640, height: 800 }, - ], + sessionsWidth: 720, + editorWidth: 640, + resizes: [], }); }); - test('docked auxiliary bar takes its toggle width from Sessions even when the editor is wide', () => { + test('docked auxiliary bar leaves a wide editor node unchanged when toggled', () => { const host = createHost({ single: true, editorWidth: 900, dockedWidth: 300, partVisibility: { editor: true, auxiliaryBar: false } }); setAuxiliaryBarHidden.call(host, false); setAuxiliaryBarHidden.call(host, true); - assert.deepStrictEqual(host.resizes, [ - { width: 1200, height: 800 }, - { width: 900, height: 800 }, - ]); + assert.deepStrictEqual(host.resizes, []); }); test('[reload] restoring docked auxiliary bar uses the persisted combined width without cumulative growth', () => { @@ -2480,28 +2448,22 @@ suite('Sessions - Workbench', () => { suppressionCount: 1, partVisibility: { editor: true, auxiliaryBar: false } }); - host._savedPartSizes.editor = 600; + host._savedPartSizes.editor = 900; setAuxiliaryBarHidden.call(host, false); setAuxiliaryBarHiddenForResize.call(host, true); setAuxiliaryBarHidden.call(host, false); - assert.deepStrictEqual(host.resizes, [ - { width: 900, height: 800 }, - { width: 900, height: 800 }, - ]); + assert.deepStrictEqual(host.resizes, []); }); - test('docked auxiliary bar returns its full width to Sessions when hidden', () => { + test('docked auxiliary bar returns its full width to the editor area when hidden', () => { const host = createHost({ single: true, editorWidth: 420, dockedWidth: 300, partVisibility: { editor: true, auxiliaryBar: false } }); setAuxiliaryBarHidden.call(host, false); setAuxiliaryBarHidden.call(host, true); - assert.deepStrictEqual(host.resizes, [ - { width: 720, height: 800 }, - { width: 420, height: 800 }, - ]); + assert.deepStrictEqual(host.resizes, []); }); test('docked auxiliary bar hide reveals hidden editor content', () => { diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 4bb35e476f1b73..5274edb2b689d5 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -57,6 +57,7 @@ import { IBrowserDeviceProfile, IBrowserViewPermissionRequestEvent, IBrowserElementSelectionState, + IBrowserViewHost, } from '../../../../platform/browserView/common/browserView.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; @@ -362,6 +363,7 @@ export interface IBrowserViewCDPService { */ export interface IBrowserViewModel extends IDisposable { readonly id: string; + readonly host: IBrowserViewHost; readonly owner: IBrowserViewOwner; readonly associatedResource: URI | undefined; readonly url: string; @@ -486,6 +488,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { constructor( readonly id: string, + readonly host: IBrowserViewHost, owner: IBrowserViewOwner, readonly associatedResource: URI | undefined, initialState: IBrowserViewState, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts index abf2c74f1e0982..b90f439389b18f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewCDPService.ts @@ -30,7 +30,9 @@ export class BrowserViewCDPService extends Disposable implements IBrowserViewCDP return this._groupService.createGroup( { browserIds: [browserId] }, { - hostWindowId: mainWindow.vscodeWindowId, + host: { + windowId: mainWindow.vscodeWindowId + }, owner: { type: 'user' }, session: { scope: BrowserViewStorageScope.Ephemeral } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index 99f97400015066..3e987b284d8c43 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -175,7 +175,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV // Listen for new browser views this._register(this._browserViewService.onDidCreateBrowserView(e => { - if (e.info.hostWindowId !== this._mainWindowId) { + if (e.info.host.windowId !== this._mainWindowId) { return; // Not for this window } @@ -363,7 +363,9 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV const info = await this._browserViewService.getOrCreateBrowserView( id, { - hostWindowId: this._mainWindowId, + host: { + windowId: this._mainWindowId + }, owner: createOptions?.owner ?? { type: 'user' }, associatedResource, session: createOptions?.session ?? { scope: await this._resolveStorageScope() }, @@ -451,7 +453,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV : initialUrl ? { ...info.state, url: initialUrl } : info.state; - const model = this.instantiationService.createInstance(BrowserViewModel, info.id, info.owner, associatedResource, state, this._browserViewService); + const model = this.instantiationService.createInstance(BrowserViewModel, info.id, info.host, info.owner, associatedResource, state, this._browserViewService); // Sanity: both pass and assign the model to be sure. It will no-op if already set. this._getOrCreateLazy({ id: info.id, associatedResource, url: initialUrl }, model).model = model; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts index 9e95780fcf1333..73d6807a437510 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts @@ -6,6 +6,7 @@ import { fetchAuthorizationServerMetadata } from '../../../../../../base/common/oauth.js'; import { SequencerByKey } from '../../../../../../base/common/async.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; +import { match } from '../../../../../../base/common/glob.js'; import { URI } from '../../../../../../base/common/uri.js'; import { readAgentModelByokIdentifier } from '../../../../../../platform/agentHost/common/agentModelByokMeta.js'; import { type McpOAuthClient, type ModelSelection, type ProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -165,6 +166,11 @@ export class AgentHostAuthTokenCache { } } +type AuthenticationTokenResolution = + | { readonly kind: 'resolved'; readonly token: string } + | { readonly kind: 'signedOut' } + | { readonly kind: 'unavailable' }; + /** * Returns a stable identity for an authentication challenge. */ @@ -211,7 +217,7 @@ export class AgentHostAuthenticationRecovery { const commandService = accessor.get(ICommandService); const logService = accessor.get(ILogService); const scopes = resource.scopes_supported ?? []; - const token = await resolveTokenForResource( + const resolution = await resolveAuthenticationTokenForResource( URI.parse(resource.resource), resource.authorization_servers ?? [], scopes, @@ -220,15 +226,14 @@ export class AgentHostAuthenticationRecovery { options.logPrefix, ); throwIfAuthenticationStale(options); - if (!token) { - logService.info(`${options.logPrefix} No token resolved for resource: ${resource.resource}`); - options.authTokenCache?.clear(resource.resource, resource.scopes_supported); - if (await forwardAuthenticationToken(options, resource.resource, scopes, '')) { + if (resolution.kind !== 'resolved') { + logAuthenticationTokenResolution(logService, options.logPrefix, resource.resource, resolution); + if (resolution.kind === 'signedOut') { this._resentTokens.delete(key); - logService.info(`${options.logPrefix} Clearing authentication for resource: ${resource.resource}`); } return; } + const token = resolution.token; const previousToken = this._resentTokens.get(key); if (previousToken !== undefined && previousToken === token) { @@ -267,24 +272,74 @@ export async function resolveTokenForResource( logService: ILogService, logPrefix: string, ): Promise { + const resolution = await resolveAuthenticationTokenForResource( + resourceServer, + authorizationServers, + scopes, + authenticationService, + logService, + logPrefix, + ); + return resolution.kind === 'resolved' ? resolution.token : undefined; +} + +async function resolveAuthenticationTokenForResource( + resourceServer: URI, + authorizationServers: readonly string[], + scopes: readonly string[], + authenticationService: IAuthenticationService, + logService: ILogService, + logPrefix: string, +): Promise { + let hasUnavailableProvider = false; for (const server of authorizationServers) { const serverUri = URI.parse(server); - const providerId = await authenticationService.getOrActivateProviderIdForServer(serverUri, resourceServer); + let providerId: string | undefined; + try { + providerId = await authenticationService.getOrActivateProviderIdForServer(serverUri, resourceServer); + } catch (error) { + hasUnavailableProvider = true; + logService.trace(`${logPrefix} Authentication provider is not ready for server: ${server}`, error); + continue; + } if (!providerId) { - logService.trace(`${logPrefix} No auth provider found for server: ${server}`); + const declaredProvider = authenticationService.declaredProviders.find(provider => + !authenticationService.isAuthenticationProviderRegistered(provider.id) + && provider.authorizationServerGlobs?.some(glob => match(glob, serverUri.toString(true), { ignoreCase: true })) + ); + if (declaredProvider) { + hasUnavailableProvider = true; + logService.trace(`${logPrefix} Authentication provider '${declaredProvider.id}' is not ready for server: ${server}`); + } else { + logService.trace(`${logPrefix} No authentication provider resolved for server: ${server}`); + } continue; } logService.trace(`${logPrefix} Resolved auth provider '${providerId}' for server: ${server}`); - // Try exact scope match first - const sessions = await authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); + let sessions: readonly AuthenticationSession[]; + try { + // Try exact scope match first + sessions = await authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); + } catch (error) { + hasUnavailableProvider = true; + logService.trace(`${logPrefix} Authentication provider '${providerId}' is not ready to resolve sessions for server: ${server}`, error); + continue; + } const exactSession = sessions[0]; if (exactSession) { - return exactSession.accessToken; + return { kind: 'resolved', token: exactSession.accessToken }; } - // Fall back: get all sessions and find the narrowest superset of requested scopes - const allSessions = await authenticationService.getSessions(providerId, undefined, { authorizationServer: serverUri }, true); + let allSessions: readonly AuthenticationSession[]; + try { + // Fall back: get all sessions and find the narrowest superset of requested scopes + allSessions = await authenticationService.getSessions(providerId, undefined, { authorizationServer: serverUri }, true); + } catch (error) { + hasUnavailableProvider = true; + logService.trace(`${logPrefix} Authentication provider '${providerId}' is not ready to resolve sessions for server: ${server}`, error); + continue; + } const requestedSet = new Set(scopes); let bestToken: string | undefined; let bestExtraScopes = Infinity; @@ -306,10 +361,10 @@ export async function resolveTokenForResource( } } if (bestToken) { - return bestToken; + return { kind: 'resolved', token: bestToken }; } } - return undefined; + return hasUnavailableProvider ? { kind: 'unavailable' } : { kind: 'signedOut' }; } export interface IAgentHostAuthenticateRequest { @@ -386,6 +441,103 @@ export async function authenticateProtectedResources( } } +/** + * Reconciles resources backed by an authentication session that was explicitly + * removed. + * + * A removal event proves the provider is live and managing its sessions, which + * is what makes an empty resolution trustworthy here -- unlike the polling path, + * where "no token" cannot be distinguished from "not loaded yet". It does not, + * however, prove the credential is gone: a provider may host several accounts, + * and signing out of one leaves the others usable. Re-resolving keeps a partial + * sign-out from revoking a credential the host still needs, which would restart + * provider clients and move the Claude proxy port for no reason. + * + * Only resources the removed sessions could have satisfied are reconciled. One + * provider commonly serves several resources with different scope sets (GitHub + * serves both the Copilot and repository challenges), and a resource the removed + * session never covered must not be re-evaluated against this client's view of + * the world -- another client may be the one supplying its credential. + */ +export async function revokeAuthenticationForRemovedSessions( + accessor: ServicesAccessor, + agents: readonly AgentInfo[], + providerId: string, + removedSessions: readonly AuthenticationSession[], + options: IAgentHostAuthenticationOptions, +): Promise { + const authenticationService = accessor.get(IAuthenticationService); + const logService = accessor.get(ILogService); + const reconciledResources = new Set(); + for (const agent of agents) { + for (const resource of agent.protectedResources ?? []) { + const scopes = resource.scopes_supported ?? []; + const key = protectedResourceAuthenticationKey(resource); + if (reconciledResources.has(key)) { + continue; + } + if (!removedSessionsCouldSatisfyResource(removedSessions, scopes)) { + continue; + } + if (!await resourceMatchesAuthenticationProvider(authenticationService, resource, providerId, logService, options.logPrefix)) { + continue; + } + reconciledResources.add(key); + + const resolution = await resolveTokenForProtectedResource(authenticationService, logService, resource, options); + throwIfAuthenticationStale(options); + if (resolution.kind === 'unavailable') { + logAuthenticationTokenResolution(logService, options.logPrefix, resource.resource, resolution); + continue; + } + if (resolution.kind === 'resolved') { + // Another account still covers this resource; forward it so the host + // swaps credentials instead of losing them. Unchanged tokens are + // deduped by the cache. + if (await forwardAuthenticationToken(options, resource.resource, scopes, resolution.token)) { + logService.info(`${options.logPrefix} Authenticating for resource after session removal: ${resource.resource}`); + } + continue; + } + + options.authTokenCache?.clear(resource.resource, scopes); + if (await forwardAuthenticationToken(options, resource.resource, scopes, '')) { + logService.info(`${options.logPrefix} Clearing authentication for resource after session removal: ${resource.resource}`); + } + } + } +} + +/** + * Whether any removed session granted every scope a resource requires, and so + * could have been the credential backing it. + */ +function removedSessionsCouldSatisfyResource(removedSessions: readonly AuthenticationSession[], scopes: readonly string[]): boolean { + return removedSessions.some(session => { + const granted = new Set(session.scopes); + return scopes.every(scope => granted.has(scope)); + }); +} + +async function resourceMatchesAuthenticationProvider( + authenticationService: IAuthenticationService, + resource: ProtectedResourceMetadata, + providerId: string, + logService: ILogService, + logPrefix: string, +): Promise { + for (const authorizationServer of resource.authorization_servers ?? []) { + try { + if (await authenticationService.getOrActivateProviderIdForServer(URI.parse(authorizationServer), URI.parse(resource.resource)) === providerId) { + return true; + } + } catch (error) { + logService.trace(`${logPrefix} Unable to resolve authentication provider for session removal: ${authorizationServer}`, error); + } + } + return false; +} + /** * Resolves and forwards a bearer token for a single protected resource. */ @@ -404,17 +556,19 @@ async function authenticateProtectedResourceWithServices( options: IAgentHostAuthenticationOptions, ): Promise { throwIfAuthenticationStale(options); - const token = await resolveTokenForProtectedResource(authenticationService, logService, resource, options); + const resolution = await resolveTokenForProtectedResource(authenticationService, logService, resource, options); throwIfAuthenticationStale(options); + if (resolution.kind !== 'resolved') { + logAuthenticationTokenResolution(logService, options.logPrefix, resource.resource, resolution); + return false; + } - const authenticated = await forwardAuthenticationToken(options, resource.resource, resource.scopes_supported ?? [], token ?? ''); + const authenticated = await forwardAuthenticationToken(options, resource.resource, resource.scopes_supported ?? [], resolution.token); if (!authenticated) { logService.trace(`${options.logPrefix} Authentication state for ${resource.resource} unchanged; skipping authenticate RPC`); return false; } - logService.info(token - ? `${options.logPrefix} Authenticating for resource: ${resource.resource}` - : `${options.logPrefix} Clearing authentication for resource: ${resource.resource}`); + logService.info(`${options.logPrefix} Authenticating for resource: ${resource.resource}`); return true; } @@ -423,8 +577,8 @@ async function resolveTokenForProtectedResource( logService: ILogService, resource: ProtectedResourceMetadata, options: Pick, -): Promise { - const token = await resolveTokenForResource( +): Promise { + return resolveAuthenticationTokenForResource( URI.parse(resource.resource), resource.authorization_servers ?? [], resource.scopes_supported ?? [], @@ -432,10 +586,19 @@ async function resolveTokenForProtectedResource( logService, options.logPrefix, ); - if (!token) { - logService.info(`${options.logPrefix} No token resolved for resource: ${resource.resource}`); +} + +function logAuthenticationTokenResolution( + logService: ILogService, + logPrefix: string, + resource: string, + resolution: Exclude, +): void { + if (resolution.kind === 'unavailable') { + logService.info(`${logPrefix} Authentication provider is not ready for resource: ${resource}; deferring authentication`); + } else { + logService.info(`${logPrefix} No signed-in session resolved for resource: ${resource}`); } - return token; } /** diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 0a711c3b342d3d..346bf95d44e8d3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -25,7 +25,7 @@ import { ILogService } from '../../../../../../platform/log/common/log.js'; import { Registry } from '../../../../../../platform/registry/common/platform.js'; import { IWorkbenchContribution } from '../../../../../common/contributions.js'; import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; -import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; +import { AuthenticationSession, IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { ChatSessionsExtensions, IAsyncChatSessionActivationRegistry, IChatSessionsService, isLocalAgentHostTarget } from '../../../common/chatSessionsService.js'; import { ChatAgentLocation } from '../../../common/constants.js'; @@ -36,7 +36,7 @@ import { Target } from '../../../common/promptSyntax/promptTypes.js'; import { AgentCustomizationItemProvider } from './agentCustomizationItemProvider.js'; import { agentHostProviderHasBuiltInGitHubMcpServer, COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID } from './agentHostMcpServerSupport.js'; import { AgentHostDownloadProgress } from './agentHostDownloadProgress.js'; -import { authenticateProtectedResources, AgentHostAuthenticationRecovery, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from './agentHostAuth.js'; +import { authenticateProtectedResources, AgentHostAuthenticationRecovery, AgentHostAuthTokenCache, resolveAuthenticationInteractively, revokeAuthenticationForRemovedSessions } from './agentHostAuth.js'; import { AgentHostLanguageModelProvider, agentHostProviderSupportsAutoModel } from './agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from './agentHostSessionHandler.js'; import { AgentHostPromptCacheNotification } from './agentHostPromptCacheNotification.js'; @@ -211,6 +211,15 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr } this._authenticateNotificationResource(notification.resource); })); + store.add(this._defaultAccountService.onDidChangeDefaultAccount(() => { + this._authenticateWithServer(this._getRootAgents()).catch(() => { /* best-effort */ }); + })); + store.add(this._authenticationService.onDidRegisterAuthenticationProvider(() => { + this._authenticateWithServer(this._getRootAgents()).catch(() => { /* best-effort */ }); + })); + store.add(this._authenticationService.onDidChangeSessions(event => { + void this._handleAuthenticationSessionsChanged(event.providerId, event.event.removed ?? []); + })); // Surface the agent host's lazy, first-use SDK download as a progress // notification. The Agents window renders this via its own sessions @@ -357,21 +366,32 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr const vendorDescriptor = { vendor, displayName: agent.displayName, configuration: undefined, managementCommand: undefined, when: undefined }; this._languageModelsService.deltaLanguageModelChatProviderDescriptors([vendorDescriptor], []); store.add(toDisposable(() => this._languageModelsService.deltaLanguageModelChatProviderDescriptors([], [vendorDescriptor]))); - const modelProvider = store.add(new AgentHostLanguageModelProvider(sessionType, vendor)); + const modelProvider = store.add(new AgentHostLanguageModelProvider(sessionType, vendor, this._languageModelsService)); this._modelProviders.set(agent.provider, modelProvider); store.add(toDisposable(() => this._modelProviders.delete(agent.provider))); store.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider)); modelProvider.updateModels(agent.models); - // Re-authenticate when credentials change - store.add(this._defaultAccountService.onDidChangeDefaultAccount(() => { - const agents = this._getRootAgents(); - this._authenticateWithServer(agents).catch(() => { /* best-effort */ }); - })); - store.add(this._authenticationService.onDidChangeSessions(() => { - const agents = this._getRootAgents(); - this._authenticateWithServer(agents).catch(() => { /* best-effort */ }); - })); + } + + private async _handleAuthenticationSessionsChanged(providerId: string, removedSessions: readonly AuthenticationSession[]): Promise { + const agents = this._getRootAgents(); + if (removedSessions.length > 0) { + const generation = this._authenticationGeneration; + try { + await this._instantiationService.invokeFunction(revokeAuthenticationForRemovedSessions, agents, providerId, removedSessions, { + authTokenCache: this._authTokenCache, + logPrefix: '[AgentHost]', + isCurrent: () => this._isAuthenticationCurrent(generation), + authenticate: request => this._authenticateIfCurrent(request, generation), + }); + } catch (error) { + if (!isCancellationError(error)) { + this._logService.error('[AgentHost] Failed to revoke removed authentication session', error); + } + } + } + await this._authenticateWithServer(agents); } private _getRootAgents(): readonly AgentInfo[] { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index bae6b6cc485f05..455c2e36dbdde9 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -7,14 +7,30 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { formatTokenCount } from '../../../../../../base/common/numbers.js'; import { localize } from '../../../../../../nls.js'; import { readAgentModelNoticesMeta } from '../../../../../../platform/agentHost/common/agentModelNotices.js'; import { ConfigSchema, SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { readAgentModelPricingMeta } from '../../../../../../platform/agentHost/common/agentModelPricing.js'; import { readAgentModelByokIdentifier } from '../../../../../../platform/agentHost/common/agentModelByokMeta.js'; import { readAgentModelGroupId, readAgentModelSourceId } from '../../../../../../platform/agentHost/common/agentModelSource.js'; +import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../../../../../platform/agentHost/common/reasoningEffort.js'; import { nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; -import { AUTO_RAW_MODEL_ID, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelConfigurationSchema } from '../../../common/languageModels.js'; +import { AUTO_RAW_MODEL_ID, COPILOT_VENDOR_ID, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelConfigurationSchema, ILanguageModelsService } from '../../../common/languageModels.js'; + +/** + * Config key naming the context-window tier a host accepts on a model selection. Its values are + * tier names (`default` / `long_context`), because a host driving the Copilot SDK has no per-model + * token counts to offer. + */ +const CONTEXT_TIER_CONFIG_KEY = 'contextTier'; + +/** + * Config key naming the numeric context-window picker the workbench's own Copilot catalogue + * synthesizes from CAPI billing. Read here only as the source of the token counts used to label + * {@link CONTEXT_TIER_CONFIG_KEY}; it is never surfaced to a host, which would not understand it. + */ +const CONTEXT_SIZE_CONFIG_KEY = 'contextSize'; /** * Returns whether an agent host provider exposes a synthetic "Auto" model to @@ -36,6 +52,15 @@ export function agentHostProviderSupportsAutoModel(provider: string): boolean { return provider === 'copilotcli'; } +/** + * Read-only view of the workbench model catalogue an agent host's models are enriched from. + * + * Narrowed to the reads {@link AgentHostLanguageModelProvider} performs (all plain lookups into + * already-registered models, so none of them re-enter a provider) plus the change signal, both so + * the dependency stays obviously side-effect free and so tests can supply a small fake. + */ +export type IAgentHostModelCatalogue = Pick; + /** * Exposes models available from the agent host process as selectable * language models in the chat model picker. Models are provided from @@ -50,8 +75,24 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu constructor( private readonly _sessionType: string, private readonly _vendor: string, + private readonly _catalogue?: IAgentHostModelCatalogue, ) { super(); + + // The catalogue is populated independently of the host — a sandbox can advertise its models + // before the Copilot vendor has resolved, and CAPI can refresh prices later — so re-publish + // whenever it changes to pick up the enrichment. + // + // Scoped to the enriched-from vendor, which also keeps this from looping: the service fires + // this event for every provider that publishes, including this one, but a host's vendor is + // always a session-type id (`agent-host-…`) and never `copilot`. + if (this._catalogue) { + this._register(this._catalogue.onDidChangeLanguageModels(vendor => { + if (vendor === COPILOT_VENDOR_ID) { + this._onDidChange.fire(); + } + })); + } } /** @@ -83,6 +124,10 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu : undefined); const modelGroup = this._modelGroupFor(m); const byokModelIdentifier = readAgentModelByokIdentifier(m); + // A host that derives its list from the Copilot SDK advertises no billing and no + // token counts, so fall back to the workbench's own catalogue entry for the same + // model. See `_catalogueEntryFor`. + const known = this._catalogueEntryFor(m, modelGroup); return { identifier: `${this._vendor}:${m.id}`, metadata: { @@ -94,26 +139,26 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu family: m.id, ...(tooltip !== undefined && { tooltip }), ...(detail !== undefined && { detail }), - maxInputTokens: m.maxPromptTokens ?? 0, - maxOutputTokens: m.maxOutputTokens ?? 0, + maxInputTokens: m.maxPromptTokens ?? known?.maxInputTokens ?? 0, + maxOutputTokens: m.maxOutputTokens ?? known?.maxOutputTokens ?? 0, isDefaultForLocation: {}, isUserSelectable: true, statusIcon: notices?.rowWarning ? Codicon.warning : undefined, warningText: notices?.warningText, infoText: notices?.infoText, - pricing: multiplierNumeric !== undefined ? `${multiplierNumeric}x` : undefined, - multiplierNumeric, - inputCost: pricing.inputCost, - cacheCost: pricing.cacheCost, - cacheWriteCost: pricing.cacheWriteCost, - outputCost: pricing.outputCost, - longContextInputCost: pricing.longContextInputCost, - longContextCacheCost: pricing.longContextCacheCost, - longContextCacheWriteCost: pricing.longContextCacheWriteCost, - longContextOutputCost: pricing.longContextOutputCost, - priceCategory: pricing.priceCategory, - category: pricing.category, - promo: pricing.promo, + pricing: multiplierNumeric !== undefined ? `${multiplierNumeric}x` : known?.pricing, + multiplierNumeric: multiplierNumeric ?? known?.multiplierNumeric, + inputCost: pricing.inputCost ?? known?.inputCost, + cacheCost: pricing.cacheCost ?? known?.cacheCost, + cacheWriteCost: pricing.cacheWriteCost ?? known?.cacheWriteCost, + outputCost: pricing.outputCost ?? known?.outputCost, + longContextInputCost: pricing.longContextInputCost ?? known?.longContextInputCost, + longContextCacheCost: pricing.longContextCacheCost ?? known?.longContextCacheCost, + longContextCacheWriteCost: pricing.longContextCacheWriteCost ?? known?.longContextCacheWriteCost, + longContextOutputCost: pricing.longContextOutputCost ?? known?.longContextOutputCost, + priceCategory: pricing.priceCategory ?? known?.priceCategory, + category: pricing.category ?? known?.category, + promo: pricing.promo ?? known?.promo, targetChatSessionType: this._sessionType, // Group agent-host models in the picker by their upstream provider // (Copilot CLI, OpenAI, a 3p BYOK provider, …). All of a host's @@ -122,35 +167,156 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu ...(modelGroup ? { modelGroup } : {}), ...(byokModelIdentifier !== undefined && { byokModelIdentifier }), capabilities: { - vision: m.supportsVision ?? false, + vision: m.supportsVision ?? known?.capabilities?.vision ?? false, toolCalling: true, agentMode: true, }, - configurationSchema: this._toLanguageModelConfigurationSchema(m.configSchema), + configurationSchema: this._toLanguageModelConfigurationSchema(m.configSchema, known), }, }; }); } - private _toLanguageModelConfigurationSchema(schema: ConfigSchema | undefined): ILanguageModelConfigurationSchema | undefined { + /** + * The workbench catalogue entry describing the same model, when one is known. + * + * A host that derives its model list from the Copilot SDK advertises only what the SDK gave it: + * no billing, and no per-tier context windows. The workbench already holds that detail for the + * same models — the Copilot vendor's catalogue is CAPI-backed — so the two are matched by model + * id and the host's list is enriched from it, which is how the GitHub desktop app renders real + * token counts for a sandbox session. + * + * Restricted to models billed through Copilot (a `copilot` picker group), so a model reached + * over a direct third-party transport is never labelled with Copilot's prices. + */ + private _catalogueEntryFor(model: SessionModelInfo, group: ILanguageModelChatMetadata['modelGroup']): ILanguageModelChatMetadata | undefined { + if (!this._catalogue || group?.id !== COPILOT_VENDOR_ID) { + return undefined; + } + for (const identifier of this._catalogue.getLanguageModelIds()) { + const metadata = this._catalogue.lookupLanguageModel(identifier); + if (metadata?.vendor === COPILOT_VENDOR_ID && metadata.id === model.id) { + return metadata; + } + } + return undefined; + } + + /** + * The distinct context-window sizes a catalogue entry offers, ascending, or `undefined` when it + * offers no real choice. Sourced from the numeric `contextSize` picker the Copilot catalogue + * synthesizes from CAPI billing, which is the only place these token counts exist. + */ + private static _contextWindowTiers(metadata: ILanguageModelChatMetadata | undefined): number[] | undefined { + const values = metadata?.configurationSchema?.properties?.[CONTEXT_SIZE_CONFIG_KEY]?.enum; + if (!values?.length) { + return undefined; + } + const sizes = [...new Set(values.filter((value): value is number => typeof value === 'number'))].sort((a, b) => a - b); + return sizes.length > 1 ? sizes : undefined; + } + + /** + * Labels for a host's `contextTier` enum, as token counts rather than tier names. + * + * The host names the tiers (`default` / `long_context`) because the SDK exposes no per-model + * windows, but the picker is far more useful showing "264K" / "1M" — what the GitHub desktop + * app displays for the same session. The wire value stays the tier name the host accepts; only + * the label changes. + * + * Returns `undefined` when the catalogue offers no distinct long-context tier (or does not know + * the model), which drops the property and hides the picker rather than offering a choice that + * has no effect — matching how the desktop app suppresses it. + */ + private static _contextTierLabels(values: readonly unknown[] | undefined, known: ILanguageModelChatMetadata | undefined): string[] | undefined { + const tiers = AgentHostLanguageModelProvider._contextWindowTiers(known); + if (!tiers || !values?.length) { + return undefined; + } + // The host orders its tiers from smallest window to largest, so they align with the sorted + // sizes by position. A tier list of a different length is not one this mapping understands. + if (values.length !== tiers.length || !values.every(value => typeof value === 'string')) { + return undefined; + } + return tiers.map(formatTokenCount); + } + + private _toLanguageModelConfigurationSchema(schema: ConfigSchema | undefined, known?: ILanguageModelChatMetadata): ILanguageModelConfigurationSchema | undefined { if (!schema) { return undefined; } - return { - type: schema.type, - required: schema.required, - properties: Object.fromEntries(Object.entries(schema.properties).map(([key, property]) => [key, { + const properties: ILanguageModelConfigurationSchema['properties'] = {}; + for (const [key, property] of Object.entries(schema.properties)) { + // Only when the producer supplied no display text at all. Filling in half of it + // would mix sources and override a producer that deliberately labels its values + // without describing them. + const effortDisplay = property.enumLabels === undefined && property.enumDescriptions === undefined + ? AgentHostLanguageModelProvider._reasoningEffortDisplay(key, property.enum) + : undefined; + + let enumItemLabels = property.enumLabels ?? effortDisplay?.labels; + if (key === CONTEXT_TIER_CONFIG_KEY) { + const tierLabels = AgentHostLanguageModelProvider._contextTierLabels(property.enum, known); + if (!tierLabels) { + // No real choice to offer (or no catalogue entry to size it with): drop the + // property so the picker hides rather than showing tier names that read as a + // setting the user cannot evaluate. + continue; + } + enumItemLabels = tierLabels; + } + + properties[key] = { type: property.type, title: property.title, description: property.description, default: property.default, enum: property.enum, - enumItemLabels: property.enumLabels, - enumDescriptions: property.enumDescriptions, + enumItemLabels, + enumDescriptions: property.enumDescriptions ?? effortDisplay?.descriptions, readOnly: property.readOnly, group: AgentHostLanguageModelProvider._groupForConfigKey(key), - }])), + }; + } + + return { + type: schema.type, + required: schema.required, + properties, + }; + } + + /** Config keys whose enum values are reasoning-effort levels, whatever the producer named them. */ + private static readonly _reasoningEffortKeys: ReadonlySet = new Set(['reasoningEffort', 'thinkingLevel']); + + /** + * Localized labels and descriptions for a reasoning-effort enum whose producer supplied none. + * + * A host that derives its schema from an upstream SDK advertises the accepted effort values + * without display text, because it has none the SDK did not give it — the Copilot agent host + * inside a cloud sandbox does exactly that. Deriving the text here keeps the picker from + * rendering raw values like `xhigh`, and matches what the agents that build their schema + * locally already emit. + * + * Only synthesized when every enum value is a string, since labels align with `enum` by index + * and a partial list would mislabel the rest. + * + * No `default` is synthesized: schema defaults are merged into the configuration that is sent + * (see `resolveModelConfiguration`), so inventing one would send an explicit effort where the + * host expects the value omitted and the backend to choose. + */ + private static _reasoningEffortDisplay(key: string, values: readonly unknown[] | undefined): { readonly labels: string[]; readonly descriptions: string[] } | undefined { + if (!AgentHostLanguageModelProvider._reasoningEffortKeys.has(key) || !values?.length) { + return undefined; + } + const levels = values.filter((value): value is string => typeof value === 'string'); + if (levels.length !== values.length) { + return undefined; + } + return { + labels: levels.map(getReasoningEffortLabel), + descriptions: levels.map(level => getReasoningEffortDescription(level) ?? ''), }; } @@ -159,8 +325,14 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu // The Auto model has no thinking level, so its routing-profile picker takes that slot, // matching how the Copilot Chat extension groups it. case 'tier': - case 'thinkingLevel': return 'navigation'; - case 'contextSize': return 'tokens'; + case 'thinkingLevel': + // `reasoningEffort` / `contextTier` are what the Copilot agent host inside a cloud + // sandbox names the same two knobs. Without them the picker finds no property in + // either group and hides itself entirely, so a sandbox session offers no way to + // choose a thinking level or a context window. + case 'reasoningEffort': return 'navigation'; + case 'contextSize': + case CONTEXT_TIER_CONFIG_KEY: return 'tokens'; default: return undefined; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 048f3e33f9c69a..df2894c678be95 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -9,6 +9,7 @@ import { escapeMarkdownLinkLabel, IMarkdownString, MarkdownString } from '../../ import { escapeIcons } from '../../../../../../base/common/iconLabels.js'; import { type Tokens } from '../../../../../../base/common/marked/marked.js'; import { rewriteMarkdownLinks as rewriteMarkdownSource } from '../../../../../../base/common/markdownLinks.js'; +import { Mimes } from '../../../../../../base/common/mime.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { posix, win32 } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -1494,14 +1495,15 @@ function getTerminalLanguage(tc: ToolCallState) { * * 1. `existingKind === 'terminal'` — preserve the prior render decision so a * tool already set up as terminal stays terminal across snapshots. - * 2. `getToolKind(tc) === 'terminal'` with a command available — the - * always-available `_meta.toolKind` flag set by the event mapper for - * built-in `bash`/`powershell` SDK tools that never emit a - * {@link ToolResultContentType.Terminal} content block. We only render the - * terminal pill once we actually have the command (`getTerminalInput`): - * rendering a terminal pill with an empty command line looks broken, so - * until the command arrives we fall back to the generic tool widget - * (the `invocationMessage`). + * 2. `getToolKind(tc) === 'terminal'` with a command available — either the + * `_meta.toolKind` flag set by the event mapper for built-in + * `bash`/`powershell` SDK tools that never emit a + * {@link ToolResultContentType.Terminal} content block, or a command + * permission request from a remote host that does not set that flag. We + * only render the terminal pill once we actually have the command + * (`getTerminalInput`): rendering a terminal pill with an empty command + * line looks broken, so until the command arrives we fall back to the + * generic tool widget (the `invocationMessage`). * 3. A `Terminal` content block in `tc.content` (Running/Completed only) — * the AHP-side signal for the custom terminal tool (`agenthost-terminal:` * URIs). @@ -1582,7 +1584,12 @@ function getToolInputOutputDetails(tc: ToolCallState, isError: boolean, errorStr for (const block of tc.content ?? []) { switch (block.type) { case ToolResultContentType.Text: - output.push({ type: 'embed', value: block.text, isText: true, mimeType: 'text/plain' }); + output.push({ + type: 'embed', + value: block.text, + isText: true, + mimeType: block.text.trimStart().startsWith('{') ? 'application/json' : Mimes.text, + }); break; case ToolResultContentType.EmbeddedResource: output.push({ type: 'embed', value: block.data, mimeType: block.contentType }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts index 8090534c7115e6..c4fde0679fb094 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/syncedCustomizationBundler.ts @@ -3,14 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Limiter } from '../../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { equals } from '../../../../../../base/common/objects.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; -import { basename, dirname } from '../../../../../../base/common/resources.js'; +import { basename, dirname, extUri } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { hash } from '../../../../../../base/common/hash.js'; -import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { IFileService, IFileStatWithPartialMetadata } from '../../../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IMcpServerConfiguration } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { AICustomizationSource } from '../../../common/aiCustomizationWorkspaceService.js'; @@ -19,11 +21,14 @@ import { withCustomizationEnablement } from '../../../../../../platform/agentHos import { customizationId, type ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { IAgentHostFileSystemService, SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; +import { IgnoreFile } from '../../../../../../workbench/services/search/common/ignoreFile.js'; // Re-export so existing consumers don't need to change their import source. export { SYNCED_CUSTOMIZATION_SCHEME }; const DISPLAY_NAME = 'VS Code Synced Data'; +const FILE_OPERATION_CONCURRENCY = 10; +const SKILL_DIRECTORY_IGNORE = new IgnoreFile('.git\nnode_modules\n', '/', undefined, true); const MANIFEST_CONTENT = JSON.stringify({ name: DISPLAY_NAME, @@ -48,6 +53,34 @@ function pluginDirForType(type: PromptsType): string | undefined { } } +type QueueFileOperation = (operation: () => Promise) => Promise; + +async function collectDirectoryFiles(fileService: IFileService, logService: ILogService, root: URI, directory: URI, queueFileOperation: QueueFileOperation): Promise { + const stat = await queueFileOperation(() => fileService.resolve(directory)); + const children = (await Promise.all((stat.children ?? []).map(async child => { + try { + return await queueFileOperation(() => fileService.stat(child.resource)); + } catch (error) { + logService.trace('[SyncedCustomizationBundler] Failed to stat skill resource', child.resource.toString(), error); + return undefined; + } + }))).filter((child): child is IFileStatWithPartialMetadata => child !== undefined); + const files = await Promise.all(children.map(async child => { + const relativePath = extUri.relativePath(root, child.resource); + if (relativePath === undefined) { + throw new Error(`Unable to resolve skill resource path: ${child.resource.toString()}`); + } + if (child.isSymbolicLink || !SKILL_DIRECTORY_IGNORE.isPathIncludedInTraversal(`/${relativePath}`, child.isDirectory)) { + return []; + } + if (child.isDirectory) { + return collectDirectoryFiles(fileService, logService, root, child.resource, queueFileOperation); + } + return child.isFile ? [child] : []; + })); + return files.flat(); +} + export interface ISyncableFile { readonly uri: URI; readonly type: PromptsType; @@ -113,14 +146,15 @@ interface IBundleResult { * rules/ ← instruction files * commands/ ← prompt files * agents/ ← agent files - * skills/ ← skill files + * skills/ ← skill directories * ``` * - * The bundler computes a content-based nonce so the agent host can + * The bundler computes a metadata-based nonce so the agent host can * skip re-loading when nothing has changed. */ export class SyncedCustomizationBundler extends Disposable { + private readonly _fileOperationLimiter = this._register(new Limiter(FILE_OPERATION_CONCURRENCY)); private readonly _authority: string; private _lastNonce: string | undefined; private _lastRef: IBundleResult | undefined; @@ -131,6 +165,7 @@ export class SyncedCustomizationBundler extends Disposable { authority: string, @IFileService private readonly _fileService: IFileService, @IAgentHostFileSystemService agentHostFileSystemService: IAgentHostFileSystemService, + @ILogService private readonly _logService: ILogService, ) { super(); this._authority = authority; @@ -146,12 +181,16 @@ export class SyncedCustomizationBundler extends Disposable { return URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: `/${this._authority}` }); } + private _queueFileOperation(operation: () => Promise): Promise { + return this._fileOperationLimiter.queue(operation) as Promise; + } + /** * Bundles the given files and MCP servers into the in-memory plugin * filesystem. * * Overwrites any previous bundle content. Returns a {@link ClientPluginCustomization} - * pointing at the virtual plugin directory with a content-based nonce. + * pointing at the virtual plugin directory with a metadata-based nonce. * * @returns The bundle result, or `undefined` if there is nothing to sync. */ @@ -161,13 +200,19 @@ export class SyncedCustomizationBundler extends Disposable { return undefined; } - // Read every source file up front so the content nonce can be computed - // before touching the in-memory tree. This lets us skip the destructive - // delete + rewrite entirely when nothing has changed since the last - // bundle (a frequent case when a change event fires but content is - // identical). - const entries: { destUri: URI; content: VSBuffer; hashPart: string }[] = []; + const entries: { sourceUri: URI; destUri: URI; hashPart: string }[] = []; const originByDest = new ResourceMap(); + const addEntry = (file: ISyncableFile, source: IFileStatWithPartialMetadata, destUri: URI, hashKey: string): void => { + entries.push({ sourceUri: source.resource, destUri, hashPart: `${hashKey}:${source.mtime}:${source.size}` }); + if (file.source !== undefined) { + originByDest.set(destUri, { + uri: source.resource, + source: file.source, + extensionId: file.extensionId, + pluginUri: file.pluginUri, + }); + } + }; await Promise.all(syncable.map(async file => { const dir = pluginDirForType(file.type)!; const fileName = basename(file.uri); @@ -176,38 +221,32 @@ export class SyncedCustomizationBundler extends Disposable { // The file locator returns the SKILL.md URI, so basename is // always "SKILL.md" — which would cause every skill to collide. // Preserve the directory structure: skills/{skillName}/SKILL.md. - let destUri: URI; - let hashKey: string; if (file.type === PromptsType.skill && fileName.toLowerCase() === 'skill.md') { - const skillDirName = basename(dirname(file.uri)); - destUri = URI.joinPath(this._rootUri, dir, skillDirName, fileName); - hashKey = `${dir}/${skillDirName}/${fileName}`; + const skillRoot = dirname(file.uri); + const skillDirName = basename(skillRoot); + const entrypoint = await this._queueFileOperation(() => this._fileService.stat(file.uri)); + addEntry(file, entrypoint, URI.joinPath(this._rootUri, dir, skillDirName, fileName), `${dir}/${skillDirName}/${fileName}`); + for (const source of await collectDirectoryFiles(this._fileService, this._logService, skillRoot, skillRoot, operation => this._queueFileOperation(operation))) { + if (extUri.isEqual(source.resource, file.uri)) { + continue; + } + const relativePath = extUri.relativePath(skillRoot, source.resource); + if (relativePath === undefined) { + throw new Error(`Unable to resolve skill resource path: ${source.resource.toString()}`); + } + addEntry( + file, + source, + URI.joinPath(this._rootUri, dir, skillDirName, relativePath), + `${dir}/${skillDirName}/${relativePath}`, + ); + } } else { - destUri = URI.joinPath(this._rootUri, dir, fileName); - hashKey = `${dir}/${fileName}`; - } - - // Record the reverse mapping so the flattened file's original - // provenance (extension/plugin/built-in) can be recovered later. - // Only files that carry a source have recoverable provenance. - if (file.source !== undefined) { - originByDest.set(destUri, { - uri: file.uri, - source: file.source, - extensionId: file.extensionId, - pluginUri: file.pluginUri, - }); + const source = await this._queueFileOperation(() => this._fileService.stat(file.uri)); + addEntry(file, source, URI.joinPath(this._rootUri, dir, fileName), `${dir}/${fileName}`); } - - const content = await this._fileService.readFile(file.uri); - entries.push({ destUri, content: content.value, hashPart: `${hashKey}:${content.value.toString()}` }); })); - // Publish the freshly computed provenance map. This is done before the - // nonce short-circuit below so the map always reflects the latest set of - // bundled files, even when the content nonce is unchanged. - this._originByDest = originByDest; - // Write MCP servers into `.mcp.json`. The agent host's Open Plugin // adapter reads this file relative to the plugin root. Servers are // sorted by name so the serialized content (and nonce) is stable. @@ -241,8 +280,9 @@ export class SyncedCustomizationBundler extends Disposable { const nonce = String(hash(hashParts.join('\n'))); // Nothing changed since the last successful bundle — reuse it and skip - // the delete + rewrite of the in-memory plugin tree. + // reading file contents and rewriting the in-memory plugin tree. if (nonce === this._lastNonce && this._lastRef) { + this._originByDest = originByDest; if (mcpServers.length > 0 && !equals(childEnablement, this._lastRef.ref.childEnablement)) { return { ref: { @@ -254,6 +294,12 @@ export class SyncedCustomizationBundler extends Disposable { return this._lastRef; } + const fileContents = await Promise.all(entries.map(async entry => ({ + destUri: entry.destUri, + content: (await this._queueFileOperation(() => this._fileService.readFile(entry.sourceUri))).value, + }))); + this._originByDest = originByDest; + // Delete the previous tree for this authority, preserving other authorities try { await this._fileService.del(this._rootUri, { recursive: true }); @@ -266,7 +312,7 @@ export class SyncedCustomizationBundler extends Disposable { await this._fileService.writeFile(manifestUri, VSBuffer.fromString(MANIFEST_CONTENT)); // Write each source file into the correct plugin directory. - for (const entry of entries) { + for (const entry of fileContents) { await this._fileService.writeFile(entry.destUri, entry.content); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts index c1191eb5f8282c..881bea421bb69a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsOpener.ts @@ -26,6 +26,7 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; import { migratedCopilotCliResource } from '../copilotCliEventsUri.js'; import { adoptLegacyCopilotCliResource, LEGACY_MIGRATION_OPEN_TIMEOUT_MS, reportLegacyMigrationOpen } from './agentHost/agentHostLegacyMigration.js'; +import { SESSION_META_EHCLI_ADOPTABLE_KEY } from '../../../../../platform/agentHost/common/state/sessionState.js'; //#region Session Opener Registry @@ -90,7 +91,8 @@ async function resolveMigratedSession(agentSessionsService: IAgentSessionsServic async function resolveMigratedSessionForOpen(accessor: ServicesAccessor, resource: URI): Promise { // Only a superseded legacy resource can redirect; skip the progress wrapper for every // normal open so they stay overhead-free. - if (!migratedCopilotCliResource(resource)) { + const twin = migratedCopilotCliResource(resource); + if (!twin) { return undefined; } @@ -100,12 +102,35 @@ async function resolveMigratedSessionForOpen(accessor: ServicesAccessor, resourc const configurationService = accessor.get(IConfigurationService); const connection = accessor.get(IAgentHostConnectionsService).ambientConnection; + // External sessions (origin "other") are never migrated — the host declines + // to adopt them. But discovery still surfaces them under their agent-host + // twin, so once surfaced we open that twin directly and skip the adopt probe + // entirely (it would waste a round-trip only to be declined, and the EH + // resource can no longer be resolved once the extension provider is retired). + // A still-adoptable session carries the marker and must keep migrating. + // External sessions (origin "other") are never migrated — the host declines + // to adopt them. But discovery still surfaces them under their agent-host + // twin, so once surfaced we open that twin directly and skip the adopt probe + // entirely (it would waste a round-trip only to be declined, and the EH + // resource can no longer be resolved once the extension provider is retired). + // A still-adoptable session carries the marker and must keep migrating. + const surfacedTwin = agentSessionsService.getSession(twin); + if (surfacedTwin && !surfacedTwin.metadata?.[SESSION_META_EHCLI_ADOPTABLE_KEY]) { + return surfacedTwin; + } + return accessor.get(IProgressService).withProgress( { location: ProgressLocation.Window, title: localize('chat.openingSession', "Opening chat…") }, async () => { const migrated = await adoptLegacyCopilotCliResource(connection, resource, logService, configurationService, telemetryService, 'open', LEGACY_MIGRATION_OPEN_TIMEOUT_MS); if (!migrated) { - return undefined; + // Not adopted. This also covers timeout/failure/no-connection, so + // re-check the marker on the refreshed result: only an external + // (or already-adopted) twin opens as-is; an adoptable session that + // failed to adopt must keep migrating, so fall through to the + // original resource by returning `undefined`. + const fallback = await resolveMigratedSession(agentSessionsService, twin); + return fallback && !fallback.metadata?.[SESSION_META_EHCLI_ADOPTABLE_KEY] ? fallback : undefined; } const surfaced = await resolveMigratedSession(agentSessionsService, migrated); reportLegacyMigrationOpen(telemetryService, 'open', !!surfaced); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts index 3ff88b2360e218..1ff63fb5916a8a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts @@ -165,9 +165,9 @@ export class CustomizationLocationPicker { return undefined; } - // if (matchingFolders.length === 1) { - // return matchingFolders[0].uri; - // } + if (matchingFolders.length === 1) { + return matchingFolders[0].uri; + } // Multiple directories — ask the user which one to use const items: (IQuickPickItem & { uri: URI })[] = matchingFolders.map(folder => ({ 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 aed935b6aff2cd..29b782c8b3639c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -1690,7 +1690,7 @@ configurationRegistry.registerConfiguration({ }, [AgentHostCopilotModelCapabilityOverridesSettingId]: { type: 'object', - markdownDescription: nls.localize('chat.agentHost.copilot.modelCapabilityOverrides', "Per-model capability overrides for Copilot SDK agent sessions, keyed by model id (`*` matches every model; a specific entry wins field-by-field), intended for evaluating models against an existing model's profile. Declare an aliased `family` (for example `claude-opus-4.8`) to route the model to that family's tuned system prompt and tool profile without changing the model id sent to the runtime — so a preview model can be evaluated against a known prompt while still running on its own endpoint — a `reasoningEffort` to pin its effort level, `availableTools`/`excludedTools` to filter its tool set, or `modelCapabilities` to override individual capability limits (e.g. vision support, context window size) passed through to the SDK. All overrides apply when a session launches or resumes. On a mid-session model change, only the new model's `reasoningEffort` is applied; the session keeps its launch-time family, tool filters, and model capabilities. Only affects Copilot agent sessions.\n\n**Note**: This is an advanced setting for experimentation."), + markdownDescription: nls.localize('chat.agentHost.copilot.modelCapabilityOverrides', "Per-model overrides for Copilot Agent Host sessions. Use `*` to match every model. Supports model family, reasoning effort, tool filters, model capabilities, and YAML prompt overrides."), additionalProperties: { type: 'object', properties: { @@ -1718,6 +1718,14 @@ configurationRegistry.registerConfiguration({ additionalProperties: true, description: nls.localize('chat.agentHost.copilot.modelCapabilityOverrides.modelCapabilities', "Per-property model capability overrides passed through to the Copilot SDK's `modelCapabilities` session field (e.g. `{ \"supports\": { \"vision\": false }, \"limits\": { \"max_context_window_tokens\": 64000 } }`), deep-merged over the runtime's resolved defaults for this model. Applied when the session launches or resumes."), }, + promptOverrideString: { + type: 'string', + description: nls.localize('chat.agentHost.copilot.modelCapabilityOverrides.promptOverrideString', "Inline YAML containing `systemPrompt` and/or `toolDescriptions` overrides. Takes precedence over `promptOverrideFile`. A system prompt replaces all Agent Host and SDK prompt sections and guardrails."), + }, + promptOverrideFile: { + type: 'string', + description: nls.localize('chat.agentHost.copilot.modelCapabilityOverrides.promptOverrideFile', "Path to a YAML file containing `systemPrompt` and/or `toolDescriptions` overrides. Ignored when `promptOverrideString` is configured."), + }, }, }, default: {}, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts index 65ebd0be1c60ec..14fe552725930c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts @@ -53,7 +53,7 @@ export class ChatToolOutputContentSubPart extends Disposable { const codeParts = [part]; while (i + 1 < this.parts.length) { const nextPart = this.parts[i + 1]; - if (nextPart.kind !== 'code' || nextPart.title) { + if (nextPart.kind !== 'code' || nextPart.title || nextPart.languageId !== part.languageId) { break; } codeParts.push(nextPart); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts index da1ae75b70ea41..c0c1eb7b1a40c5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts @@ -7,15 +7,16 @@ import { ProgressBar } from '../../../../../../../base/browser/ui/progressbar/pr import { IMarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { Lazy } from '../../../../../../../base/common/lazy.js'; import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; -import { getExtensionForMimeType } from '../../../../../../../base/common/mime.js'; +import { getExtensionForMimeType, Mimes, normalizeMimeType } from '../../../../../../../base/common/mime.js'; import { autorun } from '../../../../../../../base/common/observable.js'; import { basename } from '../../../../../../../base/common/resources.js'; import { ILanguageService } from '../../../../../../../editor/common/languages/language.js'; +import { PLAINTEXT_LANGUAGE_ID } from '../../../../../../../editor/common/languages/modesRegistry.js'; import { IModelService } from '../../../../../../../editor/common/services/model.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { ChatResponseResource } from '../../../../common/model/chatModel.js'; import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; -import { IToolResultInputOutputDetails } from '../../../../common/tools/languageModelToolsService.js'; +import { IToolResultInputOutputDetails, ToolInputOutputEmbedded } from '../../../../common/tools/languageModelToolsService.js'; import { IChatCodeBlockInfo } from '../../../chat.js'; import { IChatContentPartRenderContext } from '../chatContentParts.js'; import { ChatCollapsibleInputOutputContentPart, ChatCollapsibleIOPart, IChatCollapsibleIOCodePart } from '../chatToolInputOutputContentPart.js'; @@ -69,6 +70,27 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS } }); + const getOutputLanguageId = (part: ToolInputOutputEmbedded): string => { + if (part.mimeType) { + const mimeType = normalizeMimeType(part.mimeType).split(';', 1)[0].trim(); + if (mimeType === Mimes.markdown) { + return 'markdown'; + } + if (mimeType === Mimes.text) { + return PLAINTEXT_LANGUAGE_ID; + } + if (mimeType === 'application/json' || mimeType.endsWith('+json')) { + return 'json'; + } + const languageId = languageService.getLanguageIdByMimeType(mimeType); + if (languageId) { + return languageId; + } + } + + return PLAINTEXT_LANGUAGE_ID; + }; + let processedOutput = output; if (typeof output === 'string') { // back compat with older stored versions processedOutput = [{ type: 'embed', value: output, isText: true }]; @@ -93,7 +115,7 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS if (o.type === 'ref') { return { kind: 'data', uri: o.uri, mimeType: o.mimeType }; } else if (o.isText && !o.asResource) { - return createCodePart(o.value); + return createCodePart(o.value, getOutputLanguageId(o)); } else { // Defer base64 decoding to avoid expensive decode during scroll. // The value will be decoded lazily in ChatToolOutputContentSubPart. diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index 702f69c59e51f1..664a249db3fdbc 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -45,7 +45,7 @@ import { LifecyclePhase } from '../../../../../../services/lifecycle/common/life import { ISearchService } from '../../../../../../services/search/common/search.js'; import { McpPromptArgumentPick } from '../../../../../mcp/browser/mcpPromptArgumentPick.js'; import { IMcpPrompt, IMcpPromptMessage, IMcpServer, IMcpService, McpResourceURI } from '../../../../../mcp/common/mcpTypes.js'; -import { searchFilesAndFolders } from '../../../../../search/browser/searchChatContext.js'; +import { MAX_CHAT_FILE_COMPLETION_RESULTS, searchFilesAndFolders } from '../../../../../search/browser/searchChatContext.js'; import { IChatAgentData, IChatAgentNameService, IChatAgentService, getFullyQualifiedId } from '../../../../common/participants/chatAgents.js'; import { getAttachableImageExtension } from '../../../../common/model/chatModel.js'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from '../../../../common/requestParser/chatParserTypes.js'; @@ -1229,7 +1229,7 @@ class BuiltinDynamicCompletions extends Disposable { const workspaces = this.workspaceContextService.getWorkspace().folders.map(folder => folder.uri); for (const workspace of workspaces) { - const { folders, files } = await searchFilesAndFolders(workspace, pattern, true, token, cacheKey.key, this.configurationService, this.searchService); + const { folders, files } = await searchFilesAndFolders(workspace, pattern, true, token, cacheKey.key, this.configurationService, this.searchService, MAX_CHAT_FILE_COMPLETION_RESULTS); for (const file of files) { if (!seen.has(file)) { result.suggestions.push(makeCompletionItem(file, FileKind.FILE)); diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index cdd3b62f1d7064..47beba3ef9419e 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -239,6 +239,8 @@ export interface ILanguageModelConfigurationSchema extends IJSONSchema { group?: string; /** Labels for enum values. If provided, these are shown instead of the raw enum values. */ enumItemLabels?: string[]; + /** When `true`, the property is displayed but cannot be modified by the user. */ + readOnly?: boolean; }; }; } diff --git a/src/vs/workbench/contrib/chat/common/tunnelHost.ts b/src/vs/workbench/contrib/chat/common/tunnelHost.ts deleted file mode 100644 index 559dcacd48b022..00000000000000 --- a/src/vs/workbench/contrib/chat/common/tunnelHost.ts +++ /dev/null @@ -1,35 +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 { ITunnelHostInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; - -export const ITunnelHostService = createDecorator('tunnelHostService'); - -export interface ITunnelHostService { - readonly _serviceBrand: undefined; - - /** Fires when the sharing status changes. */ - readonly onDidChangeStatus: Event; - - /** Whether the agent host is currently shared via a tunnel. */ - readonly isSharing: boolean; - - /** Whether a tunnel connection is currently being established. */ - readonly isConnecting: boolean; - - /** Information about the active tunnel, if sharing. */ - readonly sharingInfo: ITunnelHostInfo | undefined; - - /** Start sharing the local agent host via a dev tunnel. */ - startSharing(): Promise; - - /** Stop sharing and tear down the tunnel. */ - stopSharing(): Promise; - - /** Restart sharing after a tunnel configuration change. */ - restartSharing(): Promise; -} diff --git a/src/vs/workbench/contrib/chat/electron-browser/toggleRemoteConnectionsActionViewItem.ts b/src/vs/workbench/contrib/chat/electron-browser/toggleRemoteConnectionsActionViewItem.ts index f9937e7c4e0029..60618149e8c68a 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/toggleRemoteConnectionsActionViewItem.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/toggleRemoteConnectionsActionViewItem.ts @@ -16,31 +16,56 @@ import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { localize } from '../../../../nls.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { ITunnelHostService } from '../common/tunnelHost.js'; -import { RENAME_TUNNEL_ID, SHOW_TUNNEL_HOST_OUTPUT_ID } from './tunnelHostService.js'; +import { IRemoteTunnelService, INACTIVE_TUNNEL_MODE, TunnelMode, TunnelStatus } from '../../../../platform/remoteTunnel/common/remoteTunnel.js'; +import { RemoteTunnelCommandIds } from '../../remoteTunnel/electron-browser/remoteTunnel.contribution.js'; const TUNNEL_ACCESS_DOCS_URL = 'https://aka.ms/vscode-agent-tunnel-access'; +export interface IRemoteTunnelAccessState { + readonly isSharing: boolean; + readonly isConnecting: boolean; + readonly tunnelName: string | undefined; +} + +export function getRemoteTunnelAccessState(mode: TunnelMode, status: TunnelStatus): IRemoteTunnelAccessState { + return { + isSharing: status.type === 'connected', + isConnecting: status.type === 'connecting' || (mode.active && status.type === 'uninitialized'), + tunnelName: status.type === 'connected' ? status.info.tunnelName : undefined, + }; +} + export class ToggleRemoteConnectionsActionViewItem extends BaseActionViewItem { private _iconElement: HTMLElement | undefined; private _toastElement: HTMLElement | undefined; private _hover: IManagedHover | undefined; private _wasSharing = false; + private _mode: TunnelMode = INACTIVE_TUNNEL_MODE; + private _status: TunnelStatus = { type: 'uninitialized' }; + private _hasReceivedMode = false; + private _hasReceivedStatus = false; + private _hasInitializedState = false; constructor( action: IAction, - @ITunnelHostService private readonly _tunnelHostService: ITunnelHostService, + @IRemoteTunnelService private readonly _remoteTunnelService: IRemoteTunnelService, @IHoverService private readonly _hoverService: IHoverService, @IProductService private readonly _productService: IProductService, ) { super(undefined, action); - this._wasSharing = this._tunnelHostService.isSharing; - - this._register(this._tunnelHostService.onDidChangeStatus(() => { + this._register(this._remoteTunnelService.onDidChangeTunnelStatus(status => { + this._hasReceivedStatus = true; + this._status = status; + this._updateState(); + })); + this._register(this._remoteTunnelService.onDidChangeMode(mode => { + this._hasReceivedMode = true; + this._mode = mode; this._updateState(); })); + void this._loadState(); } override render(container: HTMLElement): void { @@ -72,22 +97,38 @@ export class ToggleRemoteConnectionsActionViewItem extends BaseActionViewItem { return; } - const isSharing = this._tunnelHostService.isSharing; - const isConnecting = this._tunnelHostService.isConnecting; + const state = getRemoteTunnelAccessState(this._mode, this._status); - this.element.classList.toggle('sharing', isSharing); - this.element.classList.toggle('connecting', isConnecting); + this.element.classList.toggle('sharing', state.isSharing); + this.element.classList.toggle('connecting', state.isConnecting); this._hover?.update(this._getHoverContent()); this.element.setAttribute('aria-label', this._getAriaLabel()); - this.element.setAttribute('aria-pressed', String(isSharing)); + this.element.setAttribute('aria-pressed', String(state.isSharing)); - if (isSharing && !this._wasSharing && !isConnecting) { - this._showToast(); - } else if (!isSharing && this._wasSharing) { - this._hideToast(); + if (this._hasInitializedState) { + if (state.isSharing && !this._wasSharing && !state.isConnecting) { + this._showToast(); + } else if (!state.isSharing && this._wasSharing) { + this._hideToast(); + } } - this._wasSharing = isSharing; + this._wasSharing = state.isSharing; + } + + private async _loadState(): Promise { + const [mode, status] = await Promise.all([ + this._remoteTunnelService.getMode(), + this._remoteTunnelService.getTunnelStatus(), + ]); + if (!this._hasReceivedMode) { + this._mode = mode; + } + if (!this._hasReceivedStatus) { + this._status = status; + } + this._updateState(); + this._hasInitializedState = true; } private _showToast(): void { @@ -109,18 +150,14 @@ export class ToggleRemoteConnectionsActionViewItem extends BaseActionViewItem { private _getHoverContent(): IManagedHoverContent { const lines: string[] = []; + const state = getRemoteTunnelAccessState(this._mode, this._status); - if (this._tunnelHostService.isConnecting) { + if (state.isConnecting) { lines.push(localize('tunnelHost.hover.connecting', "Establishing tunnel connection...")); - } else if (this._tunnelHostService.isSharing) { - const info = this._tunnelHostService.sharingInfo; - if (info) { - lines.push(info.viaRemoteTunnelAccess - ? localize('tunnelHost.hover.remoteTunnelAccess', "Remote session access is provided by Remote Tunnel Access via tunnel '{0}'. Turning off remote session access does not disable Remote Tunnel Access.", info.tunnelName) - : localize('tunnelHost.hover.sharing', "Remote session access enabled via tunnel '{0}'", info.tunnelName)); - } else { - lines.push(localize('tunnelHost.hover.enabled', "Remote session access is enabled")); - } + } else if (state.isSharing) { + lines.push(state.tunnelName + ? localize('tunnelHost.hover.sharing', "Remote Tunnel Access is enabled via tunnel '{0}'", state.tunnelName) + : localize('tunnelHost.hover.enabled', "Remote Tunnel Access is enabled")); } else { const agentsUrl = this._productService.webUrl ? `${this._productService.webUrl.replace(/\/$/, '')}/agents` : undefined; lines.push(agentsUrl @@ -128,24 +165,21 @@ export class ToggleRemoteConnectionsActionViewItem extends BaseActionViewItem { : localize('tunnelHost.hover.idle.noWebUrl', "Allow connections from other machines")); } - lines.push(`[${localize('tunnelHost.hover.showOutput', "Show Output")}](command:${SHOW_TUNNEL_HOST_OUTPUT_ID}) | [${localize('tunnelHost.hover.renameTunnel', "Rename Tunnel")}](command:${RENAME_TUNNEL_ID}) | [${localize('tunnelHost.hover.learnMore', "Learn More")}](${TUNNEL_ACCESS_DOCS_URL})`); + lines.push(`[${localize('tunnelHost.hover.showOutput', "Show Output")}](command:${RemoteTunnelCommandIds.showLog}) | [${localize('tunnelHost.hover.renameTunnel', "Rename Tunnel")}](command:${RemoteTunnelCommandIds.rename}) | [${localize('tunnelHost.hover.learnMore', "Learn More")}](${TUNNEL_ACCESS_DOCS_URL})`); - const md = new MarkdownString(lines.join('\n\n'), { isTrusted: { enabledCommands: [SHOW_TUNNEL_HOST_OUTPUT_ID, RENAME_TUNNEL_ID] } }); + const md = new MarkdownString(lines.join('\n\n'), { isTrusted: { enabledCommands: [RemoteTunnelCommandIds.showLog, RemoteTunnelCommandIds.rename] } }); return { markdown: md, markdownNotSupportedFallback: lines[0] }; } private _getAriaLabel(): string { - if (this._tunnelHostService.isConnecting) { + const state = getRemoteTunnelAccessState(this._mode, this._status); + if (state.isConnecting) { return localize('tunnelHost.hover.connecting', "Establishing tunnel connection..."); } - if (this._tunnelHostService.isSharing) { - const info = this._tunnelHostService.sharingInfo; - if (info) { - return info.viaRemoteTunnelAccess - ? localize('tunnelHost.ariaLabel.remoteTunnelAccess', "Remote session access provided by Remote Tunnel Access via tunnel '{0}'", info.tunnelName) - : localize('tunnelHost.hover.sharing', "Remote session access enabled via tunnel '{0}'", info.tunnelName); - } - return localize('tunnelHost.hover.enabled', "Remote session access is enabled"); + if (state.isSharing) { + return state.tunnelName + ? localize('tunnelHost.ariaLabel.remoteTunnelAccess', "Remote Tunnel Access is enabled via tunnel '{0}'", state.tunnelName) + : localize('tunnelHost.hover.enabled', "Remote Tunnel Access is enabled"); } const agentsUrl = this._productService.webUrl ? `${this._productService.webUrl.replace(/\/$/, '')}/agents` : undefined; return agentsUrl diff --git a/src/vs/workbench/contrib/chat/electron-browser/tunnelHost.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/tunnelHost.contribution.ts index b02a7d753d15d1..597037499f6739 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/tunnelHost.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/tunnelHost.contribution.ts @@ -5,26 +5,18 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { localize, localize2 } from '../../../../nls.js'; +import { localize2 } from '../../../../nls.js'; import { IActionViewItemService, type IActionViewItemFactory } from '../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; -import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; -import { Registry } from '../../../../platform/registry/common/platform.js'; -import { CONFIGURATION_KEY_HOST_NAME, MAX_TUNNEL_NAME_LENGTH } from '../../../../platform/remoteTunnel/common/remoteTunnel.js'; +import { INACTIVE_TUNNEL_MODE, IRemoteTunnelService, TunnelMode, TunnelStatus } from '../../../../platform/remoteTunnel/common/remoteTunnel.js'; import { IsSessionsWindowContext, RemoteNameContext } from '../../../common/contextkeys.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; -import { IOutputService } from '../../../services/output/common/output.js'; import { ChatContextKeyExprs, ChatContextKeys } from '../common/actions/chatContextKeys.js'; -import { ITunnelHostService } from '../common/tunnelHost.js'; -import { CONFIGURATION_KEY_MICROSOFT_AUTH, RENAME_TUNNEL_ID, SHOW_TUNNEL_HOST_OUTPUT_ID, TunnelHostService } from './tunnelHostService.js'; -import { TUNNEL_HOST_LOG_ID } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; -import { ToggleRemoteConnectionsActionViewItem } from './toggleRemoteConnectionsActionViewItem.js'; +import { IRemoteTunnelStartOptions, RemoteTunnelCommandIds } from '../../remoteTunnel/electron-browser/remoteTunnel.contribution.js'; +import { getRemoteTunnelAccessState, ToggleRemoteConnectionsActionViewItem } from './toggleRemoteConnectionsActionViewItem.js'; export const TUNNEL_HOST_SHARING_KEY = 'tunnelHostSharing'; export const TUNNEL_HOST_SHARING_CONTEXT = new RawContextKey(TUNNEL_HOST_SHARING_KEY, false); @@ -32,35 +24,72 @@ export const TOGGLE_SHARING_ID = 'sessions.tunnelHost.toggleSharing'; const CATEGORY = localize2('tunnelHost.category', 'Remote Connections'); -/** Matches `is_valid_name` in the CLI's `cli/src/tunnels/dev_tunnels.rs`. */ -const TUNNEL_NAME_REGEX = /^[\w-]+$/; - -registerSingleton(ITunnelHostService, TunnelHostService, InstantiationType.Delayed); +export async function executeToggleRemoteConnections(remoteTunnelService: IRemoteTunnelService, commandService: ICommandService, startOptions?: IRemoteTunnelStartOptions): Promise { + const [mode, status] = await Promise.all([ + remoteTunnelService.getMode(), + remoteTunnelService.getTunnelStatus(), + ]); + const state = getRemoteTunnelAccessState(mode, status); + const command = state.isSharing || state.isConnecting ? RemoteTunnelCommandIds.turnOff : RemoteTunnelCommandIds.turnOn; + if (command === RemoteTunnelCommandIds.turnOn && startOptions) { + await commandService.executeCommand(command, startOptions); + } else { + await commandService.executeCommand(command); + } +} class TunnelHostContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.tunnelHost'; private readonly _sharingContext: IContextKey; + private _mode: TunnelMode = INACTIVE_TUNNEL_MODE; + private _status: TunnelStatus = { type: 'uninitialized' }; + private _hasReceivedMode = false; + private _hasReceivedStatus = false; constructor( @IContextKeyService contextKeyService: IContextKeyService, - @ITunnelHostService tunnelHostService: ITunnelHostService, + @IRemoteTunnelService private readonly remoteTunnelService: IRemoteTunnelService, @IActionViewItemService actionViewItemService: IActionViewItemService, ) { super(); this._sharingContext = TUNNEL_HOST_SHARING_CONTEXT.bindTo(contextKeyService); - this._sharingContext.set(tunnelHostService.isSharing); - - this._register(tunnelHostService.onDidChangeStatus(() => { - this._sharingContext.set(tunnelHostService.isSharing); + this._register(this.remoteTunnelService.onDidChangeTunnelStatus(status => { + this._hasReceivedStatus = true; + this._status = status; + this._updateSharingContext(); + })); + this._register(this.remoteTunnelService.onDidChangeMode(mode => { + this._hasReceivedMode = true; + this._mode = mode; + this._updateSharingContext(); })); const viewItemFactory: IActionViewItemFactory = (action, _options, instantiationService) => { return instantiationService.createInstance(ToggleRemoteConnectionsActionViewItem, action); }; - this._register(actionViewItemService.register(MenuId.ChatInputSecondary, TOGGLE_SHARING_ID, viewItemFactory, tunnelHostService.onDidChangeStatus)); + this._register(actionViewItemService.register(MenuId.ChatInputSecondary, TOGGLE_SHARING_ID, viewItemFactory, this.remoteTunnelService.onDidChangeTunnelStatus)); + void this._loadState(); + } + + private async _loadState(): Promise { + const [mode, status] = await Promise.all([ + this.remoteTunnelService.getMode(), + this.remoteTunnelService.getTunnelStatus(), + ]); + if (!this._hasReceivedMode) { + this._mode = mode; + } + if (!this._hasReceivedStatus) { + this._status = status; + } + this._updateSharingContext(); + } + + private _updateSharingContext(): void { + this._sharingContext.set(getRemoteTunnelAccessState(this._mode, this._status).isSharing); } } @@ -87,102 +116,8 @@ registerAction2(class ToggleRemoteConnectionsAction extends Action2 { } async run(accessor: ServicesAccessor): Promise { - const tunnelHostService = accessor.get(ITunnelHostService); - const notificationService = accessor.get(INotificationService); - - try { - if (tunnelHostService.isSharing) { - await tunnelHostService.stopSharing(); - } else { - await tunnelHostService.startSharing(); - } - } catch (err) { - notificationService.notify({ - severity: Severity.Error, - message: localize('tunnelHost.error', "Failed to toggle remote connections: {0}", String(err)), - }); - } - } -}); - -registerAction2(class ShowTunnelHostOutputAction extends Action2 { - constructor() { - super({ - id: SHOW_TUNNEL_HOST_OUTPUT_ID, - title: localize2('showTunnelHostOutput', "Show Remote Connections Output"), - category: CATEGORY, - }); - } - - async run(accessor: ServicesAccessor): Promise { - const outputService = accessor.get(IOutputService); - await outputService.showChannel(TUNNEL_HOST_LOG_ID); - } -}); - -registerAction2(class RenameTunnelAction extends Action2 { - constructor() { - super({ - id: RENAME_TUNNEL_ID, - title: localize2('renameTunnel', "Rename Tunnel"), - category: CATEGORY, - }); - } - - async run(accessor: ServicesAccessor): Promise { - const tunnelHostService = accessor.get(ITunnelHostService); - const configurationService = accessor.get(IConfigurationService); - const quickInputService = accessor.get(IQuickInputService); - const notificationService = accessor.get(INotificationService); - const currentName = tunnelHostService.sharingInfo?.tunnelName ?? configurationService.getValue(CONFIGURATION_KEY_HOST_NAME); - const name = await quickInputService.input({ - title: localize('renameTunnel.title', "Rename Tunnel"), - prompt: localize('renameTunnel.prompt', "Enter a name for this tunnel."), - value: currentName, - placeHolder: localize('renameTunnel.placeholder', "Leave blank to use this machine's host name."), - validateInput: async input => { - if (input.length === 0) { - return undefined; - } - if (input.length > MAX_TUNNEL_NAME_LENGTH) { - return localize('renameTunnel.maxLength', "The name must not be longer than {0} characters.", MAX_TUNNEL_NAME_LENGTH); - } - if (!TUNNEL_NAME_REGEX.test(input) || input.startsWith('-')) { - return localize('renameTunnel.invalidName', "The name must only consist of letters, numbers, underscore and dash. It must not start with a dash."); - } - return undefined; - }, - }); - - if (name === undefined) { - return; - } - - await configurationService.updateValue(CONFIGURATION_KEY_HOST_NAME, name || undefined, ConfigurationTarget.USER); - - if (!tunnelHostService.isSharing) { - return; - } - - try { - await tunnelHostService.restartSharing(); - } catch (err) { - notificationService.error(localize('renameTunnel.error', "Failed to rename tunnel: {0}", String(err))); - } + await executeToggleRemoteConnections(accessor.get(IRemoteTunnelService), accessor.get(ICommandService)); } }); registerWorkbenchContribution2(TunnelHostContribution.ID, TunnelHostContribution, WorkbenchPhase.AfterRestored); - -Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ - type: 'object', - properties: { - [CONFIGURATION_KEY_MICROSOFT_AUTH]: { - description: localize('tunnelHost.enableMicrosoftAuth', "Enable Microsoft account authentication for agent host tunnels. When disabled, only GitHub authentication is used."), - type: 'boolean', - scope: ConfigurationScope.APPLICATION, - default: false, - tags: ['usesOnlineServices'], - }, - } -}); diff --git a/src/vs/workbench/contrib/chat/electron-browser/tunnelHostService.ts b/src/vs/workbench/contrib/chat/electron-browser/tunnelHostService.ts deleted file mode 100644 index fd34e07c88491c..00000000000000 --- a/src/vs/workbench/contrib/chat/electron-browser/tunnelHostService.ts +++ /dev/null @@ -1,261 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { localize } from '../../../../nls.js'; -import { - ITunnelAgentHostHostingService, - TUNNEL_HOST_CHANNEL, - TUNNEL_HOST_LOG_ID, - type ITunnelHostInfo, - type TunnelHostStatus, -} from '../../../../platform/agentHost/common/tunnelAgentHost.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; -import { ISharedProcessService } from '../../../../platform/ipc/electron-browser/services.js'; -import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; -import { joinPath } from '../../../../base/common/resources.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { IAuthenticationService } from '../../../services/authentication/common/authentication.js'; -import { ITunnelHostService } from '../common/tunnelHost.js'; - -export const CONFIGURATION_KEY_MICROSOFT_AUTH = 'remote.tunnels.access.enableMicrosoftAuth'; -export const SHOW_TUNNEL_HOST_OUTPUT_ID = 'sessions.tunnelHost.showOutput'; -export const RENAME_TUNNEL_ID = 'sessions.tunnelHost.renameTunnel'; -export const TUNNEL_HOST_SHARING_PREFERENCE_KEY = 'tunnelHost.sharingEnabled'; - -export class TunnelHostService extends Disposable implements ITunnelHostService { - declare readonly _serviceBrand: undefined; - - private readonly _mainService: ITunnelAgentHostHostingService; - private readonly _logger: ILogger; - - private readonly _onDidChangeStatus = this._register(new Emitter()); - readonly onDidChangeStatus: Event = this._onDidChangeStatus.event; - - private _isSharing = false; - private _isConnecting = false; - private _sharingInfo: ITunnelHostInfo | undefined; - private readonly _initializationPromise: Promise; - - /** Tracks which auth provider was last used successfully. */ - private _lastAuthProvider: 'github' | 'microsoft' | undefined; - - constructor( - @ISharedProcessService sharedProcessService: ISharedProcessService, - @IAuthenticationService private readonly _authenticationService: IAuthenticationService, - @IProductService private readonly _productService: IProductService, - @IConfigurationService private readonly _configurationService: IConfigurationService, - @ILoggerService loggerService: ILoggerService, - @IEnvironmentService environmentService: IEnvironmentService, - @IStorageService private readonly _storageService: IStorageService, - ) { - super(); - - this._logger = this._register(loggerService.createLogger( - joinPath(environmentService.logsHome, `${TUNNEL_HOST_LOG_ID}.log`), - { id: TUNNEL_HOST_LOG_ID, name: localize('tunnelHost.outputChannel', "Remote Connections") }, - )); - - this._mainService = ProxyChannel.toService( - sharedProcessService.getChannel(TUNNEL_HOST_CHANNEL), - ); - - this._register(this._mainService.onDidChangeStatus((status: TunnelHostStatus) => { - this._isSharing = status.active; - this._sharingInfo = status.active ? status.info : undefined; - this._onDidChangeStatus.fire(); - })); - - this._initializationPromise = this._initialize(); - } - - private async _initialize(): Promise { - try { - const status = await this._mainService.getStatus(); - this._isSharing = status.active; - this._sharingInfo = status.active ? status.info : undefined; - if (status.active) { - this._onDidChangeStatus.fire(); - } - - if (!this._isSharing && this._storageService.getBoolean(TUNNEL_HOST_SHARING_PREFERENCE_KEY, StorageScope.APPLICATION, false)) { - await this._startSharing(true); - } - } catch (error) { - this._logger.error('Failed to restore remote connections.', error); - } - } - - get isSharing(): boolean { - return this._isSharing; - } - - get isConnecting(): boolean { - return this._isConnecting; - } - - get sharingInfo(): ITunnelHostInfo | undefined { - return this._sharingInfo; - } - - async startSharing(): Promise { - await this._initializationPromise; - await this._startSharing(false); - } - - private async _startSharing(silent: boolean): Promise { - if (this._isSharing || this._isConnecting) { - return; - } - - this._isConnecting = true; - this._onDidChangeStatus.fire(); - - try { - const auth = await this._getToken(silent); - if (!auth) { - this._logger.warn('No auth token available for tunnel hosting'); - throw new Error(localize('tunnelHost.noAuth', "No authentication token available. Please sign in and try again.")); - } - - this._logger.info('Starting tunnel hosting...'); - - const info = await this._mainService.startHosting(auth.token, auth.provider); - this._isSharing = true; - this._sharingInfo = info; - this._storageService.store(TUNNEL_HOST_SHARING_PREFERENCE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - } finally { - this._isConnecting = false; - this._onDidChangeStatus.fire(); - } - } - - async restartSharing(): Promise { - await this._initializationPromise; - await this._stopSharing(true); - await this._startSharing(false); - } - - async stopSharing(): Promise { - await this._initializationPromise; - - await this._stopSharing(false); - } - - private async _stopSharing(preservePreference: boolean): Promise { - if (!preservePreference) { - this._storageService.remove(TUNNEL_HOST_SHARING_PREFERENCE_KEY, StorageScope.APPLICATION); - } - this._logger.info('Stopping tunnel hosting...'); - await this._mainService.stopHosting(); - this._isSharing = false; - this._sharingInfo = undefined; - this._onDidChangeStatus.fire(); - } - - private _getEnabledProviders(): readonly ('github' | 'microsoft')[] { - const microsoftEnabled = this._configurationService.getValue(CONFIGURATION_KEY_MICROSOFT_AUTH); - return microsoftEnabled ? ['microsoft', 'github'] : ['github']; - } - - private async _getToken(silent: boolean): Promise<{ token: string; provider: 'github' | 'microsoft' } | undefined> { - const enabledProviders = this._getEnabledProviders(); - - if (this._lastAuthProvider && enabledProviders.includes(this._lastAuthProvider)) { - const result = await this._getTokenForProvider(this._lastAuthProvider, silent); - if (result) { - return result; - } - } - - for (const provider of enabledProviders) { - if (provider === this._lastAuthProvider) { - continue; - } - const result = await this._getTokenForProvider(provider, true); - if (result) { - return result; - } - } - - if (!silent) { - for (const provider of enabledProviders) { - const result = await this._getTokenForProvider(provider, false); - if (result) { - return result; - } - } - } - - return undefined; - } - - private _getScopesForProvider(provider: 'github' | 'microsoft'): string[] { - const config = this._productService.tunnelApplicationConfig?.authenticationProviders; - return config?.[provider]?.scopes ?? []; - } - - private async _getTokenForProvider( - provider: 'github' | 'microsoft', - silent: boolean, - ): Promise<{ token: string; provider: 'github' | 'microsoft' } | undefined> { - const scopes = this._getScopesForProvider(provider); - if (scopes.length === 0) { - return undefined; - } - - try { - let sessions = await this._authenticationService.getSessions(provider, scopes, {}, true); - - if (sessions.length === 0) { - const allSessions = await this._authenticationService.getSessions(provider, undefined, {}, true); - const requestedSet = new Set(scopes); - let bestSession: typeof allSessions[number] | undefined; - let bestExtra = Infinity; - for (const session of allSessions) { - const sessionScopes = new Set(session.scopes); - let isSuperset = true; - for (const scope of requestedSet) { - if (!sessionScopes.has(scope)) { - isSuperset = false; - break; - } - } - if (isSuperset) { - const extra = sessionScopes.size - requestedSet.size; - if (extra < bestExtra) { - bestExtra = extra; - bestSession = session; - } - } - } - if (bestSession) { - sessions = [bestSession]; - } - } - - if (sessions.length === 0 && !silent) { - const session = await this._authenticationService.createSession(provider, scopes, { activateImmediate: true }); - sessions = [session]; - } - - if (sessions.length > 0) { - const token = sessions[0].accessToken; - if (token) { - this._lastAuthProvider = provider; - return { token, provider }; - } - } - } catch (err) { - this._logger.debug(`Failed to get ${provider} token: ${err}`); - } - return undefined; - } - -} diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts index 612f96ce1f0252..edbdee0d59a03c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts @@ -18,10 +18,10 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { IAuthenticationMcpAccessService } from '../../../../../services/authentication/browser/authenticationMcpAccessService.js'; import { IAuthenticationMcpService } from '../../../../../services/authentication/browser/authenticationMcpService.js'; import { IAuthenticationMcpUsageService } from '../../../../../services/authentication/browser/authenticationMcpUsageService.js'; -import { IAuthenticationService, type IAuthenticationProvider } from '../../../../../services/authentication/common/authentication.js'; +import { IAuthenticationService, type AuthenticationSession, type IAuthenticationProvider } from '../../../../../services/authentication/common/authentication.js'; import { IDynamicAuthenticationProviderStorageService } from '../../../../../services/authentication/common/dynamicAuthenticationProviderStorage.js'; import { CHAT_SETUP_ACTION_ID } from '../../../browser/actions/chatActions.js'; -import { AgentHostAuthenticationRecovery, authenticateProtectedResources, resolveAuthenticationInteractively, resolveTokenForResource, AgentHostAuthTokenCache, agentHostMcpServerId, resolveMcpServerAuthentication, modelRequiresAgentAuthentication, type IAgentHostAuthenticationOptions } from '../../../browser/agentSessions/agentHost/agentHostAuth.js'; +import { AgentHostAuthenticationRecovery, authenticateProtectedResources, resolveAuthenticationInteractively, resolveTokenForResource, AgentHostAuthTokenCache, agentHostMcpServerId, resolveMcpServerAuthentication, modelRequiresAgentAuthentication, revokeAuthenticationForRemovedSessions, type IAgentHostAuthenticationOptions } from '../../../browser/agentSessions/agentHost/agentHostAuth.js'; import { createAgentModelByokMeta } from '../../../../../../platform/agentHost/common/agentModelByokMeta.js'; class TestCommandService extends mock() { @@ -51,6 +51,8 @@ function createMockAuthService(overrides: { createDynamicAuthenticationProvider?: (...args: Parameters) => Promise<{ readonly id: string } | undefined>; getProvider?: IAuthenticationService['getProvider']; isDynamicAuthenticationProvider?: (providerId: string) => boolean; + isAuthenticationProviderRegistered?: (providerId: string) => boolean; + declaredProviders?: IAuthenticationService['declaredProviders']; unregisterAuthenticationProvider?: (providerId: string) => void; }): IAuthenticationService { return { @@ -60,6 +62,8 @@ function createMockAuthService(overrides: { createDynamicAuthenticationProvider: overrides.createDynamicAuthenticationProvider ?? (() => Promise.resolve(undefined)), getProvider: overrides.getProvider ?? (() => { throw new Error('Unexpected getProvider call'); }), isDynamicAuthenticationProvider: overrides.isDynamicAuthenticationProvider ?? (() => false), + isAuthenticationProviderRegistered: overrides.isAuthenticationProviderRegistered ?? (() => true), + declaredProviders: overrides.declaredProviders ?? [], unregisterAuthenticationProvider: overrides.unregisterAuthenticationProvider ?? (() => { }), } as unknown as IAuthenticationService; } @@ -452,7 +456,7 @@ suite('AgentHostAuthenticationRecovery', () => { assert.strictEqual(commandService.calls.length, 2); }); - test('forwards credential removal and resets escalation when the current token disappears', async () => { + test('does not forward credential removal and resets escalation when the current token disappears', async () => { const token = { value: 'tok-1' as string | undefined }; const authService = createMockAuthService({ getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'), @@ -484,7 +488,7 @@ suite('AgentHostAuthenticationRecovery', () => { authenticateCalls, }, { commandCalls: 0, - authenticateCalls: ['tok-1', '', 'tok-1'], + authenticateCalls: ['tok-1', 'tok-1'], }); }); }); @@ -1119,6 +1123,13 @@ suite('authenticateProtectedResources', () => { scopes_supported: ['read'], }; + const removedSession = (scopes: readonly string[]): AuthenticationSession => ({ + id: `session-${scopes.join('-')}`, + accessToken: 'removed-token', + account: { id: 'account-1', label: 'Account' }, + scopes, + }); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); test('skips authenticate when the cached token is unchanged', async () => { @@ -1155,7 +1166,7 @@ suite('authenticateProtectedResources', () => { assert.deepStrictEqual(requests, [{ resource: protectedResource.resource, scopes: ['read'], token: 'cached-token' }]); }); - test('forwards credential removal when a previously available token disappears', async () => { + test('does not infer credential removal when a previously available token disappears', async () => { let token: string | undefined = 'cached-token'; const authService = createMockAuthService({ getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'), @@ -1186,9 +1197,149 @@ suite('authenticateProtectedResources', () => { assert.deepStrictEqual(requests, [ { resource: protectedResource.resource, scopes: ['read'], token: 'cached-token' }, - { resource: protectedResource.resource, scopes: ['read'], token: '' }, ]); }); + + test('does not clear shared authentication while the provider is not ready', async () => { + let providerReady = false; + let sharedHostToken: string | undefined = 'other-client-token'; + const authService = createMockAuthService({ + getOrActivateProviderIdForServer: () => Promise.resolve(providerReady ? 'provider-1' : undefined), + isAuthenticationProviderRegistered: () => providerReady, + declaredProviders: [{ + id: 'provider-1', + label: 'Provider', + authorizationServerGlobs: ['https://auth.example.com/*'], + }], + getSessions: () => Promise.resolve([{ scopes: ['read'], accessToken: 'healthy-client-token' }]), + }); + const requests: { resource: string; scopes?: readonly string[]; token: string }[] = []; + const agents = [{ protectedResources: [protectedResource] }] as unknown as readonly AgentInfo[]; + const instantiationService = createAuthInstantiationService(disposables, authService); + const options: IAgentHostAuthenticationOptions = { + authTokenCache: new AgentHostAuthTokenCache(), + logPrefix: '[AgentHost]', + authenticate: async request => { + requests.push(request); + sharedHostToken = request.token || undefined; + }, + }; + + await instantiationService.invokeFunction(authenticateProtectedResources, agents, options); + providerReady = true; + await instantiationService.invokeFunction(authenticateProtectedResources, agents, options); + + assert.deepStrictEqual({ requests, sharedHostToken }, { + requests: [{ resource: protectedResource.resource, scopes: ['read'], token: 'healthy-client-token' }], + sharedHostToken: 'healthy-client-token', + }); + }); + + test('clears shared authentication after an explicit session removal', async () => { + let sharedHostToken: string | undefined = 'healthy-client-token'; + const authService = createMockAuthService({ + getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'), + }); + const requests: { resource: string; scopes?: readonly string[]; token: string }[] = []; + const agents = [{ protectedResources: [protectedResource] }] as unknown as readonly AgentInfo[]; + const instantiationService = createAuthInstantiationService(disposables, authService); + + await instantiationService.invokeFunction(revokeAuthenticationForRemovedSessions, agents, 'provider-1', [removedSession(['read'])], { + authTokenCache: new AgentHostAuthTokenCache(), + logPrefix: '[AgentHost]', + authenticate: async request => { + requests.push(request); + sharedHostToken = request.token || undefined; + }, + }); + + assert.deepStrictEqual({ requests, sharedHostToken }, { + requests: [{ resource: protectedResource.resource, scopes: ['read'], token: '' }], + sharedHostToken: undefined, + }); + }); + + test('forwards the surviving token instead of revoking when another account remains', async () => { + let sharedHostToken: string | undefined = 'removed-account-token'; + const authService = createMockAuthService({ + getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'), + getSessions: () => Promise.resolve([{ scopes: ['read'], accessToken: 'surviving-account-token' }]), + }); + const requests: { resource: string; scopes?: readonly string[]; token: string }[] = []; + const agents = [{ protectedResources: [protectedResource] }] as unknown as readonly AgentInfo[]; + const instantiationService = createAuthInstantiationService(disposables, authService); + + await instantiationService.invokeFunction(revokeAuthenticationForRemovedSessions, agents, 'provider-1', [removedSession(['read'])], { + authTokenCache: new AgentHostAuthTokenCache(), + logPrefix: '[AgentHost]', + authenticate: async request => { + requests.push(request); + sharedHostToken = request.token || undefined; + }, + }); + + assert.deepStrictEqual({ requests, sharedHostToken }, { + requests: [{ resource: protectedResource.resource, scopes: ['read'], token: 'surviving-account-token' }], + sharedHostToken: 'surviving-account-token', + }); + }); + + test('leaves resources the removed session could not satisfy untouched', async () => { + // One provider commonly serves several resources with different scope sets. + // Signing out of an account that never covered a resource must not make this + // client re-evaluate -- and possibly revoke -- a credential another client owns. + let sharedHostToken: string | undefined = 'other-client-token'; + const authService = createMockAuthService({ + getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'), + }); + const requests: { resource: string; scopes?: readonly string[]; token: string }[] = []; + const agents = [{ protectedResources: [protectedResource] }] as unknown as readonly AgentInfo[]; + const instantiationService = createAuthInstantiationService(disposables, authService); + + await instantiationService.invokeFunction(revokeAuthenticationForRemovedSessions, agents, 'provider-1', [removedSession(['repo'])], { + authTokenCache: new AgentHostAuthTokenCache(), + logPrefix: '[AgentHost]', + authenticate: async request => { + requests.push(request); + sharedHostToken = request.token || undefined; + }, + }); + + assert.deepStrictEqual({ requests, sharedHostToken }, { requests: [], sharedHostToken: 'other-client-token' }); + }); + + test('repairs host authentication after an external clear without replacing the cache', async () => { + let sharedHostToken: string | undefined; + const authService = createMockAuthService({ + getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'), + getSessions: () => Promise.resolve([{ scopes: ['read'], accessToken: 'healthy-client-token' }]), + }); + const cache = new AgentHostAuthTokenCache(); + const requests: { resource: string; scopes?: readonly string[]; token: string }[] = []; + const agents = [{ protectedResources: [protectedResource] }] as unknown as readonly AgentInfo[]; + const instantiationService = createAuthInstantiationService(disposables, authService); + const options: IAgentHostAuthenticationOptions = { + authTokenCache: cache, + logPrefix: '[AgentHost]', + authenticate: async request => { + requests.push(request); + sharedHostToken = request.token || undefined; + }, + }; + + await instantiationService.invokeFunction(authenticateProtectedResources, agents, options); + sharedHostToken = undefined; + cache.clear(); + await instantiationService.invokeFunction(authenticateProtectedResources, agents, options); + + assert.deepStrictEqual({ requests, sharedHostToken }, { + requests: [ + { resource: protectedResource.resource, scopes: ['read'], token: 'healthy-client-token' }, + { resource: protectedResource.resource, scopes: ['read'], token: 'healthy-client-token' }, + ], + sharedHostToken: 'healthy-client-token', + }); + }); }); suite('resolveAuthenticationInteractively', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 961659b195f34f..70e7b58da92bec 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -766,11 +766,13 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IDefaultAccountService, { onDidChangeDefaultAccount: Event.None, getDefaultAccount: async () => null }); const commandService = new MockCommandService(); instantiationService.stub(ICommandService, commandService); - instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None, ...authServiceOverride }); + instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None, onDidRegisterAuthenticationProvider: Event.None, ...authServiceOverride }); instantiationService.stub(ILanguageModelsService, { deltaLanguageModelChatProviderDescriptors: () => { }, registerLanguageModelProvider: () => toDisposable(() => { }), lookupLanguageModel: (modelId: string) => languageModels?.get(modelId), + getLanguageModelIds: () => [...(languageModels?.keys() ?? [])], + onDidChangeLanguageModels: Event.None, getVendors: () => [], getLanguageModelGroups: () => [], ...languageModelsServiceOverride, @@ -13651,6 +13653,10 @@ suite('AgentHostChatContribution', () => { }); test('re-authenticates with the same token when authentication is required', async () => { + // This is the cross-window repair path. When another client revokes the + // shared credential, the host advertises `auth/required`; recovery must + // clear the token cache and resend the unchanged token, since the cache + // mirrors what this client last sent rather than actual host state. const tokenRef = { current: 'tok-1' }; const { agentHostService } = createContribution(disposables, { authServiceOverride: tokenAuthService(tokenRef) }); @@ -13906,7 +13912,7 @@ suite('AgentHostChatContribution', () => { }); }); - test('forwards missing-token state once when no token is resolvable', async () => { + test('does not forward missing-token state when no token is resolvable', async () => { const noTokenService: Partial = { onDidChangeSessions: Event.None, getOrActivateProviderIdForServer: async () => undefined, @@ -13919,9 +13925,7 @@ suite('AgentHostChatContribution', () => { agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 }); await timeout(0); - assert.deepStrictEqual(agentHostService.authenticateCalls, [ - { resource: 'https://api.github.com', scopes: ['read:user'], token: '' }, - ]); + assert.deepStrictEqual(agentHostService.authenticateCalls, []); }); test('propagates interactive authentication errors for eager-created sessions', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts index f645be3abd6832..7746c957cbe054 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts @@ -112,7 +112,7 @@ suite('AgentHostCopilotCliSettingsContribution', () => { // (family, effort, tool filters) plus the '*' wildcard — the forwarder // must pass the object through structurally unchanged. const capabilityOverrides = { - 'preview-model-x': { family: 'claude-opus-4-8', reasoningEffort: 'high', availableTools: ['builtin:*'], excludedTools: ['mcp:*'] }, + 'preview-model-x': { family: 'claude-opus-4-8', reasoningEffort: 'high', availableTools: ['builtin:*'], excludedTools: ['mcp:*'], promptOverrideFile: '/prompts/evaluation.yaml' }, '*': { reasoningEffort: 'medium' }, }; const { agentHostService } = setup(disposables, { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts index 4325473e400aab..268195bf67e644 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts @@ -6,6 +6,8 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; @@ -56,6 +58,88 @@ suite('AgentHostLanguageModelProvider', () => { ); }); + test('groups the config keys the Copilot agent host names, so a sandbox session can configure its model', async () => { + const provider = createProvider(); + provider.updateModels([ + { + ...makeModel('claude-sonnet-4.6'), + configSchema: { + type: 'object', + properties: { + reasoningEffort: { type: 'string', title: 'Reasoning effort', enum: ['low', 'high'] }, + contextTier: { type: 'string', title: 'Context tier', enum: ['default', 'long_context'], enumLabels: ['Default', 'Long context'], default: 'default' }, + }, + }, + }, + ]); + + const infos = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + assert.deepStrictEqual( + Object.fromEntries(Object.entries(infos[0].metadata.configurationSchema?.properties ?? {}).map(([key, property]) => [key, property.group])), + // `contextTier` needs token counts from the catalogue to be worth showing, and there is + // none here; see the context-tier tests below. + { reasoningEffort: 'navigation' } + ); + }); + + test('derives reasoning-effort display text only when the host supplied none', async () => { + const provider = createProvider(); + provider.updateModels([ + { + ...makeModel('claude-sonnet-4.6'), + configSchema: { + type: 'object', + properties: { reasoningEffort: { type: 'string', title: 'Reasoning effort', enum: ['minimal', 'xhigh'] } }, + }, + }, + { + ...makeModel('gpt-5'), + configSchema: { + type: 'object', + properties: { reasoningEffort: { type: 'string', title: 'Reasoning effort', enum: ['minimal'], enumLabels: ['Host Wins'], enumDescriptions: ['Host description'] } }, + }, + }, + { + // Labels but no descriptions: the producer described its values by omission, so + // neither half is replaced. + ...makeModel('gemini-3-pro'), + configSchema: { + type: 'object', + properties: { reasoningEffort: { type: 'string', title: 'Reasoning effort', enum: ['minimal'], enumLabels: ['Host Wins'] } }, + }, + }, + { + // Non-string values cannot be labelled as effort levels, so they are left alone. + ...makeModel('numeric'), + configSchema: { + type: 'object', + properties: { reasoningEffort: { type: 'number', title: 'Reasoning effort', enum: [1, 2] } }, + }, + }, + ]); + + const infos = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + assert.deepStrictEqual( + infos.map(info => { + const property = info.metadata.configurationSchema?.properties?.reasoningEffort; + return { id: info.metadata.id, labels: property?.enumItemLabels, descriptions: property?.enumDescriptions, default: property?.default }; + }), + [ + { + id: 'claude-sonnet-4.6', + labels: ['Minimal', 'Extra High'], + descriptions: ['Minimal reasoning for fastest responses', 'Highest reasoning depth but slowest'], + // No default is invented: schema defaults are sent, and the host expects the + // value omitted so the backend can choose. + default: undefined, + }, + { id: 'gpt-5', labels: ['Host Wins'], descriptions: ['Host description'], default: undefined }, + { id: 'gemini-3-pro', labels: ['Host Wins'], descriptions: undefined, default: undefined }, + { id: 'numeric', labels: undefined, descriptions: undefined, default: undefined }, + ] + ); + }); + test('renders the auto-mode discount as the Auto model detail (and a tooltip)', async () => { const provider = createProvider(); provider.updateModels([makeModel('auto', { discountPercent: 10 }), makeModel('gpt-5')]); @@ -137,6 +221,156 @@ suite('AgentHostLanguageModelProvider', () => { ]); }); + test('reads the capability category from the namespaced key the Copilot agent host uses', async () => { + const provider = createProvider(); + provider.updateModels([ + makeModel('claude-sonnet-4.6', { 'copilot.modelPickerCategory': 'powerful' }), + // A flat key still wins, so a host publishing both is not overridden. + makeModel('gpt-5', { category: 'versatile', 'copilot.modelPickerCategory': 'powerful' }), + makeModel('gemini', { 'copilot.modelPickerCategory': 42 }), + ]); + + const infos = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + assert.deepStrictEqual( + infos.map(info => ({ id: info.metadata.id, category: info.metadata.category })), + [ + { id: 'claude-sonnet-4.6', category: 'powerful' }, + { id: 'gpt-5', category: 'versatile' }, + { id: 'gemini', category: undefined }, + ] + ); + }); + + /** A catalogue stub standing in for the workbench's CAPI-backed Copilot models. */ + function catalogue(models: readonly { id: string; maxInputTokens?: number; maxOutputTokens?: number; multiplierNumeric?: number; category?: string; contextSizes?: number[]; vendor?: string }[]) { + const onDidChange = store.add(new Emitter()); + const byIdentifier = new Map(models.map(model => [ + `catalogue:${model.vendor ?? 'copilot'}:${model.id}`, + upcastPartial({ + id: model.id, + name: model.id, + vendor: model.vendor ?? 'copilot', + maxInputTokens: model.maxInputTokens, + maxOutputTokens: model.maxOutputTokens, + multiplierNumeric: model.multiplierNumeric, + category: model.category, + ...(model.contextSizes ? { + configurationSchema: { properties: { contextSize: { type: 'number', enum: model.contextSizes } } }, + } : {}), + }), + ])); + return { + fire: (vendor: string = 'copilot') => onDidChange.fire(vendor), + catalogue: { + getLanguageModelIds: () => [...byIdentifier.keys()], + lookupLanguageModel: (identifier: string) => byIdentifier.get(identifier), + onDidChangeLanguageModels: onDidChange.event, + }, + }; + } + + test('labels the host context tiers with the token counts from the workbench catalogue', async () => { + // The host names its tiers because the SDK gives it no per-model windows. The workbench + // already knows them, so the picker shows the numbers while the wire value stays the tier. + const { catalogue: known } = catalogue([{ id: 'claude-opus-5', contextSizes: [264_000, 1_000_000] }]); + const provider = store.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'copilot', known)); + provider.updateModels([ + { + ...makeModel('claude-opus-5'), + provider: 'copilot', + configSchema: { + type: 'object', + properties: { contextTier: { type: 'string', title: 'Context tier', enum: ['default', 'long_context'], enumLabels: ['Default', 'Long context'], default: 'default' } }, + }, + }, + ]); + + const infos = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + const tier = infos[0].metadata.configurationSchema?.properties?.contextTier; + assert.deepStrictEqual( + { enum: tier?.enum, labels: tier?.enumItemLabels, group: tier?.group }, + { enum: ['default', 'long_context'], labels: ['264K', '1M'], group: 'tokens' } + ); + }); + + test('drops the context tier when there is no distinct long-context window to choose', async () => { + // Matches how the GitHub desktop app suppresses the picker: an unknown model, or one whose + // tiers are the same size, offers a choice the user cannot act on. + const { catalogue: known } = catalogue([{ id: 'known-single-tier', contextSizes: [200_000] }]); + const provider = store.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'copilot', known)); + const contextTierOnly = { + type: 'object' as const, + properties: { contextTier: { type: 'string' as const, title: 'Context tier', enum: ['default', 'long_context'] } }, + }; + provider.updateModels([ + { ...makeModel('known-single-tier'), provider: 'copilot', configSchema: contextTierOnly }, + { ...makeModel('unknown-to-catalogue'), provider: 'copilot', configSchema: contextTierOnly }, + ]); + + const infos = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + assert.deepStrictEqual( + infos.map(info => ({ id: info.metadata.id, properties: Object.keys(info.metadata.configurationSchema?.properties ?? {}) })), + [ + { id: 'known-single-tier', properties: [] }, + { id: 'unknown-to-catalogue', properties: [] }, + ] + ); + }); + + test('fills token counts and pricing from the catalogue, but never over the host', async () => { + const { catalogue: known } = catalogue([ + { id: 'claude-opus-5', maxInputTokens: 264_000, maxOutputTokens: 64_000, multiplierNumeric: 5, category: 'powerful' }, + { id: 'host-wins', maxInputTokens: 111, multiplierNumeric: 9, category: 'lightweight' }, + // A model reached over a direct third-party transport must not take Copilot's prices. + { id: 'claude-opus-5', vendor: 'anthropic', maxInputTokens: 999, multiplierNumeric: 42 }, + ]); + const provider = store.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'copilot', known)); + provider.updateModels([ + { ...makeModel('claude-opus-5'), provider: 'copilot' }, + { ...makeModel('host-wins'), provider: 'copilot', maxPromptTokens: 222, _meta: { multiplierNumeric: 1, category: 'versatile' } }, + { ...makeModel('claude-opus-5'), provider: 'anthropic', _meta: { modelGroupId: 'anthropic' } }, + ]); + + const infos = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + assert.deepStrictEqual( + infos.map(info => ({ + group: info.metadata.modelGroup?.id, + maxInputTokens: info.metadata.maxInputTokens, + maxOutputTokens: info.metadata.maxOutputTokens, + multiplierNumeric: info.metadata.multiplierNumeric, + category: info.metadata.category, + })), + [ + { group: 'copilot', maxInputTokens: 264_000, maxOutputTokens: 64_000, multiplierNumeric: 5, category: 'powerful' }, + { group: 'copilot', maxInputTokens: 222, maxOutputTokens: 0, multiplierNumeric: 1, category: 'versatile' }, + { group: 'anthropic', maxInputTokens: 0, maxOutputTokens: 0, multiplierNumeric: undefined, category: undefined }, + ] + ); + }); + + test('republishes on a catalogue change, ignoring changes from other vendors', async () => { + // The service fires this event for every provider that publishes, including this one. Its + // own vendor is a session-type id, so scoping to the enriched-from vendor also breaks the + // loop — while still picking up a later CAPI refresh of prices or windows. + const { catalogue: known, fire } = catalogue([{ id: 'claude-opus-5', contextSizes: [264_000, 1_000_000] }]); + const provider = store.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot', known)); + let changes = 0; + store.add(provider.onDidChange(() => changes++)); + + fire('agent-host-copilot'); + const afterOwnVendor = changes; + fire('copilot'); + const afterCatalogue = changes; + fire('copilot'); + + assert.deepStrictEqual( + { afterOwnVendor, afterCatalogue, afterSecondCatalogueChange: changes }, + // Every catalogue change republishes: a price refresh that leaves ids and windows + // untouched still has to reach the picker. + { afterOwnVendor: 0, afterCatalogue: 1, afterSecondCatalogueChange: 2 } + ); + }); + test('carries model notices and flags row warnings', async () => { const provider = createProvider(); provider.updateModels([makeModel('gpt-5', { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index b36d0e1d72e88e..f7c854e3e7712a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -543,7 +543,10 @@ suite('stateToProgressAdapter', () => { responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: createCompletedToolCall({ toolInput: '{"query":"terminal activation"}', - content: [{ type: ToolResultContentType.Text, text: 'Use shell integration.' }], + content: [ + { type: ToolResultContentType.Text, text: ' \n{"matches":1}' }, + { type: ToolResultContentType.Text, text: 'Use shell integration.' }, + ], }) } as ToolCallResponsePart], }); @@ -558,7 +561,10 @@ suite('stateToProgressAdapter', () => { assertInputOutputDetails(details); assert.strictEqual(details.input, '{"query":"terminal activation"}'); assert.strictEqual(details.inputLanguage, 'json'); - assert.deepStrictEqual(details.output, [{ type: 'embed', value: 'Use shell integration.', isText: true, mimeType: 'text/plain' }]); + assert.deepStrictEqual(details.output, [ + { type: 'embed', value: ' \n{"matches":1}', isText: true, mimeType: 'application/json' }, + { type: 'embed', value: 'Use shell integration.', isText: true, mimeType: 'text/plain' }, + ]); assert.strictEqual(details.isError, false); }); @@ -1543,11 +1549,80 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(invocation.invocationMessage, 'Running shell command'); }); + test('renders the terminal confirmation for a remote host command permission (no _meta.toolKind)', () => { + // A remote host describes a pending command approval by echoing the + // permission request rather than setting `_meta.toolKind`. + const tc: ToolCallPendingConfirmationState = { + toolCallId: 'tc-perm', + toolName: 'shell', + displayName: 'Shell', + invocationMessage: 'Find copilot CLI sandbox builder', + status: ToolCallStatus.PendingConfirmation, + confirmationTitle: 'Run command', + toolInput: 'rg -n "sandbox" --glob "*.ts"', + _meta: { + requestId: 'req-1', + promptRequest: { kind: 'commands', toolCallId: 'tc-perm' }, + permissionRequest: { kind: 'shell', toolCallId: 'tc-perm' }, + }, + }; + + const invocation = toolCallStateToInvocation(tc); + assert.deepStrictEqual({ + kind: invocation.toolSpecificData?.kind, + command: (invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined)?.commandLine.original, + language: (invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined)?.language, + }, { + kind: 'terminal', + command: 'rg -n "sandbox" --glob "*.ts"', + language: 'shellscript', + }); + }); + + test('falls back to the raw permission request when the projected one is absent', () => { + // Older hosts echo only `permissionRequest`, spelling the same + // decision `shell` rather than `commands`. + const tc: ToolCallPendingConfirmationState = { + toolCallId: 'tc-perm-raw', + toolName: 'shell', + displayName: 'Shell', + invocationMessage: 'Check the build', + status: ToolCallStatus.PendingConfirmation, + toolInput: 'npm run compile', + _meta: { requestId: 'req-2', permissionRequest: { kind: 'shell' } }, + }; + + const invocation = toolCallStateToInvocation(tc); + assert.deepStrictEqual({ + kind: invocation.toolSpecificData?.kind, + command: (invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined)?.commandLine.original, + }, { + kind: 'terminal', + command: 'npm run compile', + }); + }); + + test('does not render a path permission as a terminal command', () => { + // A path request's subject is a list of paths, not a command line, + // even when its `accessKind` is `shell`. + const tc: ToolCallPendingConfirmationState = { + toolCallId: 'tc-perm-path', + toolName: 'shell', + displayName: 'Shell', + invocationMessage: 'Access paths', + status: ToolCallStatus.PendingConfirmation, + toolInput: '/a/one.ts, /a/two.ts', + _meta: { requestId: 'req-3', promptRequest: { kind: 'path', accessKind: 'shell' } }, + }; + + const invocation = toolCallStateToInvocation(tc); + assert.strictEqual(invocation.toolSpecificData?.kind, 'input'); + }); + test('sets subagent toolSpecificData from _meta for subagent toolKind', () => { const tc = createToolCallState({ _meta: { toolKind: 'subagent', subagentDescription: 'Review code', subagentAgentName: 'code-reviewer' }, }); - const invocation = toolCallStateToInvocation(tc); assert.ok(invocation.toolSpecificData); assert.strictEqual(invocation.toolSpecificData.kind, 'subagent'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts index e2d28255645ea8..90c0527c690850 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/syncedCustomizationBundler.test.ts @@ -4,26 +4,67 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import sinon from 'sinon'; +import { timeout } from '../../../../../../base/common/async.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ResourceSet } from '../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { URI } from '../../../../../../base/common/uri.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { FileService } from '../../../../../../platform/files/common/fileService.js'; +import { FileType, IFileService, IStat } from '../../../../../../platform/files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { McpServerType, type IMcpServerConfiguration } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { CustomizationEnablementKind } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { type ISyncableMcpServer, SyncedCustomizationBundler } from '../../../browser/agentSessions/agentHost/syncedCustomizationBundler.js'; +import { type ISyncableMcpServer, type ISyncedCustomizationOrigin, SyncedCustomizationBundler } from '../../../browser/agentSessions/agentHost/syncedCustomizationBundler.js'; import { IAgentHostFileSystemService, SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { IFileService } from '../../../../../../platform/files/common/files.js'; + +class TestInMemoryFileSystemProvider extends InMemoryFileSystemProvider { + private readonly symbolicLinks = new ResourceSet(); + private readonly statFailures = new ResourceSet(); + private statDelay = 0; + private activeStats = 0; + maxActiveStats = 0; + + markSymbolicLink(resource: URI): void { + this.symbolicLinks.add(resource); + } + + delayStats(delay: number): void { + this.statDelay = delay; + } + + failStat(resource: URI): void { + this.statFailures.add(resource); + } + + override async stat(resource: URI): Promise { + this.activeStats++; + this.maxActiveStats = Math.max(this.maxActiveStats, this.activeStats); + try { + if (this.statDelay > 0) { + await timeout(this.statDelay); + } + if (this.statFailures.has(resource)) { + throw new Error('Unavailable test resource'); + } + const stat = await super.stat(resource); + return this.symbolicLinks.has(resource) ? { ...stat, type: stat.type | FileType.SymbolicLink } : stat; + } finally { + this.activeStats--; + } + } +} suite('SyncedCustomizationBundler', () => { const disposables = new DisposableStore(); let fileService: FileService; + let memFs: TestInMemoryFileSystemProvider; let instantiationService: TestInstantiationService; const enabledMcpServer = (name: string, configuration: IMcpServerConfiguration): ISyncableMcpServer => ({ @@ -34,7 +75,7 @@ suite('SyncedCustomizationBundler', () => { setup(() => { fileService = disposables.add(new FileService(new NullLogService())); - const memFs = disposables.add(new InMemoryFileSystemProvider()); + memFs = disposables.add(new TestInMemoryFileSystemProvider()); disposables.add(fileService.registerProvider(Schemas.inMemory, memFs)); // Register the synced-customization scheme via a mock service @@ -48,6 +89,7 @@ suite('SyncedCustomizationBundler', () => { }); teardown(() => { + sinon.restore(); disposables.clear(); }); ensureNoDisposablesAreLeakedInTestSuite(); @@ -62,6 +104,20 @@ suite('SyncedCustomizationBundler', () => { return uri; } + async function seedBinaryFile(path: string, content: number[]): Promise { + const uri = URI.from({ scheme: Schemas.inMemory, path }); + await fileService.writeFile(uri, VSBuffer.fromByteArray(content)); + return uri; + } + + function serializeOrigin(origin: ISyncedCustomizationOrigin | undefined) { + return origin && { + ...origin, + uri: origin.uri.toString(), + pluginUri: origin.pluginUri?.toString(), + }; + } + test('returns undefined for empty file list', async () => { const bundler = createBundler(); const result = await bundler.bundle([]); @@ -146,6 +202,169 @@ suite('SyncedCustomizationBundler', () => { assert.strictEqual(contentC.value.toString(), 'skill C content'); }); + test('bundles complete SKILL.md directories', async () => { + const bundler = createBundler(); + const skill = await seedFile('/skills/my-skill/SKILL.md', 'skill content'); + await seedFile('/skills/my-skill/references/notes.md', 'reference content'); + await seedFile('/skills/my-skill/scripts/run.sh', 'script content'); + await seedFile('/skills/my-skill/assets/templates/default.txt', 'template content'); + await seedFile('/skills/my-skill/.git/config', 'git metadata'); + await seedFile('/skills/my-skill/node_modules/dependency/index.js', 'dependency content'); + await seedFile('/skills/outside.md', 'outside content'); + + const result = await bundler.bundle([{ uri: skill, type: PromptsType.skill }]); + assert.ok(result); + + const referenceUri = URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/skills/my-skill/references/notes.md' }); + const reference = await fileService.readFile(referenceUri); + const script = await fileService.readFile(URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/skills/my-skill/scripts/run.sh' })); + const template = await fileService.readFile(URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/skills/my-skill/assets/templates/default.txt' })); + assert.deepStrictEqual({ + reference: reference.value.toString(), + script: script.value.toString(), + template: template.value.toString(), + gitMetadataExists: await fileService.exists(URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/skills/my-skill/.git/config' })), + nodeModuleExists: await fileService.exists(URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/skills/my-skill/node_modules/dependency/index.js' })), + outsideExists: await fileService.exists(URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/skills/outside.md' })), + }, { + reference: 'reference content', + script: 'script content', + template: 'template content', + gitMetadataExists: false, + nodeModuleExists: false, + outsideExists: false, + }); + + await fileService.writeFile(URI.from({ scheme: Schemas.inMemory, path: '/skills/my-skill/references/notes.md' }), VSBuffer.fromString('updated reference')); + const updatedResult = await bundler.bundle([{ uri: skill, type: PromptsType.skill }]); + assert.notStrictEqual(updatedResult!.ref.nonce, result.ref.nonce); + assert.strictEqual((await fileService.readFile(referenceUri)).value.toString(), 'updated reference'); + }); + + test('excludes worktree metadata files from skill directories', async () => { + const bundler = createBundler(); + const skill = await seedFile('/skills/worktree/SKILL.md', 'skill content'); + await seedFile('/skills/worktree/.git', 'gitdir: ../../.git/worktrees/worktree'); + + await bundler.bundle([{ uri: skill, type: PromptsType.skill }]); + + assert.strictEqual(await fileService.exists(URI.from({ + scheme: SYNCED_CUSTOMIZATION_SCHEME, + path: '/test-agent/skills/worktree/.git', + })), false); + }); + + test('includes a symlinked SKILL.md entrypoint but excludes nested symlinks', async () => { + const bundler = createBundler(); + const skill = await seedFile('/skills/symlinks/SKILL.md', 'skill content'); + const nested = await seedFile('/skills/symlinks/references/linked.md', 'linked content'); + memFs.markSymbolicLink(skill); + memFs.markSymbolicLink(nested); + + await bundler.bundle([{ uri: skill, type: PromptsType.skill }]); + + const skillDestination = URI.from({ + scheme: SYNCED_CUSTOMIZATION_SCHEME, + path: '/test-agent/skills/symlinks/SKILL.md', + }); + const nestedDestination = URI.from({ + scheme: SYNCED_CUSTOMIZATION_SCHEME, + path: '/test-agent/skills/symlinks/references/linked.md', + }); + assert.deepStrictEqual({ + skill: (await fileService.readFile(skillDestination)).value.toString(), + nestedExists: await fileService.exists(nestedDestination), + }, { + skill: 'skill content', + nestedExists: false, + }); + }); + + test('rebuilds when nested skill resources are added or removed', async () => { + const bundler = createBundler(); + const skill = await seedFile('/skills/evolving/SKILL.md', 'skill content'); + const first = await bundler.bundle([{ uri: skill, type: PromptsType.skill }]); + + const added = await seedFile('/skills/evolving/references/notes.md', 'reference content'); + const second = await bundler.bundle([{ uri: skill, type: PromptsType.skill }]); + const destination = URI.from({ + scheme: SYNCED_CUSTOMIZATION_SCHEME, + path: '/test-agent/skills/evolving/references/notes.md', + }); + + await fileService.del(added); + const third = await bundler.bundle([{ uri: skill, type: PromptsType.skill }]); + + assert.deepStrictEqual({ + addedNonceChanged: second!.ref.nonce !== first!.ref.nonce, + removedNonceChanged: third!.ref.nonce !== second!.ref.nonce, + destinationExists: await fileService.exists(destination), + }, { + addedNonceChanged: true, + removedNonceChanged: true, + destinationExists: false, + }); + }); + + test('limits concurrent skill filesystem operations', async () => { + const bundler = createBundler(); + const skill = await seedFile('/skills/wide/SKILL.md', 'skill content'); + for (let index = 0; index < 20; index++) { + await seedFile(`/skills/wide/references/${index}.md`, `reference ${index}`); + } + memFs.delayStats(5); + + await bundler.bundle([{ uri: skill, type: PromptsType.skill }]); + + assert.strictEqual(memFs.maxActiveStats, 10); + }); + + test('skips unreadable nested skill resources', async () => { + const bundler = createBundler(); + const skill = await seedFile('/skills/unreadable/SKILL.md', 'skill content'); + const unavailable = await seedFile('/skills/unreadable/references/unavailable.md', 'unavailable content'); + await seedFile('/skills/unreadable/references/available.md', 'available content'); + memFs.failStat(unavailable); + + await bundler.bundle([{ uri: skill, type: PromptsType.skill }]); + + assert.deepStrictEqual({ + skill: (await fileService.readFile(URI.from({ + scheme: SYNCED_CUSTOMIZATION_SCHEME, + path: '/test-agent/skills/unreadable/SKILL.md', + }))).value.toString(), + available: (await fileService.readFile(URI.from({ + scheme: SYNCED_CUSTOMIZATION_SCHEME, + path: '/test-agent/skills/unreadable/references/available.md', + }))).value.toString(), + unavailableExists: await fileService.exists(URI.from({ + scheme: SYNCED_CUSTOMIZATION_SCHEME, + path: '/test-agent/skills/unreadable/references/unavailable.md', + })), + }, { + skill: 'skill content', + available: 'available content', + unavailableExists: false, + }); + }); + + test('bundles binary skill resources and invalidates the nonce when metadata changes', async () => { + const bundler = createBundler(); + const skill = await seedFile('/skills/binary/SKILL.md', 'skill content'); + const binary = await seedBinaryFile('/skills/binary/assets/data.bin', [0x80]); + + const result = await bundler.bundle([{ uri: skill, type: PromptsType.skill }]); + assert.ok(result); + + const binaryDest = URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/skills/binary/assets/data.bin' }); + assert.deepStrictEqual([...((await fileService.readFile(binaryDest)).value.buffer)], [0x80]); + + await fileService.writeFile(binary, VSBuffer.fromByteArray([0x81, 0x82])); + const updatedResult = await bundler.bundle([{ uri: skill, type: PromptsType.skill }]); + assert.notStrictEqual(updatedResult!.ref.nonce, result.ref.nonce); + assert.deepStrictEqual([...((await fileService.readFile(binaryDest)).value.buffer)], [0x81, 0x82]); + }); + test('writes plugin manifest', async () => { const bundler = createBundler(); const uri = await seedFile('/test/file.md', 'content'); @@ -158,7 +377,7 @@ suite('SyncedCustomizationBundler', () => { assert.strictEqual(parsed.name, 'VS Code Synced Data'); }); - test('nonce is stable for same content', async () => { + test('nonce is stable when file metadata is unchanged', async () => { const bundler = createBundler(); const uri = await seedFile('/test/stable.md', 'same content'); @@ -167,12 +386,12 @@ suite('SyncedCustomizationBundler', () => { assert.strictEqual(result1!.ref.nonce, result2!.ref.nonce); }); - test('nonce changes when content changes', async () => { + test('nonce changes when file metadata changes', async () => { const bundler = createBundler(); const uri = await seedFile('/test/changing.md', 'v1'); const result1 = await bundler.bundle([{ uri, type: PromptsType.instructions }]); - await fileService.writeFile(uri, VSBuffer.fromString('v2')); + await fileService.writeFile(uri, VSBuffer.fromString('version 2')); const result2 = await bundler.bundle([{ uri, type: PromptsType.instructions }]); assert.notStrictEqual(result1!.ref.nonce, result2!.ref.nonce); }); @@ -306,9 +525,10 @@ suite('SyncedCustomizationBundler', () => { assert.strictEqual(newContent.value.toString(), 'second version'); }); - test('unchanged rebundle reuses the previous result without touching the tree', async () => { + test('unchanged rebundle reuses the previous result without reading files or touching the tree', async () => { const bundler = createBundler(); const uri = await seedFile('/test/stable.md', 'unchanged content'); + const readFile = sinon.spy(fileService, 'readFile'); const result1 = await bundler.bundle([{ uri, type: PromptsType.instructions }]); assert.ok(result1); @@ -317,12 +537,15 @@ suite('SyncedCustomizationBundler', () => { // a skipped rebundle leaves it untouched. const sentinel = URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/sentinel.txt' }); await fileService.writeFile(sentinel, VSBuffer.fromString('keep me')); + readFile.resetHistory(); const result2 = await bundler.bundle([{ uri, type: PromptsType.instructions }]); + const sourceReads = readFile.callCount; // The exact same result object is returned and the sentinel survives, // proving the delete + rewrite was skipped. assert.strictEqual(result2, result1); + assert.strictEqual(sourceReads, 0); const survived = await fileService.readFile(sentinel); assert.strictEqual(survived.value.toString(), 'keep me'); }); @@ -338,7 +561,7 @@ suite('SyncedCustomizationBundler', () => { const sentinel = URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/sentinel.txt' }); await fileService.writeFile(sentinel, VSBuffer.fromString('remove me')); - await fileService.writeFile(uri, VSBuffer.fromString('v2')); + await fileService.writeFile(uri, VSBuffer.fromString('version 2')); const result2 = await bundler.bundle([{ uri, type: PromptsType.instructions }]); // A fresh result is produced and the sentinel is gone. @@ -381,13 +604,13 @@ suite('SyncedCustomizationBundler', () => { // A change after a reused rebundle must still trigger a rebuild — the // reuse path must not poison the cached nonce/result. - await fileService.writeFile(uri, VSBuffer.fromString('v2')); + await fileService.writeFile(uri, VSBuffer.fromString('version 2')); const result3 = await bundler.bundle([{ uri, type: PromptsType.instructions }]); assert.notStrictEqual(result3, result1); assert.notStrictEqual(result3!.ref.nonce, result1!.ref.nonce); const written = await fileService.readFile(URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/rules/evolving.md' })); - assert.strictEqual(written.value.toString(), 'v2'); + assert.strictEqual(written.value.toString(), 'version 2'); }); test('lastNonce is unchanged after a reused rebundle', async () => { @@ -517,15 +740,17 @@ suite('SyncedCustomizationBundler', () => { const bundler = createBundler(); const extUri = await seedFile('/ext/rule.md', 'ext rule'); const skillMd = await seedFile('/plugins/my-skill/SKILL.md', '# skill'); + const skillReference = await seedFile('/plugins/my-skill/references/notes.md', '# reference'); + const pluginUri = URI.from({ scheme: Schemas.inMemory, path: '/plugins/my-skill' }); await bundler.bundle([ { uri: extUri, type: PromptsType.instructions, source: 'extension', extensionId: 'pub.ext' }, - { uri: skillMd, type: PromptsType.skill, source: 'plugin', pluginUri: URI.from({ scheme: Schemas.inMemory, path: '/plugins/my-skill' }) }, + { uri: skillMd, type: PromptsType.skill, source: 'plugin', pluginUri }, ]); const ruleDest = URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/rules/rule.md' }); - assert.deepStrictEqual(bundler.getOrigin(ruleDest), { - uri: extUri, + assert.deepStrictEqual(serializeOrigin(bundler.getOrigin(ruleDest)), { + uri: extUri.toString(), source: 'extension', extensionId: 'pub.ext', pluginUri: undefined, @@ -533,11 +758,19 @@ suite('SyncedCustomizationBundler', () => { // Skills preserve their directory: skills/{skillName}/SKILL.md. const skillDest = URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/skills/my-skill/SKILL.md' }); - assert.deepStrictEqual(bundler.getOrigin(skillDest), { - uri: skillMd, + assert.deepStrictEqual(serializeOrigin(bundler.getOrigin(skillDest)), { + uri: skillMd.toString(), + source: 'plugin', + extensionId: undefined, + pluginUri: pluginUri.toString(), + }); + + const skillReferenceDest = URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/skills/my-skill/references/notes.md' }); + assert.deepStrictEqual(serializeOrigin(bundler.getOrigin(skillReferenceDest)), { + uri: skillReference.toString(), source: 'plugin', extensionId: undefined, - pluginUri: URI.from({ scheme: Schemas.inMemory, path: '/plugins/my-skill' }), + pluginUri: pluginUri.toString(), }); assert.strictEqual(bundler.getOrigin(URI.from({ scheme: SYNCED_CUSTOMIZATION_SCHEME, path: '/test-agent/rules/unknown.md' })), undefined); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationCreatorService.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationCreatorService.test.ts index 0b5d81754fb01b..7d08e6f7fa6a29 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationCreatorService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/customizationCreatorService.test.ts @@ -4,11 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Event } from '../../../../../../base/common/event.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 { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { IQuickInputService } from '../../../../../../platform/quickinput/common/quickInput.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; -import { resolveUserTargetDirectory } from '../../../browser/aiCustomization/customizationCreatorService.js'; +import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; +import { CustomizationLocationPicker, resolveUserTargetDirectory } from '../../../browser/aiCustomization/customizationCreatorService.js'; suite('customizationCreatorService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -42,4 +49,42 @@ suite('customizationCreatorService', () => { assert.strictEqual(result, undefined); }); }); + + test('skips the picker when only one target directory matches', async () => { + const sessionResource = URI.parse('test-harness:///session'); + const targetDirectory = URI.file('/workspace/.github/agents'); + const harnessService = new class extends mock() { + override findHarnessById(id: string): IHarnessDescriptor | undefined { + assert.strictEqual(id, 'test-harness'); + return { + id, + label: 'Test', + icon: Codicon.copilot, + itemProvider: { + onDidChange: Event.None, + provideChatSessionCustomizations: async () => [], + provideSourceFolders: async () => [ + { uri: targetDirectory, label: 'Workspace', source: PromptsStorage.local }, + { uri: URI.file('/user/agents'), label: 'User', source: PromptsStorage.user }, + ], + }, + }; + } + }(); + const quickInputService = new class extends mock() { + override pick(): Promise { + throw new Error('The picker should not be shown'); + } + }(); + const picker = new CustomizationLocationPicker( + quickInputService, + harnessService, + new class extends mock() { }(), + new class extends mock() { }(), + ); + + const result = await picker.resolveTargetDirectoryWithPicker(sessionResource, PromptsType.agent, 'local'); + + assert.strictEqual(result, targetDirectory); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolInputOutputContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolInputOutputContentPart.test.ts index ccc1c2e2f70669..c15bf5243d9d43 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolInputOutputContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolInputOutputContentPart.test.ts @@ -10,6 +10,8 @@ import { observableValue } from '../../../../../../../base/common/observable.js' import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { IChatToolInvocationSerialized, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; +import { ToolDataSource } from '../../../../common/tools/languageModelToolsService.js'; import { CodeBlockPart } from '../../../../browser/widget/chatContentParts/codeBlockPart.js'; import { ChatCollapsibleContentPart } from '../../../../browser/widget/chatContentParts/chatCollapsibleContentPart.js'; import { IDisposableReference } from '../../../../browser/widget/chatContentParts/chatCollections.js'; @@ -17,6 +19,7 @@ import { DiffEditorPool, EditorPool } from '../../../../browser/widget/chatConte import { IChatContentPartRenderContext, InlineTextModelCollection } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { ChatCollapsibleInputOutputContentPart } from '../../../../browser/widget/chatContentParts/chatToolInputOutputContentPart.js'; import { ChatToolOutputContentSubPart } from '../../../../browser/widget/chatContentParts/chatToolOutputContentSubPart.js'; +import { ChatInputOutputMarkdownProgressPart } from '../../../../browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.js'; import { IChatResponseViewModel } from '../../../../common/model/chatViewModel.js'; suite('ChatCollapsibleInputOutputContentPart', () => { @@ -188,4 +191,86 @@ suite('ChatCollapsibleInputOutputContentPart', () => { renderedTexts: ['First result', 'Second result'], }); }); + + test('uses output MIME types and defaults to plaintext', () => { + const renderedCodeBlocks: { text: string; languageId: string }[] = []; + const editorPool = Object.create(EditorPool.prototype) as EditorPool; + Object.defineProperty(editorPool, 'get', { + value: () => { + const codeBlockPart = Object.create(CodeBlockPart.prototype) as CodeBlockPart; + Object.defineProperties(codeBlockPart, { + element: { value: mainWindow.document.createElement('div') }, + render: { value: (data: { text: string; languageId: string }) => renderedCodeBlocks.push({ text: data.text, languageId: data.languageId }) }, + layout: { value: () => { } }, + uri: { value: URI.parse('test://codeblock') }, + }); + return { + object: codeBlockPart, + isStale: () => false, + dispose: () => { }, + } satisfies IDisposableReference; + } + }); + const element = Object.assign(Object.create(null), { + id: 'response', + sessionResource: URI.parse('chat-session://test/session'), + }) as IChatResponseViewModel; + const context: IChatContentPartRenderContext = { + element, + elementIndex: 0, + container: mainWindow.document.createElement('div'), + content: [], + contentIndex: 0, + inlineTextModels: Object.create(InlineTextModelCollection.prototype) as InlineTextModelCollection, + editorPool, + codeBlockStartIndex: 0, + treeStartIndex: 0, + diffEditorPool: Object.create(DiffEditorPool.prototype) as DiffEditorPool, + currentWidth: observableValue('testWidth', 500), + onDidChangeVisibility: Event.None, + }; + const toolInvocation: IChatToolInvocationSerialized = { + kind: 'toolInvocationSerialized', + toolCallId: 'tool-call-id', + toolId: 'test-tool', + invocationMessage: 'Running tool', + originMessage: undefined, + pastTenseMessage: 'Ran tool', + isComplete: true, + isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, + presentation: undefined, + source: ToolDataSource.Internal, + }; + const instantiationService = workbenchInstantiationService(undefined, store); + const part = store.add(instantiationService.createInstance( + ChatInputOutputMarkdownProgressPart, + toolInvocation, + context, + 0, + 'Ran tool', + undefined, + '{"query":"test"}', + undefined, + [ + { type: 'embed', value: '# Heading', isText: true, mimeType: ' Text/Markdown ; charset=utf-8' }, + { type: 'embed', value: '{"declared":true}', isText: true, mimeType: 'text/plain' }, + { type: 'embed', value: 'invalid JSON', isText: true, mimeType: 'application/problem+json' }, + { type: 'embed', value: '{"detected":true}', isText: true }, + { type: 'embed', value: '[1, 2, 3]', isText: true }, + { type: 'embed', value: 'ordinary output', isText: true }, + { type: 'embed', value: '1', isText: true }, + ], + false, + )); + + part.domNode.querySelector('.chat-confirmation-widget-title')?.click(); + + assert.deepStrictEqual(renderedCodeBlocks, [ + { text: '{"query":"test"}', languageId: 'json' }, + { text: '# Heading', languageId: 'markdown' }, + { text: '{"declared":true}', languageId: 'plaintext' }, + { text: 'invalid JSON', languageId: 'json' }, + { text: '{"detected":true}\n[1, 2, 3]\nordinary output\n1', languageId: 'plaintext' }, + ]); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/electron-browser/toggleRemoteConnectionsActionViewItem.test.ts b/src/vs/workbench/contrib/chat/test/electron-browser/toggleRemoteConnectionsActionViewItem.test.ts new file mode 100644 index 00000000000000..14611af405e65e --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/electron-browser/toggleRemoteConnectionsActionViewItem.test.ts @@ -0,0 +1,271 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Action } from '../../../../../base/common/actions.js'; +import { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ConfigurationTarget, IConfigurationOverrides, IConfigurationService, IConfigurationUpdateOverrides } from '../../../../../platform/configuration/common/configuration.js'; +import { NullHoverService } from '../../../../../platform/hover/test/browser/nullHoverService.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IInputOptions, IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { CONFIGURATION_KEY_HOST_NAME, INACTIVE_TUNNEL_MODE, IRemoteTunnelService, type ActiveTunnelMode, type TunnelMode, type TunnelStatus } from '../../../../../platform/remoteTunnel/common/remoteTunnel.js'; +import { getRemoteTunnelAccessState, ToggleRemoteConnectionsActionViewItem } from '../../electron-browser/toggleRemoteConnectionsActionViewItem.js'; +import { executeToggleRemoteConnections } from '../../electron-browser/tunnelHost.contribution.js'; +import { promptToRenameRemoteTunnel } from '../../../remoteTunnel/electron-browser/remoteTunnel.contribution.js'; + +class TestRemoteTunnelService extends mock() { + mode: TunnelMode = INACTIVE_TUNNEL_MODE; + status: TunnelStatus = { type: 'disconnected' }; + private readonly _onDidChangeMode = new Emitter(); + override readonly onDidChangeMode = this._onDidChangeMode.event; + private readonly _onDidChangeTunnelStatus = new Emitter(); + override readonly onDidChangeTunnelStatus = this._onDidChangeTunnelStatus.event; + private readonly _initialMode = new DeferredPromise(); + private readonly _initialStatus = new DeferredPromise(); + private _deferInitialState = false; + + override getMode(): Promise { + return this._deferInitialState ? this._initialMode.p : Promise.resolve(this.mode); + } + + override getTunnelStatus(): Promise { + return this._deferInitialState ? this._initialStatus.p : Promise.resolve(this.status); + } + + deferInitialState(): void { + this._deferInitialState = true; + } + + completeInitialState(mode: TunnelMode, status: TunnelStatus): void { + this._initialMode.complete(mode); + this._initialStatus.complete(status); + } + + fireMode(mode: TunnelMode): void { + this.mode = mode; + this._onDidChangeMode.fire(mode); + } + + fireStatus(status: TunnelStatus): void { + this.status = status; + this._onDidChangeTunnelStatus.fire(status); + } + + dispose(): void { + this._onDidChangeMode.dispose(); + this._onDidChangeTunnelStatus.dispose(); + } +} + +class TestCommandService extends mock() { + readonly commands: Array<{ id: string; args: unknown[] }> = []; + + override executeCommand(id: string, ...args: unknown[]): Promise { + this.commands.push({ id, args }); + return Promise.resolve(undefined); + } +} + +class TestQuickInputService extends mock() { + result: string | undefined; + options: IInputOptions | undefined; + + override async input(options?: IInputOptions): Promise { + this.options = options; + return this.result; + } +} + +class TestConfigurationService extends mock() { + readonly updates: Array<{ key: string; value: unknown; target: ConfigurationTarget | undefined }> = []; + + override updateValue(key: string, value: unknown): Promise; + override updateValue(key: string, value: unknown, target: ConfigurationTarget): Promise; + override updateValue(key: string, value: unknown, overrides: IConfigurationOverrides | IConfigurationUpdateOverrides): Promise; + override updateValue(key: string, value: unknown, targetOrOverrides?: ConfigurationTarget | IConfigurationOverrides | IConfigurationUpdateOverrides): Promise { + this.updates.push({ key, value, target: typeof targetOrOverrides === 'number' ? targetOrOverrides : undefined }); + return Promise.resolve(); + } +} + +suite('ToggleRemoteConnectionsActionViewItem', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('derives unified access state from the authoritative remote tunnel state', () => { + const activeMode: ActiveTunnelMode = { + active: true, + asService: false, + session: { providerId: 'github', sessionId: 'session', accountLabel: 'Account' }, + }; + + assert.deepStrictEqual({ + disabled: getRemoteTunnelAccessState(INACTIVE_TUNNEL_MODE, { type: 'disconnected' }), + connecting: getRemoteTunnelAccessState(activeMode, { type: 'connecting' }), + connected: getRemoteTunnelAccessState(activeMode, { + type: 'connected', + info: { tunnelName: 'my-tunnel', isAttached: false }, + serviceInstallFailed: false, + }), + externallyHosted: getRemoteTunnelAccessState(INACTIVE_TUNNEL_MODE, { + type: 'connected', + info: { tunnelName: 'external-tunnel', isAttached: true }, + serviceInstallFailed: false, + }), + }, { + disabled: { isSharing: false, isConnecting: false, tunnelName: undefined }, + connecting: { isSharing: false, isConnecting: true, tunnelName: undefined }, + connected: { isSharing: true, isConnecting: false, tunnelName: 'my-tunnel' }, + externallyHosted: { isSharing: true, isConnecting: false, tunnelName: 'external-tunnel' }, + }); + }); + + test('does not announce an existing tunnel while initial state loads', async () => { + const testDisposables = store.add(new DisposableStore()); + const remoteTunnelService = testDisposables.add(new TestRemoteTunnelService()); + const activeMode: ActiveTunnelMode = { + active: true, + asService: false, + session: { providerId: 'github', sessionId: 'session', accountLabel: 'Account' }, + }; + const connectedStatus: TunnelStatus = { + type: 'connected', + info: { tunnelName: 'my-tunnel', isAttached: false }, + serviceInstallFailed: false, + }; + remoteTunnelService.deferInitialState(); + + const action = testDisposables.add(new Action('test.toggleRemoteConnections', 'Toggle Remote Connections')); + const viewItem = testDisposables.add(new ToggleRemoteConnectionsActionViewItem( + action, + remoteTunnelService, + NullHoverService, + new class extends mock() { }(), + )); + const container = document.createElement('div'); + viewItem.render(container); + + remoteTunnelService.fireMode(activeMode); + remoteTunnelService.fireStatus(connectedStatus); + remoteTunnelService.completeInitialState(INACTIVE_TUNNEL_MODE, { type: 'disconnected' }); + await timeout(0); + + const toast = container.querySelector('.tunnel-host-toast'); + assert.deepStrictEqual({ + sharing: container.classList.contains('sharing'), + toastVisible: toast?.classList.contains('visible') ?? false, + }, { + sharing: true, + toastVisible: false, + }); + + remoteTunnelService.fireStatus({ type: 'disconnected' }); + remoteTunnelService.fireStatus(connectedStatus); + + assert.strictEqual(toast?.classList.contains('visible'), true); + }); + + test('does not announce an initially connected tunnel', async () => { + const testDisposables = store.add(new DisposableStore()); + const remoteTunnelService = testDisposables.add(new TestRemoteTunnelService()); + remoteTunnelService.mode = { + active: true, + asService: false, + session: { providerId: 'github', sessionId: 'session', accountLabel: 'Account' }, + }; + remoteTunnelService.status = { + type: 'connected', + info: { tunnelName: 'my-tunnel', isAttached: false }, + serviceInstallFailed: false, + }; + + const action = testDisposables.add(new Action('test.toggleRemoteConnections', 'Toggle Remote Connections')); + const viewItem = testDisposables.add(new ToggleRemoteConnectionsActionViewItem( + action, + remoteTunnelService, + NullHoverService, + new class extends mock() { }(), + )); + const container = document.createElement('div'); + viewItem.render(container); + await timeout(0); + + const toast = container.querySelector('.tunnel-host-toast'); + assert.deepStrictEqual({ + sharing: container.classList.contains('sharing'), + toastVisible: toast?.classList.contains('visible') ?? false, + }, { + sharing: true, + toastVisible: false, + }); + }); + + test('executes the Remote Tunnel turn-on and turn-off commands', async () => { + const activeMode: ActiveTunnelMode = { + active: true, + asService: false, + session: { providerId: 'github', sessionId: 'session', accountLabel: 'Account' }, + }; + const remoteTunnelService = new TestRemoteTunnelService(); + const commandService = new TestCommandService(); + + await executeToggleRemoteConnections(remoteTunnelService, commandService); + remoteTunnelService.mode = activeMode; + remoteTunnelService.status = { + type: 'connected', + info: { tunnelName: 'my-tunnel', isAttached: false }, + serviceInstallFailed: false, + }; + await executeToggleRemoteConnections(remoteTunnelService, commandService); + + assert.deepStrictEqual(commandService.commands, [ + { id: 'workbench.remoteTunnel.actions.turnOn', args: [] }, + { id: 'workbench.remoteTunnel.actions.turnOff', args: [] }, + ]); + }); + + test('passes the Agents tunnel start constraints only when requested', async () => { + const remoteTunnelService = new TestRemoteTunnelService(); + const commandService = new TestCommandService(); + + await executeToggleRemoteConnections(remoteTunnelService, commandService, { + authenticationProviderId: 'github', + showServiceOption: false, + }); + + assert.deepStrictEqual(commandService.commands, [{ + id: 'workbench.remoteTunnel.actions.turnOn', + args: [{ authenticationProviderId: 'github', showServiceOption: false }], + }]); + }); + + test('renames a tunnel through quick input and persists the hostname override', async () => { + const quickInputService = new TestQuickInputService(); + const configurationService = new TestConfigurationService(); + quickInputService.result = 'renamed-tunnel'; + + await promptToRenameRemoteTunnel(quickInputService, configurationService, 'old-tunnel'); + + assert.deepStrictEqual({ + input: { + title: quickInputService.options?.title, + value: quickInputService.options?.value, + placeHolder: quickInputService.options?.placeHolder, + }, + updates: configurationService.updates, + }, { + input: { + title: 'Rename Tunnel', + value: 'old-tunnel', + placeHolder: 'Leave blank to use this machine\'s host name.', + }, + updates: [{ key: CONFIGURATION_KEY_HOST_NAME, value: 'renamed-tunnel', target: ConfigurationTarget.USER }], + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/electron-browser/tunnelHostService.test.ts b/src/vs/workbench/contrib/chat/test/electron-browser/tunnelHostService.test.ts deleted file mode 100644 index c8adaf2151bfc4..00000000000000 --- a/src/vs/workbench/contrib/chat/test/electron-browser/tunnelHostService.test.ts +++ /dev/null @@ -1,200 +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 { DeferredPromise } from '../../../../../base/common/async.js'; -import { Event } from '../../../../../base/common/event.js'; -import type { IChannel } from '../../../../../base/parts/ipc/common/ipc.js'; -import { mock } from '../../../../../base/test/common/mock.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { AuthenticationSession, IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; -import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; -import { NullLoggerService } from '../../../../../platform/log/common/log.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { ITunnelHostInfo, TunnelHostStatus } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; -import { TestSharedProcessService } from '../../../../test/electron-browser/workbenchTestServices.js'; -import { TUNNEL_HOST_SHARING_PREFERENCE_KEY, TunnelHostService } from '../../electron-browser/tunnelHostService.js'; - -class TestTunnelHostChannel implements IChannel { - - readonly calls: Array<{ command: string; args: unknown[] }> = []; - failStop = false; - failStart = false; - private readonly _status = new DeferredPromise(); - - constructor(status: TunnelHostStatus | undefined = { active: false }) { - if (status) { - this._status.complete(status); - } - } - - resolveStatus(status: TunnelHostStatus): void { - this._status.complete(status); - } - - call(command: string, args: unknown[] = []): Promise { - this.calls.push({ command, args }); - switch (command) { - case 'getStatus': - return this._status.p as Promise; - case 'startHosting': - if (this.failStart) { - return Promise.reject(new Error('Unable to start hosting')); - } - return Promise.resolve(({ tunnelName: 'test-tunnel' } satisfies ITunnelHostInfo) as T); - case 'stopHosting': - if (this.failStop) { - return Promise.reject(new Error('Unable to stop hosting')); - } - return Promise.resolve(undefined as T); - default: - throw new Error(`Unexpected command: ${command}`); - } - } - - listen(_event: string): Event { - return Event.None; - } -} - -suite('TunnelHostService', () => { - const disposables = ensureNoDisposablesAreLeakedInTestSuite(); - - function createService( - storageService: InMemoryStorageService, - channel: TestTunnelHostChannel, - sessions: readonly AuthenticationSession[] = [{ - id: 'test-session', - accessToken: 'test-token', - account: { label: 'test', id: 'test' }, - scopes: ['tunnel'], - }], - ): { service: TunnelHostService; createSessionCalls: () => number } { - const sharedProcessService = new class extends TestSharedProcessService { - override getChannel(): IChannel { - return channel; - } - }; - let createSessionCalls = 0; - const authenticationService = new class extends mock() { - override getSessions(): Promise { - return Promise.resolve([...sessions]); - } - - override createSession(): Promise { - createSessionCalls++; - return Promise.resolve({ - id: 'created-session', - accessToken: 'created-token', - account: { label: 'test', id: 'test' }, - scopes: ['tunnel'], - }); - } - }; - const productService = new class extends mock() { - override readonly tunnelApplicationConfig = { - authenticationProviders: { - github: { scopes: ['tunnel'] }, - }, - editorWebUrl: 'https://example.test', - extension: { - friendlyName: 'Remote Tunnels', - extensionId: 'ms-vscode.remote-server', - }, - }; - }; - - return { - service: disposables.add(new TunnelHostService( - sharedProcessService, - authenticationService, - productService, - new TestConfigurationService(), - disposables.add(new NullLoggerService()), - { logsHome: URI.file('/logs') } as IEnvironmentService, - storageService, - )), - createSessionCalls: () => createSessionCalls, - }; - } - - test('restores sharing enabled by the user and clears it before stopping', async () => { - const storageService = disposables.add(new InMemoryStorageService()); - const initiallyDisabledChannel = new TestTunnelHostChannel(); - const initiallyDisabledService = createService(storageService, initiallyDisabledChannel); - - await initiallyDisabledService.service.startSharing(); - const initialMachineKeys = storageService.keys(StorageScope.APPLICATION, StorageTarget.MACHINE); - - const restoredChannel = new TestTunnelHostChannel(); - const restoredService = createService(storageService, restoredChannel); - await Event.toPromise(Event.filter(restoredService.service.onDidChangeStatus, () => restoredService.service.isSharing)); - - restoredChannel.failStop = true; - await assert.rejects(restoredService.service.stopSharing(), /Unable to stop hosting/); - - assert.deepStrictEqual({ - initialStartCalls: initiallyDisabledChannel.calls.filter(call => call.command === 'startHosting').length, - restoredStartCalls: restoredChannel.calls.filter(call => call.command === 'startHosting').length, - isSharing: restoredService.service.isSharing, - preference: storageService.getBoolean(TUNNEL_HOST_SHARING_PREFERENCE_KEY, StorageScope.APPLICATION, false), - initialMachineKeys, - userKeys: storageService.keys(StorageScope.APPLICATION, StorageTarget.USER), - }, { - initialStartCalls: 1, - restoredStartCalls: 1, - isSharing: true, - preference: false, - initialMachineKeys: [TUNNEL_HOST_SHARING_PREFERENCE_KEY], - userKeys: [], - }); - }); - - test('restores sharing without prompting for authentication', async () => { - const storageService = disposables.add(new InMemoryStorageService()); - storageService.store(TUNNEL_HOST_SHARING_PREFERENCE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - const channel = new TestTunnelHostChannel(undefined); - const service = createService(storageService, channel, []); - const initialized = Event.toPromise(Event.filter(service.service.onDidChangeStatus, () => !service.service.isConnecting)); - - channel.resolveStatus({ active: false }); - await initialized; - - assert.deepStrictEqual({ - startHostingCalls: channel.calls.filter(call => call.command === 'startHosting').length, - createSessionCalls: service.createSessionCalls(), - preference: storageService.getBoolean(TUNNEL_HOST_SHARING_PREFERENCE_KEY, StorageScope.APPLICATION, false), - }, { - startHostingCalls: 0, - createSessionCalls: 0, - preference: true, - }); - }); - - test('preserves sharing preference when restarting after a configuration change', async () => { - const storageService = disposables.add(new InMemoryStorageService()); - const channel = new TestTunnelHostChannel(); - const service = createService(storageService, channel); - - await service.service.startSharing(); - channel.failStart = true; - await assert.rejects(service.service.restartSharing(), /Unable to start hosting/); - - assert.deepStrictEqual({ - stopHostingCalls: channel.calls.filter(call => call.command === 'stopHosting').length, - startHostingCalls: channel.calls.filter(call => call.command === 'startHosting').length, - preference: storageService.getBoolean(TUNNEL_HOST_SHARING_PREFERENCE_KEY, StorageScope.APPLICATION, false), - machineKeys: storageService.keys(StorageScope.APPLICATION, StorageTarget.MACHINE), - }, { - stopHostingCalls: 1, - startHostingCalls: 2, - preference: true, - machineKeys: [TUNNEL_HOST_SHARING_PREFERENCE_KEY], - }); - }); -}); diff --git a/src/vs/workbench/contrib/remoteTunnel/electron-browser/remoteTunnel.contribution.ts b/src/vs/workbench/contrib/remoteTunnel/electron-browser/remoteTunnel.contribution.ts index 5d9e7c58d8fe54..56b33f186805d5 100644 --- a/src/vs/workbench/contrib/remoteTunnel/electron-browser/remoteTunnel.contribution.ts +++ b/src/vs/workbench/contrib/remoteTunnel/electron-browser/remoteTunnel.contribution.ts @@ -14,6 +14,7 @@ import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; @@ -26,7 +27,7 @@ import { IProductService } from '../../../../platform/product/common/productServ import { IProgress, IProgressService, IProgressStep, ProgressLocation } from '../../../../platform/progress/common/progress.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator, QuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { CONFIGURATION_KEY_HOST_NAME, CONFIGURATION_KEY_PREFIX, CONFIGURATION_KEY_PREVENT_SLEEP, ConnectionInfo, INACTIVE_TUNNEL_MODE, IRemoteTunnelService, IRemoteTunnelSession, LOGGER_NAME, LOG_ID, TunnelStatus } from '../../../../platform/remoteTunnel/common/remoteTunnel.js'; +import { CONFIGURATION_KEY_HOST_NAME, CONFIGURATION_KEY_PREFIX, CONFIGURATION_KEY_PREVENT_SLEEP, ConnectionInfo, INACTIVE_TUNNEL_MODE, IRemoteTunnelService, IRemoteTunnelSession, LOGGER_NAME, LOG_ID, MAX_TUNNEL_NAME_LENGTH, TunnelStatus } from '../../../../platform/remoteTunnel/common/remoteTunnel.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService, isUntitledWorkspace } from '../../../../platform/workspace/common/workspace.js'; import { IWorkbenchContribution, IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../../common/contributions.js'; @@ -58,17 +59,27 @@ type ExistingSessionItem = { session: AuthenticationSession; providerId: string; type IAuthenticationProvider = { id: string; scopes: string[] }; type AuthenticationProviderOption = IQuickPickItem & { provider: IAuthenticationProvider }; -enum RemoteTunnelCommandIds { - turnOn = 'workbench.remoteTunnel.actions.turnOn', - turnOff = 'workbench.remoteTunnel.actions.turnOff', - connecting = 'workbench.remoteTunnel.actions.connecting', - manage = 'workbench.remoteTunnel.actions.manage', - showLog = 'workbench.remoteTunnel.actions.showLog', - configure = 'workbench.remoteTunnel.actions.configure', - copyToClipboard = 'workbench.remoteTunnel.actions.copyToClipboard', - learnMore = 'workbench.remoteTunnel.actions.learnMore', +export interface IRemoteTunnelStartOptions { + readonly authenticationProviderId?: 'github'; + readonly showServiceOption?: boolean; + readonly showSuccessNotification?: boolean; } +/** Matches `is_valid_name` in the CLI's `cli/src/tunnels/dev_tunnels.rs`. */ +const TUNNEL_NAME_REGEX = /^[\w-]+$/; + +export const RemoteTunnelCommandIds = { + turnOn: 'workbench.remoteTunnel.actions.turnOn', + turnOff: 'workbench.remoteTunnel.actions.turnOff', + connecting: 'workbench.remoteTunnel.actions.connecting', + manage: 'workbench.remoteTunnel.actions.manage', + showLog: 'workbench.remoteTunnel.actions.showLog', + configure: 'workbench.remoteTunnel.actions.configure', + rename: 'workbench.remoteTunnel.actions.rename', + copyToClipboard: 'workbench.remoteTunnel.actions.copyToClipboard', + learnMore: 'workbench.remoteTunnel.actions.learnMore', +} as const; + // name shown in nofications namespace RemoteTunnelCommandLabels { export const turnOn = localize('remoteTunnel.actions.turnOn', 'Turn on Remote Tunnel Access...'); @@ -79,6 +90,35 @@ namespace RemoteTunnelCommandLabels { export const learnMore = localize('remoteTunnel.actions.learnMore', 'Get Started with Tunnels'); } +export async function promptToRenameRemoteTunnel( + quickInputService: IQuickInputService, + configurationService: IConfigurationService, + currentName: string | undefined, +): Promise { + const name = await quickInputService.input({ + title: localize('renameTunnel.title', "Rename Tunnel"), + prompt: localize('renameTunnel.prompt', "Enter a name for this tunnel."), + value: currentName, + placeHolder: localize('renameTunnel.placeholder', "Leave blank to use this machine's host name."), + validateInput: async input => { + if (input.length === 0) { + return undefined; + } + if (input.length > MAX_TUNNEL_NAME_LENGTH) { + return localize('renameTunnel.maxLength', "The name must not be longer than {0} characters.", MAX_TUNNEL_NAME_LENGTH); + } + if (!TUNNEL_NAME_REGEX.test(input) || input.startsWith('-')) { + return localize('renameTunnel.invalidName', "The name must only consist of letters, numbers, underscore and dash. It must not start with a dash."); + } + return undefined; + }, + }); + + if (name !== undefined) { + await configurationService.updateValue(CONFIGURATION_KEY_HOST_NAME, name || undefined, ConfigurationTarget.USER); + } +} + export class RemoteTunnelWorkbenchContribution extends Disposable implements IWorkbenchContribution { @@ -290,7 +330,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo return session.session.accessToken || session.session.idToken; } - private async startTunnel(asService: boolean): Promise { + private async startTunnel(asService: boolean, authenticationProviderId?: 'github'): Promise { if (this.connectionInfo) { return this.connectionInfo; } @@ -301,7 +341,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo for (let i = 0; i < INVALID_TOKEN_RETRIES; i++) { tokenProblems = false; - const authenticationSession = await this.getAuthenticationSession(); + const authenticationSession = await this.getAuthenticationSession(authenticationProviderId); if (authenticationSession === undefined) { this.logger.info('No authentication session available, not starting tunnel'); return undefined; @@ -371,14 +411,19 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo return undefined; } - private async getAuthenticationSession(): Promise { - const sessions = await this.getAllSessions(); + private async getAuthenticationSession(authenticationProviderId?: 'github'): Promise { + if (authenticationProviderId) { + return this.getAuthenticationSessionForProvider(authenticationProviderId); + } + + const authenticationProviders = await this.getAuthenticationProviders(); + const sessions = await this.getAllSessions(authenticationProviders); const disposables = new DisposableStore(); const quickpick = disposables.add(this.quickInputService.createQuickPick({ useSeparators: true })); quickpick.ok = false; quickpick.placeholder = localize('accountPreference.placeholder', "Sign in to an account to enable remote access"); quickpick.ignoreFocusOut = true; - quickpick.items = await this.createQuickpickItems(sessions); + quickpick.items = await this.createQuickpickItems(sessions, authenticationProviders); return new Promise((resolve, reject) => { disposables.add(quickpick.onDidHide((e) => { @@ -403,6 +448,23 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo }); } + private async getAuthenticationSessionForProvider(providerId: 'github'): Promise { + const provider = (await this.getAuthenticationProviders()).find(provider => provider.id === providerId); + if (!provider) { + return undefined; + } + + const session = (await this.getAllSessions([provider]))[0]; + if (session) { + return session; + } + + return this.createExistingSessionItem( + await this.authenticationService.createSession(provider.id, provider.scopes), + provider.id + ); + } + private createExistingSessionItem(session: AuthenticationSession, providerId: string): ExistingSessionItem { return { label: session.account.label, @@ -412,7 +474,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo }; } - private async createQuickpickItems(sessions: ExistingSessionItem[]): Promise<(ExistingSessionItem | AuthenticationProviderOption | IQuickPickSeparator | IQuickPickItem & { canceledAuthentication: boolean })[]> { + private async createQuickpickItems(sessions: ExistingSessionItem[], authenticationProviders: readonly IAuthenticationProvider[]): Promise<(ExistingSessionItem | AuthenticationProviderOption | IQuickPickSeparator | IQuickPickItem & { canceledAuthentication: boolean })[]> { const options: (ExistingSessionItem | AuthenticationProviderOption | IQuickPickSeparator | IQuickPickItem & { canceledAuthentication: boolean })[] = []; if (sessions.length) { @@ -421,7 +483,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo options.push({ type: 'separator', label: localize('others', "Others") }); } - for (const authenticationProvider of (await this.getAuthenticationProviders())) { + for (const authenticationProvider of authenticationProviders) { const signedInForProvider = sessions.some(account => account.providerId === authenticationProvider.id); const provider = this.authenticationService.getProvider(authenticationProvider.id); if (!signedInForProvider || provider.supportsMultipleAccounts) { @@ -435,13 +497,12 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo /** * Returns all authentication sessions available from {@link getAuthenticationProviders}. */ - private async getAllSessions(): Promise { - const authenticationProviders = await this.getAuthenticationProviders(); + private async getAllSessions(authenticationProviders?: readonly IAuthenticationProvider[]): Promise { const accounts = new Map(); const currentAccount = await this.remoteTunnelService.getMode(); let currentSession: ExistingSessionItem | undefined; - for (const provider of authenticationProviders) { + for (const provider of authenticationProviders ?? await this.getAuthenticationProviders()) { const sessions = await this.authenticationService.getSessions(provider.id, provider.scopes); for (const session of sessions) { @@ -512,7 +573,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo }); } - async run(accessor: ServicesAccessor) { + async run(accessor: ServicesAccessor, options?: IRemoteTunnelStartOptions) { const notificationService = accessor.get(INotificationService); const clipboardService = accessor.get(IClipboardService); const commandService = accessor.get(ICommandService); @@ -534,60 +595,67 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo storageService.store(REMOTE_TUNNEL_PROMPTED_PREVIEW_STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.USER); } - const disposables = new DisposableStore(); - const quickPick = quickInputService.createQuickPick(); - quickPick.placeholder = localize('tunnel.enable.placeholder', 'Select how you want to enable access'); - quickPick.items = [ - { service: false, label: localize('tunnel.enable.session', 'Turn on for this session'), description: localize('tunnel.enable.session.description', 'Run whenever {0} is open', productService.nameShort) }, - { service: true, label: localize('tunnel.enable.service', 'Install as a service'), description: localize('tunnel.enable.service.description', 'Run whenever you\'re logged in') } - ]; - - const asService = await new Promise(resolve => { - disposables.add(quickPick.onDidAccept(() => resolve(quickPick.selectedItems[0]?.service))); - disposables.add(quickPick.onDidHide(() => resolve(undefined))); - quickPick.show(); - }); - - quickPick.dispose(); + const asService = options?.showServiceOption === false + ? false + : await new Promise(resolve => { + const disposables = new DisposableStore(); + const quickPick = disposables.add(quickInputService.createQuickPick()); + quickPick.placeholder = localize('tunnel.enable.placeholder', 'Select how you want to enable access'); + quickPick.items = [ + { service: false, label: localize('tunnel.enable.session', 'Turn on for this session'), description: localize('tunnel.enable.session.description', 'Run whenever {0} is open', productService.nameShort) }, + { service: true, label: localize('tunnel.enable.service', 'Install as a service'), description: localize('tunnel.enable.service.description', 'Run whenever you\'re logged in') } + ]; + disposables.add(quickPick.onDidAccept(() => { + resolve(quickPick.selectedItems[0]?.service); + quickPick.hide(); + })); + disposables.add(quickPick.onDidHide(() => { + disposables.dispose(); + resolve(undefined); + })); + quickPick.show(); + }); if (asService === undefined) { return; // no-op } - const connectionInfo = await that.startTunnel(/* installAsService= */ asService); + const connectionInfo = await that.startTunnel(/* installAsService= */ asService, options?.authenticationProviderId); if (connectionInfo) { - const remoteExtension = that.serverConfiguration.extension; - if (connectionInfo.link && connectionInfo.domain) { - const linkToOpen = that.getLinkToOpen(connectionInfo.link); - const linkToOpenForMarkdown = linkToOpen.toString(false).replace(/\)/g, '%29'); - notificationService.notify({ - severity: Severity.Info, - message: - localize( - { - key: 'progress.turnOn.final', - comment: ['{0} will be the tunnel name, {1} will the link address to the web UI, {6} an extension name, {7} a link to the extension documentation. [label](command:commandId) is a markdown link. Only translate the label, do not modify the format'] - }, - "You can now access this machine anywhere via the secure tunnel [{0}](command:{4}). To connect via a different machine, use the generated [{1}]({2}) link or use the [{6}]({7}) extension in the desktop or web. You can [configure](command:{3}) or [turn off](command:{5}) this access via the VS Code Accounts menu.", - connectionInfo.tunnelName, connectionInfo.domain, linkToOpenForMarkdown, RemoteTunnelCommandIds.manage, RemoteTunnelCommandIds.configure, RemoteTunnelCommandIds.turnOff, remoteExtension.friendlyName, 'https://code.visualstudio.com/docs/remote/tunnels' - ), - actions: { - primary: [ - toAction({ id: 'copyToClipboard', label: localize('action.copyToClipboard', "Copy Browser Link to Clipboard"), run: () => clipboardService.writeText(linkToOpen.toString(true)) }), - toAction({ - id: 'showExtension', label: localize('action.showExtension', "Show Extension"), run: () => { - return commandService.executeCommand('workbench.extensions.action.showExtensionsWithIds', [remoteExtension.extensionId]); - } - }) - ] - } - }); - } else { - notificationService.notify({ - severity: Severity.Info, - message: localize('progress.turnOn.final.noLink', "Remote Tunnel Access is enabled for {0}. You can [configure](command:{1}) or [turn off](command:{2}) this access via the VS Code Accounts menu.", connectionInfo.tunnelName, RemoteTunnelCommandIds.configure, RemoteTunnelCommandIds.turnOff), - }); + if (options?.showSuccessNotification !== false) { + const remoteExtension = that.serverConfiguration.extension; + if (connectionInfo.link && connectionInfo.domain) { + const linkToOpen = that.getLinkToOpen(connectionInfo.link); + const linkToOpenForMarkdown = linkToOpen.toString(false).replace(/\)/g, '%29'); + notificationService.notify({ + severity: Severity.Info, + message: + localize( + { + key: 'progress.turnOn.final', + comment: ['{0} will be the tunnel name, {1} will the link address to the web UI, {6} an extension name, {7} a link to the extension documentation. [label](command:commandId) is a markdown link. Only translate the label, do not modify the format'] + }, + "You can now access this machine anywhere via the secure tunnel [{0}](command:{4}). To connect via a different machine, use the generated [{1}]({2}) link or use the [{6}]({7}) extension in the desktop or web. You can [configure](command:{3}) or [turn off](command:{5}) this access via the VS Code Accounts menu.", + connectionInfo.tunnelName, connectionInfo.domain, linkToOpenForMarkdown, RemoteTunnelCommandIds.manage, RemoteTunnelCommandIds.configure, RemoteTunnelCommandIds.turnOff, remoteExtension.friendlyName, 'https://code.visualstudio.com/docs/remote/tunnels' + ), + actions: { + primary: [ + toAction({ id: 'copyToClipboard', label: localize('action.copyToClipboard', "Copy Browser Link to Clipboard"), run: () => clipboardService.writeText(linkToOpen.toString(true)) }), + toAction({ + id: 'showExtension', label: localize('action.showExtension', "Show Extension"), run: () => { + return commandService.executeCommand('workbench.extensions.action.showExtensionsWithIds', [remoteExtension.extensionId]); + } + }) + ] + } + }); + } else { + notificationService.notify({ + severity: Severity.Info, + message: localize('progress.turnOn.final.noLink', "Remote Tunnel Access is enabled for {0}. You can [configure](command:{1}) or [turn off](command:{2}) this access via the VS Code Accounts menu.", connectionInfo.tunnelName, RemoteTunnelCommandIds.configure, RemoteTunnelCommandIds.turnOff), + }); + } } const usedOnHostMessage: UsedOnHostMessage = { hostName: connectionInfo.tunnelName, timeStamp: new Date().getTime() }; storageService.store(REMOTE_TUNNEL_USED_STORAGE_KEY, JSON.stringify(usedOnHostMessage), StorageScope.APPLICATION, StorageTarget.USER); @@ -622,6 +690,28 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo } })); + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: RemoteTunnelCommandIds.rename, + title: localize2('remoteTunnel.actions.rename', 'Rename Tunnel'), + category: REMOTE_TUNNEL_CATEGORY, + menu: [{ + id: MenuId.CommandPalette, + when: ContextKeyExpr.notEquals(REMOTE_TUNNEL_CONNECTION_STATE_KEY, ''), + }] + }); + } + + async run(accessor: ServicesAccessor) { + await promptToRenameRemoteTunnel( + accessor.get(IQuickInputService), + accessor.get(IConfigurationService), + that.connectionInfo?.tunnelName ?? await that.remoteTunnelService.getTunnelName(), + ); + } + })); + this._register(registerAction2(class extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/contrib/remoteTunnel/test/electron-browser/remoteTunnel.contribution.test.ts b/src/vs/workbench/contrib/remoteTunnel/test/electron-browser/remoteTunnel.contribution.test.ts new file mode 100644 index 00000000000000..38d0538a192abd --- /dev/null +++ b/src/vs/workbench/contrib/remoteTunnel/test/electron-browser/remoteTunnel.contribution.test.ts @@ -0,0 +1,224 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ITunnelApplicationConfig } from '../../../../../base/common/product.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 { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { INativeEnvironmentService } from '../../../../../platform/environment/common/environment.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { NullLoggerService } from '../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IProgress, IProgressService, IProgressStep } from '../../../../../platform/progress/common/progress.js'; +import { IQuickInputService, IQuickPick, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { INACTIVE_TUNNEL_MODE, IRemoteTunnelService, type ActiveTunnelMode, type TunnelStatus } from '../../../../../platform/remoteTunnel/common/remoteTunnel.js'; +import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IAuthenticationProvider, AuthenticationSession, IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { RemoteTunnelWorkbenchContribution } from '../../electron-browser/remoteTunnel.contribution.js'; + +const tunnelApplicationConfig: ITunnelApplicationConfig = { + authenticationProviders: { + github: { scopes: ['user:email'] }, + }, + editorWebUrl: '', + extension: { extensionId: 'ms-vscode.remote-server', friendlyName: 'Remote Tunnels' }, +}; + +const githubSession: AuthenticationSession = { + id: 'github-session', + accessToken: 'github-token', + account: { id: 'github-account', label: 'GitHub Account' }, + scopes: ['user:email'], +}; + +class TestAuthenticationService extends mock() { + override readonly declaredProviders = [{ id: 'github', label: 'GitHub' }]; + readonly requestedSessions: Array<{ providerId: string; scopes: readonly string[] | undefined }> = []; + readonly createdSessions: Array<{ providerId: string; scopes: readonly string[] }> = []; + + private readonly provider = new class extends mock() { + override readonly id = 'github'; + override readonly label = 'GitHub'; + override readonly supportsMultipleAccounts = false; + }; + + constructor(private readonly sessions: readonly AuthenticationSession[], private readonly createdSession = githubSession) { + super(); + } + + override getProvider(): IAuthenticationProvider { + return this.provider; + } + + override async getSessions(...[providerId, scopes]: Parameters): Promise { + this.requestedSessions.push({ providerId, scopes: Array.isArray(scopes) ? scopes : undefined }); + return this.sessions; + } + + override async createSession(...[providerId, scopes]: Parameters): Promise { + this.createdSessions.push({ providerId, scopes: Array.isArray(scopes) ? scopes : [] }); + return this.createdSession; + } +} + +class TestQuickInputService extends mock() { + createQuickPickCalls = 0; + + override createQuickPick(options: { useSeparators: true }): IQuickPick; + override createQuickPick(options?: { useSeparators: boolean }): IQuickPick; + override createQuickPick(): never { + this.createQuickPickCalls++; + throw new Error('Unexpected quick pick'); + } +} + +class TestRemoteTunnelService extends mock() { + override readonly onDidChangeTunnelStatus = Event.None; + override readonly onDidChangeMode = Event.None; + override readonly onDidTokenFailed = Event.None; + readonly startedModes: ActiveTunnelMode[] = []; + + override async getMode() { + return INACTIVE_TUNNEL_MODE; + } + + override async getTunnelStatus(): Promise { + return { type: 'disconnected' }; + } + + override async initialize(): Promise { + return { type: 'disconnected' }; + } + + override async startTunnel(mode: ActiveTunnelMode): Promise { + this.startedModes.push(mode); + return { + type: 'connected', + info: { tunnelName: 'test-tunnel', isAttached: false }, + serviceInstallFailed: false, + }; + } + + override async getTunnelName(): Promise { + return undefined; + } +} + +class TestEnvironmentService extends mock() { + override readonly logsHome = URI.parse('test:///logs'); +} + +class TestExtensionService extends mock() { + override async whenInstalledExtensionsRegistered(): Promise { + return true; + } + + override async getExtension() { + return undefined; + } +} + +class TestProgressService extends mock() { + override async withProgress(_options: Parameters[0], task: (progress: IProgress) => Promise): Promise { + return task({ report() { } }); + } +} + +class TestDialogService extends mock() { } +class TestCommandService extends mock() { } +class TestWorkspaceContextService extends mock() { } +class TestNotificationService extends mock() { } + +function createContribution(store: Pick, authenticationService: TestAuthenticationService, quickInputService: TestQuickInputService, remoteTunnelService: TestRemoteTunnelService): RemoteTunnelWorkbenchContribution { + return store.add(new RemoteTunnelWorkbenchContribution( + authenticationService, + new TestDialogService(), + new TestExtensionService(), + store.add(new MockContextKeyService()), + new class extends mock() { + override readonly tunnelApplicationName = 'Code'; + override readonly tunnelApplicationConfig = tunnelApplicationConfig; + }, + store.add(new InMemoryStorageService()), + store.add(new NullLoggerService()), + quickInputService, + new TestEnvironmentService(), + remoteTunnelService, + new TestCommandService(), + new TestWorkspaceContextService(), + new TestProgressService(), + new TestNotificationService(), + )); +} + +suite('RemoteTunnelWorkbenchContribution', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('starts Agents remote access with an existing GitHub session without an authentication quick pick', async () => { + const authenticationService = new TestAuthenticationService([githubSession]); + const quickInputService = new TestQuickInputService(); + const remoteTunnelService = new TestRemoteTunnelService(); + const contribution = createContribution(store, authenticationService, quickInputService, remoteTunnelService); + + await contribution['startTunnel'](false, 'github'); + + assert.deepStrictEqual({ + quickPickCalls: quickInputService.createQuickPickCalls, + requestedSessions: authenticationService.requestedSessions, + createdSessions: authenticationService.createdSessions, + startedModes: remoteTunnelService.startedModes, + }, { + quickPickCalls: 0, + requestedSessions: [{ providerId: 'github', scopes: ['user:email'] }], + createdSessions: [], + startedModes: [{ + active: true, + asService: false, + session: { + providerId: 'github', + sessionId: 'github-session', + token: 'github-token', + accountLabel: 'GitHub Account', + }, + }], + }); + }); + + test('signs in to GitHub directly for Agents remote access when no session exists', async () => { + const authenticationService = new TestAuthenticationService([]); + const quickInputService = new TestQuickInputService(); + const remoteTunnelService = new TestRemoteTunnelService(); + const contribution = createContribution(store, authenticationService, quickInputService, remoteTunnelService); + + await contribution['startTunnel'](false, 'github'); + + assert.deepStrictEqual({ + quickPickCalls: quickInputService.createQuickPickCalls, + createdSessions: authenticationService.createdSessions, + startedModes: remoteTunnelService.startedModes, + }, { + quickPickCalls: 0, + createdSessions: [{ providerId: 'github', scopes: ['user:email'] }], + startedModes: [{ + active: true, + asService: false, + session: { + providerId: 'github', + sessionId: 'github-session', + token: 'github-token', + accountLabel: 'GitHub Account', + }, + }], + }); + }); +}); diff --git a/src/vs/workbench/contrib/search/browser/searchChatContext.ts b/src/vs/workbench/contrib/search/browser/searchChatContext.ts index ca4bcf769dd8bc..3a2d5caca540a3 100644 --- a/src/vs/workbench/contrib/search/browser/searchChatContext.ts +++ b/src/vs/workbench/contrib/search/browser/searchChatContext.ts @@ -36,6 +36,8 @@ import { SymbolKinds } from '../../../../editor/common/languages.js'; import { isSupportedChatFileScheme } from '../../chat/common/constants.js'; import { IChatWidget } from '../../chat/browser/chat.js'; +export const MAX_CHAT_FILE_COMPLETION_RESULTS = 100; + export class SearchChatContextContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contributions.searchChatContextContribution'; @@ -234,7 +236,8 @@ export async function searchFilesAndFolders( token: CancellationToken | undefined, cacheKey: string | undefined, configurationService: IConfigurationService, - searchService: ISearchService + searchService: ISearchService, + maxResults?: number ): Promise<{ folders: URI[]; files: URI[] }> { const segmentMatchPattern = fuzzyMatch ? fuzzyMatchingGlobPattern(pattern) : continousMatchingGlobPattern(pattern); @@ -250,6 +253,7 @@ export async function searchFilesAndFolders( excludePattern: searchExcludePattern, sortByScore: true, ignoreGlobCase: true, + maxResults, }; let searchResult: ISearchComplete | undefined; diff --git a/src/vs/workbench/contrib/search/test/browser/searchChatContext.test.ts b/src/vs/workbench/contrib/search/test/browser/searchChatContext.test.ts new file mode 100644 index 00000000000000..b67fa91e824be4 --- /dev/null +++ b/src/vs/workbench/contrib/search/test/browser/searchChatContext.test.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IFileQuery, ISearchService } from '../../../../services/search/common/search.js'; +import { MAX_CHAT_FILE_COMPLETION_RESULTS, searchFilesAndFolders } from '../../browser/searchChatContext.js'; + +suite('Search Chat Context', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('limits file completion search results', async () => { + const instantiationService = store.add(new TestInstantiationService()); + let actualMaxResults: number | undefined; + instantiationService.stub(ISearchService, { + fileSearch: async (query: IFileQuery) => { + actualMaxResults = query.maxResults; + return { results: [], messages: [] }; + }, + }); + + await searchFilesAndFolders( + URI.file('/workspace'), + 'file', + true, + CancellationToken.None, + undefined, + new TestConfigurationService(), + instantiationService.get(ISearchService), + MAX_CHAT_FILE_COMPLETION_RESULTS + ); + + assert.strictEqual(actualMaxResults, 100); + }); +});